
    0i                    X   % S SK r S SKrS SKrS SKrS SKrS SKrS SKJr  S SKJ	r	Jr  S SKJr
  S SKJr  S SKJr  S SKrS SKrS SKrS SKrS SKJr  S SKJrJrJrJrJr  S S	KJrJrJrJ r   S S
K!J"r"  \RF                  " S5      r$\
" SS 5      r%\
" SS 5      r&S\'4S jr(Sr)Sr*\"" \)\*5      r!Sr+Sr,\-" 5       r.\-\/   \0S'   Sr1Sr2Sr3\Rh                  " \2SS9  \Rj                  " \Rl                  Rn                  S-   5      r8\" SS9r9\" 5       r: " S S5      r;\;" 5       r</ q=\>\R~                     \0S'   / q@\>\A   \0S '   \" 5       rBS! rC\C" 5         S"\/S\/4S# jrDS$\ES\'4S% jrFSfS&\R~                  S'\GS\ES-  4S( jjrHS&\R~                  4S) jrISgS&\R~                  S*\>\J\/\J4      S-  S\R~                  4S+ jjrKS,\R~                  S\A4S- jrLS.\>\R~                     S\>\A   4S/ jrMS0\/S1\/S\A4S2 jrNS.\>\R~                     S\>\A   4S3 jrOS4\R~                  S0\/S1\/S\'4S5 jrP " S6 S75      rQ\Q" 5       rRSSSS S8S8S9.rSS: rTS; rU\:R                  S<5      S=\4S> j5       rW\:R                  S?5      S=\4S@ j5       rXS.\>\R~                     4SA jrYSB rZS1\/S\J\A\'4   4SC jr[\:R                  SD5      SE 5       r]\:R                  SF5      SG 5       r^\:R                  SH5      SI\4SJ j5       r`\:R                  SK5      \" SSL94SM\/4SN jj5       ra\:R                  SO5      \" SSL94SM\/4SP jj5       rbSQrcSRrdSSreSTrf\:R                  SU\SV9SW 5       rg\:R                  SX\SV9SY 5       rh\:R                  SZ\SV9S[ 5       ri\:R                  S\\SV9\" SSL94SM\/4S] jj5       rj\:R                  S^5      \" SSL94SM\/4S_ jj5       rkS` rl\" SSL94SM\/4Sa jjrm\" SSL94SM\/4Sb jjrn\:R                  SH5      SI\4Sc j5       r`\:R                  SK5      \" SSL94SM\/4Sd jj5       ra\:R                  SO5      \" SSL94SM\/4Se jj5       rbg)h    N)ThreadPoolExecutor)datedatetime)time)BytesIO)Lock)Image)CookieFastAPI	WebSocketWebSocketDisconnectRequest)ResponseStreamingResponseHTMLResponseRedirectResponse)create_clientzAsia/Jakarta      returnc                      [         R                  " [        5      R                  5       n [        U s=:*  =(       a	    [
        :*  $ s  $ )z)Cek apakah sekarang dalam jam operasional)r   nowTIMEZONEr   OPERATION_STARTOPERATION_END)r   s    AC:\Users\A\Documents\GitHub\backendAbsensiFaceRecognition\main.pyis_operation_hoursr      s2    
,,x
 
%
%
'Cc22]2222    z(https://vtuamuvlfnouzekjhtsk.supabase.co.sb_publishable_0LJO9qBDgV29zXMPaS44Iw_3d4GP9uwzadmin@example.comadmin123active_sessions
   facesg?T)exist_okz#haarcascade_frontalface_default.xml   )max_workersc                   x    \ rS rSrSrS rS rS\\\4   4S jr	S\
R                  S-  4S jrS\S-  4S	 jrS
rg)FrameBuffer;   z9
Simpan frame terbaru di memory.
Thread-safe pakai Lock.
c                 h    S U l         S U l        / U l        / U l        [	        5       U l        S U l        g N)framedisplay_framer#   
face_cropsr   lock
jpeg_bytesselfs    r   __init__FrameBuffer.__init__@   s/    (,
04
,.F	(,r   c                     U R                      Xl        X l        X0l        X@l        XPl        S S S 5        g ! , (       d  f       g = fr+   )r/   r,   r-   r#   r.   r0   )r2   r,   r-   r#   r.   r0   s         r   updateFrameBuffer.updateH   s-    YYJ!.J(O(O YYs	   5
Ar   c                     U R                      [        U R                  5      U R                   Vs/ s H  oR	                  5       PM     sn4sSSS5        $ s  snf ! , (       d  f       g= f)z*Ambil snapshot face_crops + faces saat iniN)r/   listr#   r.   copy)r2   cs     r   get_latest_cropsFrameBuffer.get_latest_cropsP   sC    YY

#%H1ffh%HH Y%H Ys   #AA	AA
A)Nc                     U R                      U R                  b  U R                  R                  5       OSsSSS5        $ ! , (       d  f       g= f)z!Ambil snapshot raw frame saat iniN)r/   r,   r:   r1   s    r   get_latest_frameFrameBuffer.get_latest_frameU   s,    YY(,

(>4::??$D YYs   )A  
Ac                 h    U R                      U R                  sS S S 5        $ ! , (       d  f       g = fr+   )r/   r0   r1   s    r   get_jpegFrameBuffer.get_jpegZ   s    YY?? YYs   #
1)r-   r.   r#   r,   r0   r/   )__name__
__module____qualname____firstlineno____doc__r3   r6   tupler9   r<   npndarrayr?   bytesrB   __static_attributes__ r   r   r(   r(   ;   sO    -)I%d
"3 I
I"**t"3 I
#%$, #r   r(   KNOWN_ENCODINGSKNOWN_USERSc                  X    [         R                  S5      R                  S5      R                  SS5      R	                  5       n / n/ nU R
                   H  nUS   (       d  M  [        R                  R                  [        US   5      n[        R                  R                  U5      (       d  [        SU 35        Mj  [        R                  " U5      nUR                  S:X  a  UR                  SS5      nU H.  nUR!                  U5        UR!                  US	   US
   S.5        M0     M     ["           UqUqSSS5        [        S[)        U5       S[)        [+        S U 5       5      5       S35        g! , (       d  f       NC= f! [,         a  n[        SU 35         SnAgSnAff = f)z-Load semua enrolled faces dari DB + file .npyuserszid, user_name, face_fileis_enrolledT	face_fileu   ⚠️  File tidak ditemukan:    id	user_name)user_idrX   Nu   ✅ Loaded z encodings, c              3   *   #    U  H	  oS    v   M     g7f)rX   NrN   ).0us     r   	<genexpr>!load_all_faces.<locals>.<genexpr>   s     Cf\eWXkN\es   z useru   ❌ load_all_faces error: )supabasetableselecteqexecutedataospathjoin	FACES_DIRexistsprintrJ   loadndimreshapeappend
faces_lockrO   rP   lenset	Exception)rR   new_encodings	new_usersr\   rf   encses          r   load_all_facesrw   h   sk   %0NN7#V./Rt$WY	 	 	A[>77<<	1[>:D77>>$''6tf=>774=DyyA~||Ar*$$Q'   w!";"   & +O#K  	C./|CCf\eCf@f<g;hhmno	 Z  0*1#.//0s0   D2F 4E69<F 6
F F 
F)F$$F)namec                 j    [         R                  " SSU R                  5       R                  5       5      $ )Nz
[^a-z0-9_]_)resubstriplower)rx   s    r   normalize_namer      s$    66-djjl&8&8&:;;r   image_bytesc                      [         R                  " [        U 5      5      R                  5         g! [         a     gf = f)NTF)r	   openr   verifyrr   )r   s    r   is_valid_imager      s6    

7;'(//1 s   -0 
==r,   qualityc                     [         R                  " SU [         R                  U/5      u  p#U(       a  UR                  5       $ S $ )Nz.jpg)cv2imencodeIMWRITE_JPEG_QUALITYtobytes)r,   r   successencodeds       r   encode_to_jpegr      s6    ||FEC4L4Lg3VWG '7??1T1r   c                    [         R                  " U [         R                  5      n[        R	                  USSS9n/ nU H  u  pEpg[        SU-  5      n[        SXH-
  5      n	[        SXX-
  5      n
[        U R                  S   XF-   U-   5      n[        U R                  S   XW-   U-   5      nUR                  X
U2X24   5        M     X#4$ )z?
Deteksi wajah + crop.
Ini CEPAT (~5-15ms), aman di main loop.
g?   )scaleFactorminNeighborsg?r   rU   )
r   cvtColorCOLOR_BGR2GRAYface_cascadedetectMultiScaleintmaxminshapern   )r,   grayr#   r.   xywhpadx1y1x2y2s                r   detect_facesr      s    
 <<s112D))$Ca)PEJq#'lAG_AG_Q-Q-%2ru-.  r   labelsc           
      \   U R                  5       n[        U5       H  u  nu  pVpxU(       a  U[        U5      :  a  X$   u  pOSu  p[        R                  " X5U4XW-   Xh-   4U
S5        U	(       d  MT  [        R
                  " X9U[        US-
  S5      4[        R                  SU
S5        M     U$ )z[
Draw bounding box. Kalau labels None, box kuning polos.
labels: list of (text, color_bgr)
) )r      r   r%   r"      g333333?)r:   	enumeraterp   r   	rectangleputTextr   FONT_HERSHEY_SIMPLEX)r,   r#   r   displayir   r   r   r   textcolors              r   
draw_boxesr      s    
 jjlG$U+<A!a#f+o )KD%+KDg1vqu~ua@4KK3q2vr?';00#uaA , Nr   face_bgrc                 f   [            [        [        5      S:X  a  SS0sSSS5        $ [        [        5      n[        [        5      nSSS5        [
        R                  " U [
        R                  5      n[        R                  " U5      n[        U5      S:X  a  SS0$ US   n[        R                  " [        R                  " W5      U5      n[        [        R                  " U5      5      n[        Xg   5      nU[         ::  a  WU   n	SU	S   U	S   [#        US5      S	.$ SS0$ ! , (       d  f       N= f)
uA   
Identify 1 wajah. BLOCKING — panggil via asyncio.to_thread().
r   statusunknownN
recognizedrY   rX   r   )r   rY   rx   distance)ro   rp   rO   r9   rP   r   r   COLOR_BGR2RGBface_recognitionface_encodingsface_distancerJ   arrayr   argminfloat	THRESHOLDround)
r   encodings_snapshotusers_snapshotface_rgb	encodingsr   	distancesbest_idxbest_distanceusers
             r   identify_face_syncr      s    
1$i( 
 "/2k*	 
 ||Hc&7&78H //9I
9~)$$lG ..rxx8J/KWUI299Y'(H)-.M	!h'"I%mQ/	
 	
 i  7 
s   D"D""
D0r.   c                 T    / nU  H  n[        U5      nUR                  U5        M!     U$ )u9   
Recognize semua wajah. BLOCKING — jalankan di thread.
)r   rn   )r.   resultscropresults       r   run_recognition_syncr      s0     G#D)v  Nr   rY   rX   c                    [         R                  " 5       n[        R                  " 5       R	                  5       n [
        R                  S5      R                  S5      R                  SU 5      R                  SUR                  5       5      R                  5       nUR                  (       a  SUR                  5       S.$ [
        R                  S5      R                  U UUR                  5       UR                  S5      S.5      R                  5         S	UR                  5       UR                  S5      S
.$ ! [         a&  n[        SU 35        S[!        U5      S.s SnA$ SnAff = f)uX   
Record attendance ke DB. BLOCKING — jalankan di thread.
Cek duplikat di DB langsung.

attendancerW   rY   checkin_datealready_checked_in)r   r   z%H:%M:%S)rY   rX   r   checkin_time
checked_in)r   r   r   u   ❌ Attendance error: errorr   messageN)r   todayr   r   r   r_   r`   ra   rb   	isoformatrc   rd   insertstrftimerr   rj   str)rY   rX   r   r   existingrv   s         r   record_attendance_syncr     s   
 JJLE
,,.


C6NN<(VD\R	7#R 12WY 	 ==2EOO<MNN|$++"!OO-LL4	-
 	
 79 #OO%LL,
 	

  6&qc*+!c!f556s%   BD5 >A6D5 5
E%?E E% E%c                 n    [        U 5      nU H#  nUS   S:X  d  M  [        US   US   5      US'   M%     U$ )z^
Gabung: recognition + attendance dalam 1 blocking function.
Panggil via asyncio.to_thread().
r   r   rY   rx   r   )r   r   )r.   r   rs      r   do_recognition_and_attendancer   '  sE    
 #:.G X;,&4Qy\1V9MAlO  Nr   	face_cropc                    [         R                  " U [         R                  5      n[        R                  " U5      n[        U5      S:w  a  [        S[        U5       S35        g[        R                  " X45      nU(       d  [        S5        gUS   n[        U5      nU SU S3n[        R                  R                  [        U5      n	[        R                  R                  U	5      (       aP  [        R                  " U	5      n
U
R                   S:X  a  U
R#                  SS	5      n
[        R$                  " X/5      nO[        R&                  " U/5      n[        R(                  " X5        [        S
[        U5       SU S35        [*        R-                  S5      R/                  USS.5      R1                  SU5      R3                  5         g)uK   
Enroll 1 wajah + update DB. BLOCKING — panggil via asyncio.to_thread().
rU   u    ⚠️  Enroll gagal: ditemukan z wajahFu2   ⚠️  Enroll gagal: tidak bisa generate encodingr   rz   z.npyrV   u   ✅ Enroll #z untuk ''rR   T)rT   rS   rW   )r   r   r   r   face_locationsrp   rj   r   r   re   rf   rg   rh   ri   rJ   rk   rl   rm   vstackr   saver_   r`   r6   rb   rc   )r   rY   rX   r   r   r   new_encoding	safe_namefilenamerf   r   all_encs               r   do_enroll_syncr   6  so    ||Is'8'89H &44X>N
>a0^1D0EVLM //IIBCQ<L y)I!I;d+H77<<	8,D	ww~~d774===A''2.H))X45((L>*GGD	LWhyk
;< NN7""$  
r$r   c                   D    \ rS rSrS rS\4S jrS\4S jrS\4S jr	Sr
g	)
ConnectionManagerie  c                     / U l         g r+   active_connectionsr1   s    r   r3   ConnectionManager.__init__f  s
    35r   	websocketc                    #    UR                  5       I S h  vN   U R                  R                  U5        [        S[	        U R                  5       S35        g  NB7f)Nu   ✅ WS connected (total: ))acceptr   rn   rj   rp   r2   r   s     r   connectConnectionManager.connecti  sN        &&y1)#d.E.E*F)GqIJ 	!s   AAAAc                     XR                   ;   a>  U R                   R                  U5        [        S[        U R                   5       S35        g g )Nu   ❌ WS disconnected (total: r   )r   removerj   rp   r   s     r   
disconnectConnectionManager.disconnectn  sE    ///##**950T5L5L1M0NaPQ 0r   r   c                    #    / nU R                    H  n UR                  U5      I S h  vN   M     U H  nU R	                  U5        M     g  N#! [         a    UR                  U5         M\  f = f7fr+   )r   	send_textrr   rn   r   )r2   r   deadwss       r   broadcast_text ConnectionManager.broadcast_texts  sg     ))B ll7+++ *
 BOOB  ,  B s7   A1AAAA1AA.*A1-A..A1r   N)rD   rE   rF   rG   r3   r   r   r   r   r  rM   rN   r   r   r   r   e  s/    6Ky K
RI R
 C  r   r   F)moderY   rX   enroll_countcapture_requested
processingc                  p    S [         S'   S [         S'   S [         S'   S[         S'   S[         S'   S[         S'   g )	Nr  rY   rX   r   r  Fr  r  )SESSIONrN   r   r   reset_sessionr    s>    GFOGIGKGN#(G !GLr   c                     #    [        5       n  [        R                  " S5      I Sh  vN   [        5       nX:w  a8  U(       a  SOSn[        R	                  U5      I Sh  vN   [        SU 35        Un Mg   NM N7f)zv
Background task yang jalan terus untuk broadcast schedule changes.
Cek waktu tiap 30 detik, broadcast saat transisi.
   NitsWorkingTimenotWorkingTimeu   📢 Schedule broadcast: )r   asynciosleepmanagerr  rj   )
last_statecurrent_stater   s      r   schedule_broadcasterr    su     
 $%J
mmB*, &*7&=MG((111-gY78&J  2s!   %A9A56A9A7A97A9z
/ws/streamr   c                   #    [         R                  U 5      I S h  vN     U R                  5       I S h  vN nUS   S:X  a  GOUS   S:X  a  SU;   a  US   nU(       d  MA  [        R                  " U[        R
                  S9n[        R                  " U[        R                  5      nUc  M  [        U5      u  pV[        XE5      n[        U5      n[        R                  XGXVU5        [        S   S:X  a:  [        S	   (       a,  [        S
   (       d  [        R                   " [#        5       5        US   S:X  a  SU;   a  US   R%                  5       n	U	S:X  a=  ['        5       (       a  SOSn
U R)                  U
5      I S h  vN   [+        SU
 35        GMt  U	S:X  a@  [-        5         [         R/                  [0        R2                  " SS05      5      I S h  vN   GM  U	S:X  a$  [        S   S:X  a  S[        S	'   [+        S5        GM  GM  [         R7                  U 5        g  GN GN N NR! [4         a     N,f = f! [         R7                  U 5        f = f7f)NTtypewebsocket.disconnectwebsocket.receiverL   )dtyper  ENROLLr  r  r   isWorkingTime?r  r  "   📞 ESP32 query working time → stopcaptureu!   📸 Capture requested dari ESP32)r  r   receiverJ   
frombufferuint8r   imdecodeIMREAD_COLORr   r   r   bufferr6   r
  r  create_taskprocess_enroll_capturer}   r   r   rj   r  r  jsondumpsr   r   )r   r   raw	img_arrayr,   r#   r.   r   jpegr   responses              r   	ws_streamr.    s     
//)
$$$?&%--//Gv"88 v"55'W:Lg& MM#RXX>	Y0@0@A= %1$7! %U2 &g. eeF FOx//0-''(>(@A v"55&G:Kv,,. ++3E3G3G/M]H#--h777>xjIJ 6>!O!00VV<L1MNNN 9$H)D37G/0=>s | 	9%C % 0T 8 O   	9%s   IHIH( H!H( H8 D:H( ?H$ AH( H&4H( I!H( $H( &H( (
H52H8 4H55H8 8IIz/ws/cmdc                   #    [         R                  U 5      I S h  vN     U R                  5       I S h  vN nUS   S:X  a  GOUS   S:X  Ga  SU;   Ga  US   R                  5       n[	        SU S35        US:X  a?  [        5         [         R                  [        R                  " SS05      5      I S h  vN   M  US	:X  a<  [        5       (       a  S
OSnU R                  U5      I S h  vN   [	        SU 35        M  US:X  aC  [         R                  [        R                  " SSSS.5      5      I S h  vN   [	        S5        GM'  US:X  aC  [         R                  [        R                  " SSSS.5      5      I S h  vN   [	        S5        GMp  US:X  Gah  [        5       (       d_  [         R                  [        R                  " SSSSS.5      5      I S h  vN   [	        S5        [        R                  " S5      I S h  vN   [        R                  5       u  pE[        U5      S:X  a  U R                  [        R                  " SSSS .5      5      I S h  vN   [        5       (       d@  [	        S!5        [         R                  [        R                  " SSSS.5      5      I S h  vN   GM  U R                  [        R                  " S"S#S$.5      5      I S h  vN   [        R                   " [#        XE5      5        GM   [        R$                  " U5      nUR)                  S5      nUS%:X  Ga@  UR)                  S&5      =(       d    S'R                  5       nU(       d3  U R                  [        R                  " S(S)S$.5      5      I S h  vN   GMp  [*        R,                  " S*U5      (       d3  U R                  [        R                  " S(S+S$.5      5      I S h  vN   GM   [        R.                  " [0        U5      I S h  vN u  pU	R)                  S,5      nU(       a  [6        R8                  R;                  [<        U5      n[6        R8                  R?                  U5      (       a  [@        RB                  " U5      nURD                  S-:X  a  URG                  S-S.5      n[        U5      [H        :  aC  U R                  [        R                  " S(SU S/[        U5       S03S$.5      5      I S h  vN   GM  [        5         S1[J        S2'   U	S3   [J        S4'   U[J        S5'   S[J        S6'   [         R                  [        R                  " S7US[H        S8.5      5      I S h  vN   [	        S9U S35        OkUS::X  a"  [J        S2   S1:X  a  S[J        S;'   [	        S<5        OCUS=:X  a=  [        5         [         R                  [        R                  " SS05      5      I S h  vN   GM  [         RO                  U 5        g  GN GN GNC GN GN GN GN- GN GN GNh GN8! [        R&                   a     GM  f = f GN GN[ GN7! [2         aF  nU R                  [        R                  " S([5        U5      S$.5      5      I S h  vN     S nAGMc  S nAff = f GN GN5 N! [L         a     Nf = f! [         RO                  U 5        f = f7f)>NTr  r  r  r   u   📨 CMD: 'r   r  r  r  r  r  
ForceStartcommandesp32STARTr  targetactionu&   📞 ESP32 ForceStart command received	ForceStopSTOPu%   📞 ESP32 ForceStop command received	recognize
TEMP_STARTr"   )r  r5  r6  timeoutu"   ⏳ Waiting for stream to start...g      @r   recognize_resultno_facezTidak ada wajah terdeteksi)r  r   r   u1   🛑 Stopping temporary stream (no face detected)recognize_statuszMemproses...r  r   start_enrollnamar   r   zNama tidak boleh kosongz^[a-zA-Z0-9_ ]+$zNama tidak validrT   rU   rV   z' sudah ter-enroll (z encodings)r  r  rW   rY   rX   r  enroll_started)r  rx   counttotalu   🔵 Enroll started: 'r  r  u%   📸 Capture requested dari dashboardstop_enroll)(r  r   r   r}   rj   r  r  r(  r)  r   r   r  r  r%  r<   rp   r&  run_recognize_taskloadsJSONDecodeErrorgetr{   match	to_threadget_or_create_user_syncrr   r   re   rf   rg   rh   isfilerJ   rk   rl   rm   ENROLL_TARGETr
  r   r   )r   r   r   r-  r#   r.   rd   cmdrA  r   createdrv   rT   npy_pathr   s                  r   ws_cmdrR    s)    
//)
$$$h&%--//Gv"88v"55&G:Kv,,.D6+, 6>!O!00VV<L1MNNN++3E3G3G/M]H#--h777>xjIJ<'!00$-&-&-= 2   
 BD;&!00$-&-&,= 2   
 AC;&-//%44TZZ$-&-&2')	A 6    BC%mmC000(.(?(?(A%E:!+'11$**$6&/'C> 3   
  233!"UV")"8"8(1*1*0E : #     
 ! $--djj 2#1: /   
 ''*5= ::d+D hhy) .( HHV,299;D'11$**$+'@> 3    !88$7>>'11$**$+'9> 3    !!.5.?.?@WY].^(^ !% 5I !#i!Ch11%'WWX%6(%]]a/)1)9)9!R)@h ]m;$-$7$7

*1/06J3x=/Yd-eD  9! %" " " ' "O&.GFO)-dGI&+/GK(./GN+!00 0 $!"!.	= 2    24&:; I%v(27; 34EF M)!O!00VV<L1MNNNE N 	9%S % 0 O 8 1
  ++  )_$ !'11$**$+'*1v> 3    !! "." O  	9%s  Z>W3Z>Z W6Z Z$ A1Z 6W973Z *W<+A
Z 5W?6AZ >X?AZ X(Z XAZ XAZ &X'2Z X&Z X A2Z 	X0
AZ X3Z !X9 ?X6 X9 C&Z ,Z-A-Z ZA:Z ZZ Z>6Z 9Z <Z ?Z Z Z Z Z Z Z X-(Z ,X--Z 3Z 6X9 9
Z	4Z7Y:8Z=Z Z		Z Z Z 
Z!Z$  Z!!Z$ $Z;;Z>c           
      p  #     [         R                  " [        U5      I Sh  vN n/ nU H5  nUS   S:X  a  UR                  US   S45        M$  UR                  S5        M7     [        R                  5       nUbF  [        XPU5      n[        U5      n[        R                     U[        l	        U[        l
        SSS5        [        R                  [        R                  " SUS.5      5      I Sh  vN   [        S	U Vs/ s H  oDR!                  SS
5      PM     sn 35        [#        5       (       d6  [        R                  [        R                  " SSSS.5      5      I Sh  vN   gg GNG! , (       d  f       N= f Ns  snf  N!! [$         a  n[        SU 35        [        R                  [        R                  " SS['        U5       3S.5      5      I Sh  vN    [#        5       (       d;  [        R                  [        R                  " SSSS.5      5      I Sh  vN     SnAg SnAgSnAff = f7f)zARecognition + attendance di background thread, broadcast hasilnyaNr   r   rx   )r   r   r   )Unknown)r   r   r   r<  )r  r#   u   🎯 Recognize done: r   r1  r2  r8  r4  u   ❌ Recognition error: r   zRecognition error: r?  )r  rK  r   rn   r%  r?   r   r   r/   r-   r0   r  r  r(  r)  rj   rI  r   rr   r   )	r#   r.   r   r   r   r,   labeledr,  rv   s	            r   rF  rF    s    .))*GTT A{l*qy+6767	  '') v6G!'*D'.$$(! 
 $$TZZ&1
 &  	 	 	%&QAuuVY'?&Q%RST!##((##"5 *    $5 U  
	 'R  's+,$$TZZ,SVH51
 &  	 	 "##((!! 5 *    $s   H6F E)A?F "E,97F 0E=1F ?E?
AF "F#F 'H6)F ,
E:6F ?F 
H3A	H.GAH.H!H.$
H6.H33H6c            
      |  #    S[         S'   S[         S'    [        R                  5       u  p[        U5      S:w  aL  [        R                  [        R                  " SS[        U5       S3S	.5      5      I S
h  vN    S[         S'   g
[        R                  " [        US   [         S   [         S   5      I S
h  vN nU(       d?  [        R                  [        R                  " SSS	.5      5      I S
h  vN    S[         S'   g
[         S==   S-  ss'   [         S   n[        SU S[         35        [        R                  [        R                  " SU[        [         S   S.5      5      I S
h  vN   U[        :  a  [         S   n[        5         [        R                  " [        5      I S
h  vN   [        R                  [        R                  " SUSU S3S.5      5      I S
h  vN   [        R                  [        R                  " SS05      5      I S
h  vN   [        SU S35        S[         S'   g
 GN GN GNJ N N N] N,! [         aY  n[        SU 35        [        R                  [        R                  " SS[!        U5       3S	.5      5      I S
h  vN     S
nANyS
nAff = f! S[         S'   f = f7f)z%Proses 1 enroll capture di backgroundTr  Fr  rU   enroll_warningz
Ditemukan z wajah. Harus tepat 1.r?  Nr   rY   rX   zGagal enroll. Coba lagi.r  u   📸 Enroll progress: /enroll_progress)r  rC  rD  rx   enroll_donezEnroll 'z
' selesai!)r  rx   r   r  r  u   ✅ Enroll selesai: 'r   u   ❌ Enroll error: r   zEnroll error: )r
  r%  r<   rp   r  r  r(  r)  r  rK  r   rj   rN  r  rw   rr   r   )rz   r.   r   rC  rA  rv   s         r   r'  r'    s     GL#(G <&//1z?a((('J'88NO5 *    h !&c  ))qMIK 	
 
 (((55 *    J !&G 	1$'&ugQ}o>? $$TZZ%"K(	1
 &  	 	 M!;'DO ##N333((%%dV:65 *   
 ((VV4D)EFFF)$q12 !&q
	 4
 G  "1#&'$$TZZ'Ax01
 &  	 	 !&s   J<A!I 7H78I =
J<1I 8H:9:I 3H=4I 9
J<A+I .I />I -I.8I &I'2I II -
J<7I :I =I  I I I I 
J+A	J&JJ&!J. &J++J. .J99J<c                    [         R                  S5      R                  S5      R                  SU 5      R	                  5       nUR
                  (       a  UR
                  S   S4$ [         R                  S5      R                  U SS S.5      R	                  5       nUR
                  (       d  [        S5      eUR
                  S   S4$ )	NrR   *rX   r   F)rX   rS   rT   zGagal membuat userT)r_   r`   ra   rb   rc   rd   r   RuntimeError)rX   r   ress      r   rL  rL  &  s    w		K	#		  }}}}Q&&
..
!
(
(*  wy	  88/0088A;r   z/streamc                  &    S n [        U " 5       SS9$ )Nc               3      #     [         R                  5       n U (       a
  SU -   S-   v   [        R                  " S5        M=  7f)Ns%   --frame
Content-Type: image/jpeg

s   
g?)r%  rB   r   r  )r,  s    r   	generatorstream_video.<locals>.generator@  sF     ??$D8  JJt s   ?Az)multipart/x-mixed-replace; boundary=frame)
media_type)r   )ra  s    r   stream_videord  >  s    
 Y[5`aar   z/imagec                      [         R                  5       n U (       a
  [        U SS9$ [        R                  " SSSS9n[        5       nUR                  US5        [        UR                  5       SS9$ )Nz
image/jpegcontentrc  RGB)i@     )   rj  r  )r   JPEG)r%  rB   r   r	   newr   r   getvalue)r,  blankbufs      r   	get_imagerp  O  sX    ??D>>IIeZ|<E
)C	JJsFCLLN|DDr   z/auth/loginrequestc                   #    U R                  5       I S h  vN nUR                  S5      [        :X  ay  UR                  S5      [        :X  a`  [        R
                  " S5      n[        R                  U5        [        [         R                  " SS05      SS9nUR                  SUS	S
SS9  U$ [        [         R                  " SSS.5      SSS9$  N7f)Nemailpassword    r   r   application/jsonrf  session_tokenTlaxQ )httponlysamesitemax_ager   Invalid credentialsr     rg  status_coderc  )r(  rI  ADMIN_EMAILADMIN_PASSWORDsecrets	token_hexr!   addr   r)  
set_cookie)rq  bodytokenresps       r   loginr  ^  s     DxxK'DHHZ,@N,R!!"%E"JJ)45)
 	W\]

g:OPQ$6   s   CCB9Cz/auth/logout)defaultrw  c                 |   #    U (       a  [         R                  U 5        [        SSS9nUR                  S5        U$ 7f)N/login/  urlr  rw  r!   discardr   delete_cookie)rw  r  s     r   logoutr  o  s5     .c:D'Ks   :<z/auth/checkc                 v   #    U (       a  U [         ;   a  SS0$ [        [        R                  " SS05      SSS9$ 7f)NauthenticatedTFr~  rv  r  r!   r   r(  r)  rw  s    r   
check_authr  w  s>     /9&&

OU34$6    79u$  
    <!DOCTYPE html>
    <html lang="id">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Rekap Absensi Bulanan</title>
        <style>
            @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=Syne:wght@700&display=swap');

            * { margin: 0; padding: 0; box-sizing: border-box; }

            body {
                font-family: 'DM Sans', sans-serif;
                background: #0a0a0f;
                color: #e2e2e5;
                min-height: 100vh;
                padding: 30px 20px;
            }

            .topbar {
                max-width: 900px;
                margin: 0 auto 30px;
                display: flex;
                justify-content: space-between;
                align-items: center;
            }

            .topbar h1 {
                font-family: 'Syne', sans-serif;
                font-size: 22px;
                letter-spacing: -0.5px;
            }

            .admin-link {
                font-size: 13px;
                color: #555;
                text-decoration: none;
                padding: 8px 14px;
                border: 1px solid #222;
                border-radius: 6px;
                transition: all 0.2s;
            }

            .admin-link:hover {
                border-color: #444;
                color: #999;
            }

            .controls {
                max-width: 900px;
                margin: 0 auto 20px;
                display: flex;
                align-items: center;
                gap: 12px;
            }

            .controls label {
                font-size: 14px;
                color: #666;
            }

            select {
                padding: 8px 12px;
                background: #13131a;
                color: #e2e2e5;
                border: 1px solid #222;
                border-radius: 6px;
                cursor: pointer;
                font-size: 14px;
                font-family: inherit;
                min-width: 180px;
                outline: none;
                transition: border-color 0.2s;
            }

            select:focus { border-color: #444; }

            .table-wrapper {
                max-width: 900px;
                margin: 0 auto;
                background: #13131a;
                border: 1px solid #1e1e28;
                border-radius: 10px;
                overflow: hidden;
            }

            table { width: 100%; border-collapse: collapse; }

            th {
                background: #0f0f14;
                padding: 12px 16px;
                text-align: left;
                font-size: 11px;
                font-weight: 600;
                text-transform: uppercase;
                letter-spacing: 1px;
                color: #555;
                border-bottom: 1px solid #1e1e28;
            }

            td {
                padding: 14px 16px;
                border-bottom: 1px solid #1a1a22;
                font-size: 14px;
            }

            tr:last-child td { border-bottom: none; }

            tbody tr {
                cursor: pointer;
                transition: background 0.15s;
            }

            tbody tr:hover { background: #1a1a24; }

            .name-cell {
                color: #7eaaff;
                font-weight: 500;
            }

            .total-cell {
                font-weight: 600;
                color: #4ade80;
                text-align: center;
            }

            .summary {
                max-width: 900px;
                margin: 14px auto 0;
                font-size: 13px;
                color: #444;
                text-align: right;
            }

            .hint {
                max-width: 900px;
                margin: 10px auto 0;
                font-size: 12px;
                color: #333;
            }

            .empty-cell, .loading-cell {
                text-align: center;
                color: #444;
                padding: 40px;
                font-style: italic;
            }
        </style>
    </head>
    <body>

    <div class="topbar">
        <h1>📊 Rekap Absensi Bulanan</h1>
        <a href="/dashboard" class="admin-link">⚙️ Admin</a>
    </div>

    <div class="controls">
        <label for="month">Bulan:</label>
        <select id="month"></select>
    </div>

    <div class="table-wrapper">
        <table>
            <thead>
                <tr>
                    <th>Nama</th>
                    <th>Bulan</th>
                    <th style="text-align:center;">Total Absen</th>
                </tr>
            </thead>
            <tbody id="result-body">
                <tr><td colspan="3" class="loading-cell">Memuat data...</td></tr>
            </tbody>
        </table>
    </div>

    <div id="summary" class="summary" style="display:none;"></div>
    <div class="hint">💡 Klik pada nama untuk melihat detail absensi</div>

    <script>
        const SUPABASE_URL = "https://vtuamuvlfnouzekjhtsk.supabase.co";
        const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ0dWFtdXZsZm5vdXpla2podHNrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjY3NTkyMzksImV4cCI6MjA4MjMzNTIzOX0.wjWTw3kQVxpcj95LGsV9Ti3NrjGjFaqdmsqhROjLBWY";
        const TABLE = "attendance";
        const monthSelect = document.getElementById("month");

        function initMonthOptions() {
            const now = new Date();
            for (let i = 0; i < 12; i++) {
                const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
                const year = d.getFullYear();
                const month = String(d.getMonth() + 1).padStart(2, '0');
                const value = `${year}-${month}`;
                const label = d.toLocaleString("id-ID", { month: "long", year: "numeric" });
                const opt = document.createElement("option");
                opt.value = value;
                opt.textContent = label;
                monthSelect.appendChild(opt);
            }
        }

        async function loadMonthlyAttendance(month) {
            try {
                document.getElementById("result-body").innerHTML =
                    "<tr><td colspan='3' class='loading-cell'>Memuat data...</td></tr>";

                const [year, monthNum] = month.split("-");
                const start = `${year}-${monthNum}-01`;
                let nextMonthInt = parseInt(monthNum) + 1;
                let nextYear = year;
                let nextMonth = String(nextMonthInt).padStart(2, '0');
                if (nextMonthInt > 12) { nextMonth = '01'; nextYear = parseInt(year) + 1; }

                const url =
                    `${SUPABASE_URL}/rest/v1/${TABLE}` +
                    `?checkin_date=gte.${start}` +
                    `&checkin_date=lt.${nextYear}-${nextMonth}-01` +
                    `&select=user_name,checkin_date`;

                const res = await fetch(url, {
                    headers: {
                        apikey: SUPABASE_ANON_KEY,
                        Authorization: `Bearer ${SUPABASE_ANON_KEY}`
                    }
                });

                if (!res.ok) throw new Error(`HTTP ${res.status}`);
                const data = await res.json();

                const counts = {};
                data.forEach(row => {
                    if (row.user_name) counts[row.user_name] = (counts[row.user_name] || 0) + 1;
                });

                renderTable(counts, month);
            } catch (error) {
                document.getElementById("result-body").innerHTML =
                    `<tr><td colspan='3' class='empty-cell' style='color:#f44336;'>Gagal memuat data</td></tr>`;
            }
        }

        function renderTable(counts, month) {
            const tbody = document.getElementById("result-body");
            const summaryDiv = document.getElementById("summary");
            tbody.innerHTML = "";

            if (Object.keys(counts).length === 0) {
                tbody.innerHTML = "<tr><td colspan='3' class='empty-cell'>Tidak ada data untuk bulan ini</td></tr>";
                summaryDiv.style.display = "none";
                return;
            }

            const sorted = Object.entries(counts).sort((a, b) => a[0].localeCompare(b[0], 'id'));
            const [year, monthNum] = month.split("-");
            const monthName = new Date(year, parseInt(monthNum) - 1).toLocaleString("id-ID", { month: "long", year: "numeric" });

            let total = 0;
            sorted.forEach(([name, count]) => {
                total += count;
                const tr = document.createElement("tr");
                tr.innerHTML = `<td class="name-cell">${name}</td><td>${monthName}</td><td class="total-cell">${count}</td>`;
                tr.onclick = () => window.location.href = `/detail?name=${encodeURIComponent(name)}&month=${month}`;
                tbody.appendChild(tr);
            });

            summaryDiv.innerHTML = `${sorted.length} karyawan &middot; ${total} total absensi`;
            summaryDiv.style.display = "block";
        }

        monthSelect.addEventListener("change", () => loadMonthlyAttendance(monthSelect.value));
        initMonthOptions();
        loadMonthlyAttendance(monthSelect.value);
    </script>
    </body>
    </html>
    u!  
    <!DOCTYPE html>
    <html lang="id">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Detail Absensi</title>
        <style>
            @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=Syne:wght@700&display=swap');

            * { margin: 0; padding: 0; box-sizing: border-box; }

            body {
                font-family: 'DM Sans', sans-serif;
                background: #0a0a0f;
                color: #e2e2e5;
                min-height: 100vh;
                padding: 30px 20px;
            }

            .topbar {
                max-width: 900px;
                margin: 0 auto 24px;
                display: flex;
                align-items: center;
                gap: 14px;
            }

            .back-btn {
                font-size: 13px;
                color: #666;
                text-decoration: none;
                padding: 7px 12px;
                border: 1px solid #222;
                border-radius: 6px;
                transition: all 0.2s;
                white-space: nowrap;
            }

            .back-btn:hover { border-color: #444; color: #999; }

            .topbar h1 {
                font-family: 'Syne', sans-serif;
                font-size: 20px;
            }

            .info-card {
                max-width: 900px;
                margin: 0 auto 20px;
                background: #13131a;
                border: 1px solid #1e1e28;
                border-radius: 10px;
                padding: 18px 20px;
                display: flex;
                gap: 40px;
            }

            .info-item label {
                font-size: 11px;
                text-transform: uppercase;
                letter-spacing: 1px;
                color: #444;
                display: block;
                margin-bottom: 4px;
            }

            .info-item span {
                font-size: 16px;
                font-weight: 600;
            }

            .info-item .name-val { color: #7eaaff; }
            .info-item .total-val { color: #4ade80; }

            .table-wrapper {
                max-width: 900px;
                margin: 0 auto;
                background: #13131a;
                border: 1px solid #1e1e28;
                border-radius: 10px;
                overflow: hidden;
            }

            table { width: 100%; border-collapse: collapse; }

            th {
                background: #0f0f14;
                padding: 12px 16px;
                text-align: left;
                font-size: 11px;
                font-weight: 600;
                text-transform: uppercase;
                letter-spacing: 1px;
                color: #555;
                border-bottom: 1px solid #1e1e28;
            }

            td {
                padding: 13px 16px;
                border-bottom: 1px solid #1a1a22;
                font-size: 14px;
            }

            tr:last-child td { border-bottom: none; }

            .num-cell { color: #444; text-align: center; width: 50px; }
            .date-cell { color: #7eaaff; font-weight: 500; }
            .day-cell { color: #555; }
            .time-cell { color: #4ade80; }

            .weekend td { background: #111118; }
            .weekend .day-cell { color: #f06262; }

            .empty-cell {
                text-align: center;
                color: #444;
                padding: 40px;
                font-style: italic;
            }
        </style>
    </head>
    <body>

    <div class="topbar">
        <a href="/" class="back-btn">← Kembali</a>
        <h1>📋 Detail Absensi</h1>
    </div>

    <div class="info-card">
        <div class="info-item">
            <label>Nama</label>
            <span class="name-val" id="infoName">-</span>
        </div>
        <div class="info-item">
            <label>Bulan</label>
            <span id="infoMonth">-</span>
        </div>
        <div class="info-item">
            <label>Total Absensi</label>
            <span class="total-val" id="infoTotal">-</span>
        </div>
    </div>

    <div class="table-wrapper">
        <table>
            <thead>
                <tr>
                    <th style="text-align:center;">#</th>
                    <th>Tanggal</th>
                    <th>Hari</th>
                    <th>Check-in</th>
                </tr>
            </thead>
            <tbody id="result-body">
                <tr><td colspan="4" class="empty-cell">Memuat data...</td></tr>
            </tbody>
        </table>
    </div>

    <script>
        const SUPABASE_URL = "https://vtuamuvlfnouzekjhtsk.supabase.co";
        const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ0dWFtdXZsZm5vdXpla2podHNrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjY3NTkyMzksImV4cCI6MjA4MjMzNTIzOX0.wjWTw3kQVxpcj95LGsV9Ti3NrjGjFaqdmsqhROjLBWY";
        const TABLE = "attendance";

        function getParams() {
            const p = new URLSearchParams(window.location.search);
            return { name: p.get('name'), month: p.get('month') };
        }

        function isWeekend(dateStr) {
            const d = new Date(dateStr + 'T00:00:00');
            return d.getDay() === 0 || d.getDay() === 6;
        }

        async function loadDetail() {
            const { name, month } = getParams();
            if (!name || !month) {
                document.getElementById("result-body").innerHTML =
                    "<tr><td colspan='4' class='empty-cell' style='color:#f44336;'>Parameter tidak valid</td></tr>";
                return;
            }

            document.getElementById("infoName").textContent = name;
            const [year, monthNum] = month.split("-");
            document.getElementById("infoMonth").textContent =
                new Date(year, parseInt(monthNum) - 1).toLocaleString("id-ID", { month: "long", year: "numeric" });

            try {
                let nextMonthInt = parseInt(monthNum) + 1;
                let nextYear = year;
                let nextMonth = String(nextMonthInt).padStart(2, '0');
                if (nextMonthInt > 12) { nextMonth = '01'; nextYear = parseInt(year) + 1; }

                const url =
                    `${SUPABASE_URL}/rest/v1/${TABLE}` +
                    `?user_name=eq.${encodeURIComponent(name)}` +
                    `&checkin_date=gte.${year}-${monthNum}-01` +
                    `&checkin_date=lt.${nextYear}-${nextMonth}-01` +
                    `&select=checkin_date,checkin_time` +
                    `&order=checkin_date.asc`;

                const res = await fetch(url, {
                    headers: {
                        apikey: SUPABASE_ANON_KEY,
                        Authorization: `Bearer ${SUPABASE_ANON_KEY}`
                    }
                });

                if (!res.ok) throw new Error(`HTTP ${res.status}`);
                const data = await res.json();

                renderDetail(data);
            } catch (e) {
                document.getElementById("result-body").innerHTML =
                    "<tr><td colspan='4' class='empty-cell' style='color:#f44336;'>Gagal memuat data</td></tr>";
            }
        }

        function renderDetail(data) {
            const tbody = document.getElementById("result-body");
            tbody.innerHTML = "";
            document.getElementById("infoTotal").textContent = data.length;

            if (data.length === 0) {
                tbody.innerHTML = "<tr><td colspan='4' class='empty-cell'>Tidak ada data</td></tr>";
                return;
            }

            data.forEach((row, i) => {
                const d = new Date(row.checkin_date + 'T00:00:00');
                const formattedDate = d.toLocaleDateString('id-ID', { day: '2-digit', month: 'long', year: 'numeric' });
                const dayName = d.toLocaleDateString('id-ID', { weekday: 'long' });
                const weekend = isWeekend(row.checkin_date);

                const tr = document.createElement("tr");
                if (weekend) tr.classList.add("weekend");
                tr.innerHTML = `
                    <td class="num-cell">${i + 1}</td>
                    <td class="date-cell">${formattedDate}</td>
                    <td class="day-cell">${dayName}</td>
                    <td class="time-cell">${row.checkin_time || '-'}</td>
                `;
                tbody.appendChild(tr);
            });
        }

        loadDetail();
    </script>
    </body>
    </html>
    u  
    <!DOCTYPE html>
    <html lang="id">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Admin Login</title>
        <style>
            @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=Syne:wght@700&display=swap');

            * { margin: 0; padding: 0; box-sizing: border-box; }

            body {
                font-family: 'DM Sans', sans-serif;
                background: #0a0a0f;
                color: #e2e2e5;
                min-height: 100vh;
                display: flex;
                align-items: center;
                justify-content: center;
                padding: 20px;
            }

            .login-box {
                width: 100%;
                max-width: 380px;
                background: #13131a;
                border: 1px solid #1e1e28;
                border-radius: 14px;
                padding: 40px 32px;
            }

            .login-box h1 {
                font-family: 'Syne', sans-serif;
                font-size: 24px;
                margin-bottom: 6px;
            }

            .login-box p {
                font-size: 13px;
                color: #555;
                margin-bottom: 30px;
            }

            .form-group {
                margin-bottom: 18px;
            }

            .form-group label {
                display: block;
                font-size: 12px;
                color: #555;
                margin-bottom: 6px;
                text-transform: uppercase;
                letter-spacing: 0.8px;
            }

            .form-group input {
                width: 100%;
                padding: 10px 14px;
                background: #0a0a0f;
                border: 1px solid #222;
                border-radius: 8px;
                color: #e2e2e5;
                font-size: 14px;
                font-family: inherit;
                outline: none;
                transition: border-color 0.2s;
            }

            .form-group input:focus { border-color: #7eaaff; }

            .error-msg {
                background: rgba(244, 67, 54, 0.1);
                border: 1px solid #333;
                color: #f06262;
                font-size: 13px;
                padding: 10px 14px;
                border-radius: 8px;
                margin-bottom: 18px;
                display: none;
            }

            .btn-login {
                width: 100%;
                padding: 11px;
                background: #7eaaff;
                color: #0a0a0f;
                border: none;
                border-radius: 8px;
                font-size: 15px;
                font-weight: 600;
                font-family: inherit;
                cursor: pointer;
                transition: background 0.2s;
            }

            .btn-login:hover { background: #6e9aef; }
            .btn-login:disabled { background: #333; color: #555; cursor: not-allowed; }

            .back-link {
                display: block;
                text-align: center;
                margin-top: 24px;
                font-size: 13px;
                color: #444;
                text-decoration: none;
                transition: color 0.2s;
            }

            .back-link:hover { color: #7eaaff; }
        </style>
    </head>
    <body>
        <div class="login-box">
            <h1>⚙️ Admin</h1>
            <p>Silakan login untuk mengakses dashboard</p>

            <div class="error-msg" id="errorMsg">Email atau password salah</div>

            <div class="form-group">
                <label>Email</label>
                <input type="email" id="email" placeholder="admin@example.com" autocomplete="off">
            </div>
            <div class="form-group">
                <label>Password</label>
                <input type="password" id="password" placeholder="••••••••">
            </div>

            <button class="btn-login" id="loginBtn" onclick="login()">Login</button>

            <a href="/" class="back-link">← Kembali ke Rekap Absensi</a>
        </div>

        <script>
            // Jika sudah login, redirect langsung ke dashboard
            fetch('/auth/check').then(r => {
                if (r.ok) window.location.href = '/dashboard';
            });

            // Enter key → login
            document.getElementById('password').addEventListener('keydown', (e) => {
                if (e.key === 'Enter') login();
            });

            async function login() {
                const email = document.getElementById('email').value.trim();
                const password = document.getElementById('password').value;
                const btn = document.getElementById('loginBtn');
                const errorMsg = document.getElementById('errorMsg');

                if (!email || !password) return;

                btn.disabled = true;
                btn.textContent = 'Logging in...';
                errorMsg.style.display = 'none';

                try {
                    const res = await fetch('/auth/login', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({ email, password })
                    });

                    if (res.ok) {
                        window.location.href = '/dashboard';
                    } else {
                        errorMsg.style.display = 'block';
                        btn.disabled = false;
                        btn.textContent = 'Login';
                    }
                } catch (e) {
                    errorMsg.style.display = 'block';
                    btn.disabled = false;
                    btn.textContent = 'Login';
                }
            }
        </script>
    </body>
    </html>
    un  
  <!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Admin Dashboard</title>
    <style>
        @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=Syne:wght@700&display=swap');

        * { margin: 0; padding: 0; box-sizing: border-box; }

        body {
            font-family: 'DM Sans', sans-serif;
            background: #0a0a0f;
            color: #e2e2e5;
            min-height: 100vh;
        }

        /* ===== SIDEBAR ===== */
        .sidebar {
            position: fixed;
            top: 0; left: 0;
            width: 220px;
            height: 100vh;
            background: #10101a;
            border-right: 1px solid #1e1e28;
            padding: 24px 16px;
            display: flex;
            flex-direction: column;
            z-index: 10;
        }

        .sidebar-logo {
            font-family: 'Syne', sans-serif;
            font-size: 18px;
            padding: 0 8px;
            margin-bottom: 30px;
        }

        .nav-item {
            display: flex;
            align-items: center;
            gap: 10px;
            padding: 10px 12px;
            border-radius: 8px;
            color: #555;
            text-decoration: none;
            font-size: 14px;
            transition: all 0.15s;
            margin-bottom: 2px;
        }

        .nav-item:hover { background: #1a1a24; color: #e2e2e5; }
        .nav-item.active { background: #1a1a24; color: #7eaaff; }
        .nav-item .icon { font-size: 16px; width: 20px; text-align: center; }

        .sidebar-bottom {
            margin-top: auto;
            border-top: 1px solid #1e1e28;
            padding-top: 16px;
        }

        .logout-btn {
            display: flex;
            align-items: center;
            gap: 10px;
            padding: 10px 12px;
            border-radius: 8px;
            color: #f06262;
            background: none;
            border: none;
            font-size: 14px;
            font-family: inherit;
            cursor: pointer;
            width: 100%;
            transition: background 0.15s;
        }

        .logout-btn:hover { background: rgba(240, 98, 98, 0.1); }

        /* ===== MAIN ===== */
        .main {
            margin-left: 220px;
            padding: 30px;
            min-height: 100vh;
        }

        .main-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 28px;
        }

        .main-header h1 {
            font-family: 'Syne', sans-serif;
            font-size: 22px;
        }

        .badge {
            font-size: 12px;
            background: rgba(126, 170, 255, 0.1);
            color: #7eaaff;
            padding: 4px 10px;
            border-radius: 20px;
            border: 1px solid rgba(126, 170, 255, 0.2);
        }

        /* ===== CARDS ===== */
        .cards {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
            gap: 14px;
            margin-bottom: 28px;
        }

        .card {
            background: #13131a;
            border: 1px solid #1e1e28;
            border-radius: 10px;
            padding: 20px;
        }

        .card-label {
            font-size: 11px;
            color: #555;
            text-transform: uppercase;
            letter-spacing: 1px;
            margin-bottom: 8px;
        }

        .card-value {
            font-size: 26px;
            font-weight: 600;
        }

        .card-value.blue { color: #7eaaff; }
        .card-value.green { color: #4ade80; }
        .card-value.yellow { color: #fbbf24; }

        /* ===== STREAM SECTION ===== */
        .stream-section {
            display: grid;
            grid-template-columns: 1fr 300px;
            gap: 20px;
        }

        .stream-box {
            background: #13131a;
            border: 1px solid #1e1e28;
            border-radius: 10px;
            padding: 20px;
            display: flex;
            flex-direction: column;
            align-items: center;
        }

        .stream-placeholder {
            width: 100%;
            aspect-ratio: 4/3;
            background: #0a0a0f;
            border-radius: 8px;
            border: 1px dashed #222;
            display: flex;
            align-items: center;
            justify-content: center;
            color: #333;
            font-size: 14px;
        }

        #streamImg {
            width: 100%;
            border-radius: 8px;
            display: block;
        }

        .stream-controls {
            display: flex;
            gap: 10px;
            margin-top: 16px;
            flex-wrap: wrap;
            justify-content: center;
        }

        .btn {
            padding: 9px 18px;
            border-radius: 8px;
            border: none;
            font-size: 13px;
            font-weight: 600;
            font-family: inherit;
            cursor: pointer;
            transition: all 0.2s;
        }

        .btn:disabled {
            opacity: 0.35;
            cursor: not-allowed;
        }

        .btn-blue { background: #7eaaff; color: #0a0a0f; }
        .btn-blue:hover:not(:disabled) { background: #6e9aef; }

        .btn-green { background: #4ade80; color: #0a0a0f; }
        .btn-green:hover:not(:disabled) { background: #3bcc70; }

        .btn-outline { background: transparent; color: #f06262; border: 1px solid #333; }
        .btn-outline:hover:not(:disabled) { border-color: #f06262; }

        /* ===== PANEL KANAN ===== */
        .panel {
            background: #13131a;
            border: 1px solid #1e1e28;
            border-radius: 10px;
            padding: 20px;
            display: flex;
            flex-direction: column;
            gap: 16px;
        }

        .panel-title {
            font-size: 13px;
            color: #555;
            text-transform: uppercase;
            letter-spacing: 1px;
        }

        .status-row {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 9px 0;
            border-bottom: 1px solid #1a1a22;
            font-size: 13px;
        }

        .status-row:last-child { border-bottom: none; }
        .status-row .label { color: #555; }

        .dot {
            display: inline-block;
            width: 8px; height: 8px;
            border-radius: 50%;
            margin-right: 6px;
        }

        .dot-green { background: #4ade80; }
        .dot-yellow { background: #fbbf24; }
        .dot-red { background: #f06262; }

        /* ===== ENROLL PROGRESS ===== */
        .enroll-panel {
            display: none;
            flex-direction: column;
            gap: 10px;
            padding: 14px;
            background: #0a0a0f;
            border-radius: 8px;
            border: 1px solid #1e1e28;
        }

        .enroll-panel.show { display: flex; }

        .enroll-name {
            font-size: 14px;
            font-weight: 600;
            color: #7eaaff;
        }

        .enroll-progress-text {
            font-size: 12px;
            color: #555;
            display: flex;
            justify-content: space-between;
        }

        /* progress bar track */
        .progress-track {
            width: 100%;
            height: 6px;
            background: #1e1e28;
            border-radius: 3px;
            overflow: hidden;
        }

        /* progress bar fill */
        .progress-fill {
            height: 100%;
            width: 0%;
            background: #4ade80;
            border-radius: 3px;
            transition: width 0.35s ease;
        }

        /* ===== RESULT BOX ===== */
        .result-box {
            display: none;
            flex-direction: column;
            gap: 8px;
            padding: 14px;
            background: #0a0a0f;
            border-radius: 8px;
            border: 1px solid #1e1e28;
        }

        .result-box.show { display: flex; }

        .result-label {
            font-size: 11px;
            color: #555;
            text-transform: uppercase;
            letter-spacing: 1px;
        }

        .result-face {
            padding: 8px 0;
            border-bottom: 1px solid #1a1a22;
        }

        .result-face:last-child { border-bottom: none; }

        .result-face .result-name {
            color: #7eaaff;
            font-weight: 600;
            font-size: 15px;
        }

        .result-face .result-name.unknown { color: #f06262; }

        .result-face .result-detail {
            color: #555;
            font-size: 12px;
            margin-top: 3px;
        }

        .result-face .result-detail .att-ok { color: #4ade80; }
        .result-face .result-detail .att-dup { color: #fbbf24; }

        /* ===== TOAST ===== */
        .toast {
            position: fixed;
            bottom: 24px;
            right: 24px;
            padding: 14px 20px;
            border-radius: 10px;
            font-size: 14px;
            font-weight: 500;
            display: flex;
            align-items: center;
            gap: 10px;
            z-index: 200;
            transform: translateY(100px);
            opacity: 0;
            transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
            max-width: 320px;
            pointer-events: none;
        }

        .toast.show { transform: translateY(0); opacity: 1; }

        .toast.success {
            background: #13131a;
            border: 1px solid rgba(74, 222, 128, 0.3);
            color: #4ade80;
        }

        .toast.error {
            background: #13131a;
            border: 1px solid rgba(240, 98, 98, 0.3);
            color: #f06262;
        }

        .toast.warn {
            background: #13131a;
            border: 1px solid rgba(251, 191, 36, 0.3);
            color: #fbbf24;
        }
    </style>
</head>
<body>

<!-- ===== SIDEBAR ===== -->
<div class="sidebar">
    <div class="sidebar-logo">⚙️ Admin Panel</div>

    <a href="/" class="nav-item"><span class="icon">📊</span> Rekap Absensi</a>
    <a href="/dashboard" class="nav-item active"><span class="icon">🎯</span> Face Recognition</a>
    <a href="/users" class="nav-item"><span class="icon">👥</span> User Management</a>

    <div class="sidebar-bottom">
        <button class="logout-btn" onclick="logout()">
            <span class="icon">🚪</span> Logout
        </button>
    </div>
</div>

<!-- ===== MAIN ===== -->
<div class="main">
    <div class="main-header">
        <h1>Face Recognition</h1>
        <span class="badge">🔒 Admin Only</span>
    </div>

    <!-- Cards -->
    <div class="cards">
        <div class="card">
            <div class="card-label">Status</div>
            <div class="card-value blue" id="statusCard">Idle</div>
        </div>
        <div class="card">
            <div class="card-label">WebSocket</div>
            <div class="card-value yellow" id="wsStatus">Disconnected</div>
        </div>
        <div class="card">
            <div class="card-label">Enrolled</div>
            <div class="card-value green" id="enrolledCount">0</div>
        </div>
    </div>

    <!-- Stream + Panel -->
    <div class="stream-section">

        <!-- Stream -->
        <div class="stream-box">
            <div class="stream-placeholder" id="placeholder">📹 Menunggu stream dari ESP32...</div>
            <img id="streamImg" src="/stream" style="display:none;">

            <div class="stream-controls">
                <button class="btn btn-blue" id="btnForceStart" onclick="sendForceStart()">▶️ ForceStart</button>
                <button class="btn btn-blue" style="display:none;" id="btnForceStop" onclick="sendForceStop()">⏸️ ForceStop</button>
                <button class="btn btn-blue" id="btnRecognize" onclick="sendRecognize()">🎯 Recognize</button>
                <button class="btn btn-green" id="btnEnroll" onclick="openEnroll()">📸 Enroll</button>
                <!-- Capture: hanya keliatan saat mode ENROLL -->
                <button class="btn btn-outline" id="btnCapture" onclick="sendCapture()" style="display:none;">📷 Capture</button>
                <button class="btn btn-outline" id="btnStop" onclick="sendStop()" style="display:none;">⏹ Stop</button>
            </div>
        </div>

        <!-- Panel Kanan -->
        <div class="panel">
            <div class="panel-title">Status</div>

            <div>
                <div class="status-row">
                    <span class="label">Connection</span>
                    <span><span class="dot dot-yellow" id="dotWs"></span><span id="connText">Disconnected</span></span>
                </div>
                <div class="status-row">
                    <span class="label">Mode</span>
                    <span id="modeText">—</span>
                </div>
                <div class="status-row">
                    <span class="label">Stream</span>
                    <span><span class="dot dot-yellow" id="dotStream"></span><span id="streamText">Menunggu</span></span>
                </div>
            </div>

            <!-- Enroll Progress (tersembunyi default) -->
            <div class="enroll-panel" id="enrollPanel">
                <div class="enroll-name" id="enrollName">—</div>
                <div class="enroll-progress-text">
                    <span id="enrollProgressLabel">0 / 10</span>
                    <span id="enrollPercentLabel">0%</span>
                </div>
                <div class="progress-track">
                    <div class="progress-fill" id="progressFill"></div>
                </div>
            </div>

            <!-- Result Box (tersembunyi default) -->
            <div class="result-box" id="resultBox">
                <div class="result-label">Hasil Recognition</div>
                <div id="resultList">—</div>
            </div>
        </div>
    </div>
</div>

<!-- ===== TOAST ===== -->
<div class="toast" id="toast"></div>

<!-- ===== ENROLL MODAL (simple prompt pengganti) ===== -->
<div id="enrollModal" style="
    display:none; position:fixed; inset:0;
    background:rgba(0,0,0,0.6); backdrop-filter:blur(4px);
    z-index:100; align-items:center; justify-content:center;
">
    <div style="
        background:#13131a; border:1px solid #1e1e28; border-radius:14px;
        padding:32px; width:100%; max-width:380px;
    ">
        <h2 style="font-family:'Syne',sans-serif; font-size:18px; margin-bottom:6px;">📸 Enroll Baru</h2>
        <p style="font-size:13px; color:#555; margin-bottom:22px;">Masukkan nama untuk enrollment wajah</p>

        <label style="display:block; font-size:12px; color:#555; text-transform:uppercase; letter-spacing:0.8px; margin-bottom:6px;">Nama</label>
        <input id="enrollInput" type="text" placeholder="Contoh: John_Doe" style="
            width:100%; padding:10px 14px; background:#0a0a0f;
            border:1px solid #222; border-radius:8px; color:#e2e2e5;
            font-size:14px; font-family:inherit; outline:none;
        " oninput="this.style.borderColor = this.value.trim() ? '#7eaaff' : '#222'">

        <div style="display:flex; justify-content:flex-end; gap:10px; margin-top:24px;">
            <button onclick="closeEnrollModal()" style="
                padding:9px 20px; border-radius:8px; background:transparent;
                color:#666; border:1px solid #1e1e28; font-size:14px;
                font-family:inherit; cursor:pointer;
            ">Cancel</button>
            <button onclick="submitEnroll()" style="
                padding:9px 20px; border-radius:8px; background:#4ade80;
                color:#0a0a0f; border:none; font-size:14px; font-weight:600;
                font-family:inherit; cursor:pointer;
            ">Mulai Enroll</button>
        </div>
    </div>
</div>

<script>
// ============================================================
// STATE
// ============================================================
let ws = null;
let currentMode = null;   // null | "ENROLL"
let streamStarted = false;

const ENROLL_TARGET = 10; // harus sama dengan server

// ============================================================
// WEBSOCKET
// ============================================================
function connectWebSocket() {
    ws = new WebSocket(`ws://${location.host}/ws/cmd`);

    ws.onopen = () => {
        setWsStatus(true);
    };

    ws.onmessage = (e) => {
        let msg;
        try {
            msg = JSON.parse(e.data);                // BUG FIX 1: "e.data" bukan "event.data"
        } catch (_) {
            console.log("Raw WS:", e.data);
            return;
        }

        console.log("📨 WS msg:", msg);

        switch (msg.type) {
            // ---- STOP ----
            case "stop":
                handleStop();
                break;

            // ---- RECOGNIZE ----
            case "recognize_status":
                setStatus("Memproses…", "yellow");
                break;

            case "recognize_result":
                handleRecognizeResult(msg);
                break;

            // ---- ENROLL ----
            case "enroll_started":
                handleEnrollStarted(msg);
                break;

            case "enroll_progress":
                handleEnrollProgress(msg);
                break;

            case "enroll_done":
                handleEnrollDone(msg);
                break;

            case "enroll_warning":
                showToast(msg.message, "warn");
                break;

            // ---- ERROR ----
            case "error":
                showToast(msg.message, "error");
                break;
        }
    };                                                // BUG FIX 2: closing brace onmessage

    ws.onclose = () => {
        setWsStatus(false);
        setTimeout(connectWebSocket, 3000);           // auto reconnect
    };

    ws.onerror = () => ws.close();
}

// ============================================================
// STREAM — nyala dari awal, placeholder kalau belum ada data
// ============================================================
function initStream() {
    const img = document.getElementById('streamImg');
    img.src = '/stream?' + Date.now();               // cache buster

    img.onload = () => {
        // Kalau gambar berhasil load, tampilkan stream, sembunyikan placeholder
        if (!streamStarted) {
            streamStarted = true;
            document.getElementById('placeholder').style.display = 'none';
            img.style.display = 'block';
            document.getElementById('dotStream').className = 'dot dot-green';
            document.getElementById('streamText').textContent = 'On';
        }
    };

    img.onerror = () => {
        // Kalau error (ESP32 belum kirim), retry setelah 2 detik
        setTimeout(initStream, 2000);
    };
}

// ============================================================
// SEND COMMANDS
// ============================================================
function sendRecognize() {
    if (!ws || ws.readyState !== 1) {
        showToast("WebSocket tidak terhubung", "error");
        return;
    }
    ws.send("recognize");
    setStatus("Recognizing…", "blue");
    hideResultAndEnroll();
}

function sendForceStart() {
    document.getElementById('btnForceStart').style.display = 'none';
    document.getElementById('btnForceStop').style.display = 'block';
    if (!ws || ws.readyState !== 1) return;
    ws.send(JSON.stringify({ command: "ForceStart" }));
}
function sendForceStop() {
    document.getElementById('btnForceStart').style.display = 'block';
    document.getElementById('btnForceStop').style.display = 'none';
    if (!ws || ws.readyState !== 1) return;
    ws.send(JSON.stringify({ command: "ForceStop" }));
}
function sendCapture() {
    if (!ws || ws.readyState !== 1) return;
    ws.send(JSON.stringify({ command: "capture" }));
}

function sendStop() {
    if (!ws || ws.readyState !== 1) return;
    ws.send("stop");
    handleStop();
}

// ============================================================
// ENROLL MODAL
// ============================================================
function openEnroll() {
    document.getElementById('enrollModal').style.display = 'flex';
    document.getElementById('enrollInput').value = '';
    document.getElementById('enrollInput').focus();
}

function closeEnrollModal() {
    document.getElementById('enrollModal').style.display = 'none';
}

function submitEnroll() {
    const nama = document.getElementById('enrollInput').value.trim();
    if (!nama) {
        showToast("Nama tidak boleh kosong", "error");
        return;
    }

    // Validasi: huruf, angka, underscore, spasi
    if (!/^[a-zA-Z0-9_ ]+$/.test(nama)) {
        showToast("Nama hanya boleh huruf, angka, underscore, atau spasi", "error");
        return;
    }

    closeEnrollModal();

    if (!ws || ws.readyState !== 1) {
        showToast("WebSocket tidak terhubung", "error");
        return;
    }

    ws.send(JSON.stringify({ command: "start_enroll", nama: nama }));  // BUG FIX 3: "nama" bukan "name"
}

// ============================================================
// HANDLERS — terima hasil dari server
// ============================================================
function handleStop() {
    currentMode = null;
    setStatus("Idle", "blue");
    document.getElementById('modeText').textContent = '—';
    document.getElementById('btnCapture').style.display = 'none';
    document.getElementById('btnStop').style.display = 'none';
    document.getElementById('enrollPanel').classList.remove('show');
}

function handleRecognizeResult(msg) {
    const faces = msg.faces || [];

    // Update result box
    const resultBox = document.getElementById('resultBox');
    const resultList = document.getElementById('resultList');

    if (faces.length === 0) {
        setStatus("Tidak ada wajah", "yellow");
        showToast("Tidak ada wajah terdeteksi", "warn");
        return;
    }

    let html = '';
    faces.forEach((face, i) => {
        if (face.status === "recognized") {
            const att = face.attendance || {};
            const attClass = att.status === "checked_in" ? "att-ok" : "att-dup";
            const attLabel = att.status === "checked_in"
                ? `✅ Check-in berhasil (${att.time})`
                : `⚠️ Sudah check-in hari ini`;

            html += `
                <div class="result-face">
                    <div class="result-name">${face.name}</div>
                    <div class="result-detail">
                        Distance: ${face.distance} &nbsp;|&nbsp;
                        <span class="${attClass}">${attLabel}</span>
                    </div>
                </div>`;
        } else {
            html += `
                <div class="result-face">
                    <div class="result-name unknown">Unknown</div>
                    <div class="result-detail">Wajah tidak dikenali</div>
                </div>`;
        }
    });

    resultList.innerHTML = html;
    resultBox.classList.add('show');

    // Status
    const recognized = faces.filter(f => f.status === "recognized");
    setStatus(recognized.length > 0 ? "Done ✓" : "Unknown", recognized.length > 0 ? "green" : "yellow");

    // Toast ringkas
    if (recognized.length > 0) {
        showToast(`Dikenali: ${recognized.map(f => f.name).join(', ')}`, "success");
    } else {
        showToast("Wajah tidak dikenali", "warn");
    }
}

function handleEnrollStarted(msg) {
    currentMode = "ENROLL";
    setStatus("Enrolling…", "green");
    document.getElementById('modeText').textContent = 'Enrollment';

    // Tampilkan Capture + Stop, sembunyikan Enroll
    document.getElementById('btnCapture').style.display = 'inline-flex';
    document.getElementById('btnStop').style.display = 'inline-flex';

    // Enroll panel
    document.getElementById('enrollPanel').classList.add('show');
    document.getElementById('enrollName').textContent = msg.name;
    updateProgress(0, ENROLL_TARGET);

    hideResultBox();
    showToast(`Enroll dimulai untuk "${msg.name}". Tekan Capture untuk foto.`, "success");
}

function handleEnrollProgress(msg) {
    updateProgress(msg.count, msg.total);
    showToast(`Foto ${msg.count}/${msg.total} berhasil`, "success");
}

function handleEnrollDone(msg) {
    updateProgress(ENROLL_TARGET, ENROLL_TARGET);
    showToast(msg.message, "success");

    // Fetch ulang jumlah enrolled
    fetchEnrolledCount();

    // Setelah 1.5s, auto stop
    setTimeout(() => handleStop(), 1500);
}

// ============================================================
// UI HELPERS
// ============================================================
function setStatus(text, color) {
    const el = document.getElementById('statusCard');
    el.textContent = text;
    el.className = `card-value ${color}`;
}

function setWsStatus(connected) {
    document.getElementById('wsStatus').textContent = connected ? 'Connected' : 'Disconnected';
    document.getElementById('wsStatus').style.color = connected ? '#4ade80' : '#fbbf24';
    document.getElementById('dotWs').className = connected ? 'dot dot-green' : 'dot dot-yellow';
    document.getElementById('connText').textContent = connected ? 'Connected' : 'Disconnected';
}

function updateProgress(current, total) {
    const pct = Math.round((current / total) * 100);
    document.getElementById('enrollProgressLabel').textContent = `${current} / ${total}`;
    document.getElementById('enrollPercentLabel').textContent = `${pct}%`;
    document.getElementById('progressFill').style.width = pct + '%';
}

function hideResultAndEnroll() {
    document.getElementById('resultBox').classList.remove('show');
    document.getElementById('enrollPanel').classList.remove('show');
}

function hideResultBox() {
    document.getElementById('resultBox').classList.remove('show');
}

// ============================================================
// FETCH enrolled count dari Supabase
// ============================================================
async function fetchEnrolledCount() {
    try {
        const SUPABASE_URL = "https://vtuamuvlfnouzekjhtsk.supabase.co";
        const SUPABASE_KEY = "sb_publishable_0LJO9qBDgV29zXMPaS44Iw_3d4GP9uw";

        const res = await fetch(
            `${SUPABASE_URL}/rest/v1/users?is_enrolled=eq.true&select=count`,
            {
                headers: {
                    apikey: SUPABASE_KEY,
                    Authorization: `Bearer ${SUPABASE_KEY}`,
                    Prefer: "count=exact"
                }
            }
        );

        const count = res.headers.get("content-range")?.split("/").pop() || "0";
        document.getElementById('enrolledCount').textContent = count;
    } catch (_) {
        // silent
    }
}

// ============================================================
// TOAST
// ============================================================
let toastTimeout = null;

function showToast(message, type = "success") {
    const toast = document.getElementById('toast');
    const icon = type === "success" ? "✅" : type === "error" ? "❌" : "⚠️";
    toast.className = `toast ${type} show`;
    toast.innerHTML = `<span>${icon}</span> ${message}`;

    if (toastTimeout) clearTimeout(toastTimeout);
    toastTimeout = setTimeout(() => toast.classList.remove('show'), 3500);
}

// ============================================================
// LOGOUT
// ============================================================
function logout() {
    fetch('/auth/logout', { method: 'POST', redirect: 'follow' })
        .then(() => { window.location.href = '/login'; });
}

// ============================================================
// INIT
// ============================================================
connectWebSocket();
initStream();
fetchEnrolledCount();

// Enter key di modal → submit
document.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && document.getElementById('enrollModal').style.display === 'flex') {
        submitEnroll();
    }
    if (e.key === 'Escape' && document.getElementById('enrollModal').style.display === 'flex') {
        closeEnrollModal();
    }
});
</script>

</body>
</html>
rX  )response_classc                     #    [         $ 7f)zHalaman rekap bulanan)
html_rekaprN   r   r   	get_rekapr  	  s         	z/detailc                     #    [         $ 7f)zHalaman detail absensi per nama)html_detailrN   r   r   
get_detailr  	  s      r  r  c                      [         $ )zHalaman login - PUBLIC)
html_loginrN   r   r   
login_pager  	  s
     r   z
/dashboardc                 D    U (       a
  U [         ;  a
  [        SSS9$ [        $ )zAdmin Dashboard - PROTECTEDr  r  r  )r!   r   html_dashboardr  s    r   	dashboardr  	  s#    
 M@H#>> r   z/usersc                 N    U (       a
  U [         ;  a
  [        SSS9$ [        S5      $ )zUser Management - PROTECTEDr  r  r  uFv  
    <!DOCTYPE html>
    <html lang="id">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>User Management</title>
        <style>
            @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=Syne:wght@700&display=swap');

            * { margin: 0; padding: 0; box-sizing: border-box; }

            body {
                font-family: 'DM Sans', sans-serif;
                background: #0a0a0f;
                color: #e2e2e5;
                min-height: 100vh;
            }

            /* ===== SIDEBAR ===== */
            .sidebar {
                position: fixed;
                top: 0; left: 0;
                width: 220px;
                height: 100vh;
                background: #10101a;
                border-right: 1px solid #1e1e28;
                padding: 24px 16px;
                display: flex;
                flex-direction: column;
                z-index: 10;
            }

            .sidebar-logo {
                font-family: 'Syne', sans-serif;
                font-size: 18px;
                padding: 0 8px;
                margin-bottom: 30px;
            }

            .nav-item {
                display: flex;
                align-items: center;
                gap: 10px;
                padding: 10px 12px;
                border-radius: 8px;
                color: #555;
                text-decoration: none;
                font-size: 14px;
                transition: all 0.15s;
                margin-bottom: 2px;
            }

            .nav-item:hover { background: #1a1a24; color: #e2e2e5; }
            .nav-item.active { background: #1a1a24; color: #7eaaff; }
            .nav-item .icon { font-size: 16px; width: 20px; text-align: center; }

            .sidebar-bottom {
                margin-top: auto;
                border-top: 1px solid #1e1e28;
                padding-top: 16px;
            }

            .logout-btn {
                display: flex;
                align-items: center;
                gap: 10px;
                padding: 10px 12px;
                border-radius: 8px;
                color: #f06262;
                background: none;
                border: none;
                font-size: 14px;
                font-family: inherit;
                cursor: pointer;
                width: 100%;
                transition: background 0.15s;
            }

            .logout-btn:hover { background: rgba(240, 98, 98, 0.1); }

            /* ===== MAIN ===== */
            .main {
                margin-left: 220px;
                padding: 30px;
                min-height: 100vh;
            }

            .main-header {
                display: flex;
                justify-content: space-between;
                align-items: center;
                margin-bottom: 24px;
            }

            .main-header h1 {
                font-family: 'Syne', sans-serif;
                font-size: 22px;
            }

            .badge {
                font-size: 12px;
                background: rgba(126, 170, 255, 0.1);
                color: #7eaaff;
                padding: 4px 10px;
                border-radius: 20px;
                border: 1px solid rgba(126, 170, 255, 0.2);
            }

            /* ===== TOOLBAR ===== */
            .toolbar {
                display: flex;
                justify-content: space-between;
                align-items: center;
                margin-bottom: 16px;
                gap: 12px;
            }

            .toolbar-left {
                display: flex;
                align-items: center;
                gap: 12px;
            }

            .search-box {
                position: relative;
            }

            .search-box input {
                padding: 9px 14px 9px 36px;
                background: #13131a;
                border: 1px solid #1e1e28;
                border-radius: 8px;
                color: #e2e2e5;
                font-size: 14px;
                font-family: inherit;
                width: 240px;
                outline: none;
                transition: border-color 0.2s;
            }

            .search-box input:focus { border-color: #7eaaff; }

            .search-box .search-icon {
                position: absolute;
                left: 12px;
                top: 50%;
                transform: translateY(-50%);
                color: #444;
                font-size: 14px;
            }

            .filter-select {
                padding: 9px 14px;
                background: #13131a;
                border: 1px solid #1e1e28;
                border-radius: 8px;
                color: #e2e2e5;
                font-size: 14px;
                font-family: inherit;
                outline: none;
                cursor: pointer;
                transition: border-color 0.2s;
            }

            .filter-select:focus { border-color: #7eaaff; }

            .count-label {
                font-size: 13px;
                color: #444;
                margin-left: 8px;
            }

            /* ===== TABLE ===== */
            .table-wrapper {
                background: #13131a;
                border: 1px solid #1e1e28;
                border-radius: 10px;
                overflow: hidden;
            }

            table {
                width: 100%;
                border-collapse: collapse;
            }

            th {
                background: #0f0f14;
                padding: 12px 16px;
                text-align: left;
                font-size: 11px;
                font-weight: 600;
                text-transform: uppercase;
                letter-spacing: 1px;
                color: #444;
                border-bottom: 1px solid #1e1e28;
                white-space: nowrap;
                user-select: none;
            }

            th.sortable { cursor: pointer; }
            th.sortable:hover { color: #7eaaff; }
            th .sort-arrow { margin-left: 4px; opacity: 0.4; }
            th.sorted .sort-arrow { opacity: 1; color: #7eaaff; }

            td {
                padding: 14px 16px;
                border-bottom: 1px solid #1a1a22;
                font-size: 14px;
                vertical-align: middle;
            }

            tr:last-child td { border-bottom: none; }

            tbody tr {
                transition: background 0.15s;
            }

            tbody tr:hover { background: #161620; }

            /* ===== CELLS ===== */
            .user-cell {
                display: flex;
                align-items: center;
                gap: 12px;
            }

            .avatar {
                width: 36px;
                height: 36px;
                border-radius: 8px;
                background: linear-gradient(135deg, #1e1e28, #2a2a36);
                border: 1px solid #222;
                display: flex;
                align-items: center;
                justify-content: center;
                font-size: 15px;
                font-weight: 600;
                color: #7eaaff;
                flex-shrink: 0;
            }

            .user-name { font-weight: 500; }
            .user-id { font-size: 11px; color: #333; font-family: monospace; margin-top: 2px; }

            .face-file-cell {
                font-size: 12px;
                color: #555;
                font-family: monospace;
                max-width: 200px;
                overflow: hidden;
                text-overflow: ellipsis;
                white-space: nowrap;
            }

            .face-file-cell.empty { color: #333; font-style: italic; font-family: inherit; }

            .date-cell { color: #666; font-size: 13px; white-space: nowrap; }

            /* ===== BADGES ===== */
            .badge-enrolled {
                display: inline-flex;
                align-items: center;
                gap: 5px;
                font-size: 12px;
                font-weight: 600;
                padding: 4px 10px;
                border-radius: 20px;
            }

            .badge-enrolled.yes {
                background: rgba(74, 222, 128, 0.1);
                color: #4ade80;
                border: 1px solid rgba(74, 222, 128, 0.2);
            }

            .badge-enrolled.no {
                background: rgba(100, 100, 120, 0.1);
                color: #555;
                border: 1px solid rgba(100, 100, 120, 0.2);
            }

            .badge-dot {
                width: 6px; height: 6px;
                border-radius: 50%;
            }

            .badge-enrolled.yes .badge-dot { background: #4ade80; }
            .badge-enrolled.no .badge-dot { background: #555; }

            /* ===== ACTION BUTTONS ===== */
            .actions {
                display: flex;
                align-items: center;
                gap: 6px;
            }

            .btn-icon {
                width: 34px;
                height: 34px;
                border-radius: 6px;
                border: 1px solid #1e1e28;
                background: transparent;
                color: #666;
                cursor: pointer;
                display: flex;
                align-items: center;
                justify-content: center;
                font-size: 15px;
                transition: all 0.15s;
            }

            .btn-icon:hover { background: #1a1a24; border-color: #2a2a36; }

            .btn-icon.edit:hover { color: #7eaaff; border-color: rgba(126, 170, 255, 0.3); }
            .btn-icon.delete:hover { color: #f06262; border-color: rgba(240, 98, 98, 0.3); background: rgba(240, 98, 98, 0.05); }

            /* ===== EMPTY STATE ===== */
            .empty-state {
                text-align: center;
                padding: 60px 20px;
                color: #333;
            }

            .empty-state .empty-icon { font-size: 36px; margin-bottom: 12px; }
            .empty-state p { font-size: 14px; }

            /* ===== MODAL ===== */
            .modal-overlay {
                position: fixed;
                inset: 0;
                background: rgba(0, 0, 0, 0.6);
                backdrop-filter: blur(4px);
                display: none;
                align-items: center;
                justify-content: center;
                z-index: 100;
            }

            .modal-overlay.show { display: flex; }

            .modal {
                background: #13131a;
                border: 1px solid #1e1e28;
                border-radius: 14px;
                width: 100%;
                max-width: 440px;
                padding: 28px;
                position: relative;
            }

            .modal h2 {
                font-family: 'Syne', sans-serif;
                font-size: 18px;
                margin-bottom: 6px;
            }

            .modal .modal-subtitle {
                font-size: 13px;
                color: #555;
                margin-bottom: 24px;
            }

            .modal-close {
                position: absolute;
                top: 20px; right: 20px;
                background: none;
                border: none;
                color: #555;
                font-size: 18px;
                cursor: pointer;
                width: 28px; height: 28px;
                display: flex;
                align-items: center;
                justify-content: center;
                border-radius: 6px;
                transition: all 0.15s;
            }

            .modal-close:hover { background: #1a1a24; color: #e2e2e5; }

            .form-group {
                margin-bottom: 16px;
            }

            .form-group label {
                display: block;
                font-size: 12px;
                color: #555;
                margin-bottom: 6px;
                text-transform: uppercase;
                letter-spacing: 0.8px;
            }

            .form-group input,
            .form-group select {
                width: 100%;
                padding: 10px 14px;
                background: #0a0a0f;
                border: 1px solid #222;
                border-radius: 8px;
                color: #e2e2e5;
                font-size: 14px;
                font-family: inherit;
                outline: none;
                transition: border-color 0.2s;
            }

            .form-group input:focus,
            .form-group select:focus { border-color: #7eaaff; }

            .form-group input:disabled {
                color: #444;
                cursor: not-allowed;
            }

            .form-group select option { background: #13131a; }

            .modal-actions {
                display: flex;
                justify-content: flex-end;
                gap: 10px;
                margin-top: 24px;
            }

            .btn {
                padding: 9px 20px;
                border-radius: 8px;
                border: none;
                font-size: 14px;
                font-weight: 600;
                font-family: inherit;
                cursor: pointer;
                transition: all 0.2s;
            }

            .btn-ghost {
                background: transparent;
                color: #666;
                border: 1px solid #1e1e28;
            }

            .btn-ghost:hover { background: #1a1a24; color: #e2e2e5; }

            .btn-blue { background: #7eaaff; color: #0a0a0f; }
            .btn-blue:hover { background: #6e9aef; }

            .btn-red { background: #f06262; color: #fff; }
            .btn-red:hover { background: #e05050; }

            /* ===== CONFIRM MODAL ===== */
            .confirm-icon {
                width: 48px; height: 48px;
                border-radius: 12px;
                background: rgba(240, 98, 98, 0.1);
                border: 1px solid rgba(240, 98, 98, 0.2);
                display: flex;
                align-items: center;
                justify-content: center;
                font-size: 22px;
                margin-bottom: 16px;
            }

            .confirm-modal h2 { color: #f06262; }

            .confirm-modal .confirm-name {
                font-weight: 600;
                color: #e2e2e5;
                margin-top: 4px;
            }

            /* ===== TOAST ===== */
            .toast {
                position: fixed;
                bottom: 24px;
                right: 24px;
                padding: 14px 20px;
                border-radius: 10px;
                font-size: 14px;
                font-weight: 500;
                display: flex;
                align-items: center;
                gap: 10px;
                z-index: 200;
                transform: translateY(100px);
                opacity: 0;
                transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
                max-width: 320px;
            }

            .toast.show { transform: translateY(0); opacity: 1; }

            .toast.success {
                background: #13131a;
                border: 1px solid rgba(74, 222, 128, 0.3);
                color: #4ade80;
            }

            .toast.error {
                background: #13131a;
                border: 1px solid rgba(240, 98, 98, 0.3);
                color: #f06262;
            }
        </style>
    </head>
    <body>

    <!-- ===== SIDEBAR ===== -->
    <div class="sidebar">
        <div class="sidebar-logo">⚙️ Admin Panel</div>

        <a href="/" class="nav-item"><span class="icon">📊</span> Rekap Absensi</a>
        <a href="/dashboard" class="nav-item"><span class="icon">🎯</span> Face Recognition</a>
        <a href="/users" class="nav-item active"><span class="icon">👥</span> User Management</a>

        <div class="sidebar-bottom">
            <button class="logout-btn" onclick="logout()">
                <span class="icon">🚪</span> Logout
            </button>
        </div>
    </div>

    <!-- ===== MAIN ===== -->
    <div class="main">

        <div class="main-header">
            <h1>User Management</h1>
            <span class="badge">🔒 Admin Only</span>
        </div>

        <!-- Toolbar -->
        <div class="toolbar">
            <div class="toolbar-left">
                <div class="search-box">
                    <span class="search-icon">🔍</span>
                    <input type="text" id="searchInput" placeholder="Cari nama user..." oninput="filterUsers()">
                </div>
                <select class="filter-select" id="filterEnrolled" onchange="filterUsers()">
                    <option value="all">Semua Status</option>
                    <option value="true">Enrolled</option>
                    <option value="false">Belum Enrolled</option>
                </select>
                <span class="count-label" id="countLabel">0 user</span>
            </div>
        </div>

        <!-- Table -->
        <div class="table-wrapper">
            <table>
                <thead>
                    <tr>
                        <th class="sortable" onclick="sortTable('user_name')">
                            Nama <span class="sort-arrow" id="sort-user_name">↕</span>
                        </th>
                        <th>Face File</th>
                        <th class="sortable" onclick="sortTable('is_enrolled')">
                            Status <span class="sort-arrow" id="sort-is_enrolled">↕</span>
                        </th>
                        <th class="sortable" onclick="sortTable('created_at')">
                            Dibuat <span class="sort-arrow" id="sort-created_at">↕</span>
                        </th>
                        <th style="width: 100px;">Aksi</th>
                    </tr>
                </thead>
                <tbody id="userTableBody">
                    <tr><td colspan="5" class="empty-state"><div class="empty-icon">⏳</div><p>Memuat data...</p></td></tr>
                </tbody>
            </table>
        </div>
    </div>

    <!-- ===== EDIT MODAL ===== -->
    <div class="modal-overlay" id="editModal">
        <div class="modal">
            <button class="modal-close" onclick="closeModal('editModal')">✕</button>
            <h2>Edit User</h2>
            <p class="modal-subtitle">Ubah informasi user di bawah</p>

            <input type="hidden" id="editUserId">

            <div class="form-group">
                <label>ID</label>
                <input type="text" id="editId" disabled>
            </div>
            <div class="form-group">
                <label>Nama</label>
                <input type="text" id="editName" placeholder="Nama user">
            </div>
            <div class="form-group">
                <label>Face File</label>
                <input type="text" id="editFaceFile" readonly placeholder="Path file face">
            </div>
            <div class="form-group">
                <label>Status Enrolled</label>
                <select id="editEnrolled">
                    <option value="true">✅ Enrolled</option>
                    <option value="false">❌ Belum Enrolled</option>
                </select>
            </div>

            <div class="modal-actions">
                <button class="btn btn-ghost" onclick="closeModal('editModal')">Cancel</button>
                <button class="btn btn-blue" onclick="saveEdit()">Simpan</button>
            </div>
        </div>
    </div>

    <!-- ===== DELETE CONFIRM MODAL ===== -->
    <div class="modal-overlay" id="deleteModal">
        <div class="modal confirm-modal">
            <button class="modal-close" onclick="closeModal('deleteModal')">✕</button>
            <div class="confirm-icon">🗑️</div>
            <h2>Hapus User</h2>
            <p class="modal-subtitle">
                Aksi ini tidak bisa dibatalkan.<br>
                <span class="confirm-name" id="deleteUserName">—</span> akan dihapus permanen.
            </p>

            <input type="hidden" id="deleteUserId">

            <div class="modal-actions">
                <button class="btn btn-ghost" onclick="closeModal('deleteModal')">Cancel</button>
                <button class="btn btn-red" onclick="confirmDelete()">Hapus</button>
            </div>
        </div>
    </div>

    <!-- ===== TOAST ===== -->
    <div class="toast" id="toast"></div>

    <script>
        // ===== CONFIG =====
        const SUPABASE_URL = "https://vtuamuvlfnouzekjhtsk.supabase.co";
        const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ0dWFtdXZsZm5vdXpla2podHNrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjY3NTkyMzksImV4cCI6MjA4MjMzNTIzOX0.wjWTw3kQVxpcj95LGsV9Ti3NrjGjFaqdmsqhROjLBWY";

        const headers = {
            apikey: SUPABASE_ANON_KEY,
            Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
            "Content-Type": "application/json",
            Prefer: "return=representation"
        };

        // ===== STATE =====
        let allUsers = [];
        let sortField = 'created_at';
        let sortAsc = false;

        // ===== FETCH =====
        async function fetchUsers() {
            try {
                const res = await fetch(
                    `${SUPABASE_URL}/rest/v1/users?select=*&order=created_at.desc`,
                    { headers }
                );

                if (!res.ok) throw new Error(`HTTP ${res.status}`);

                allUsers = await res.json();
                renderTable(allUsers);

            } catch (e) {
                console.error(e);
                showToast("Gagal memuat data user", "error");
            }
        }

        // ===== RENDER =====
        function renderTable(users) {
            const tbody = document.getElementById("userTableBody");
            document.getElementById("countLabel").textContent = `${users.length} user`;

            if (users.length === 0) {
                tbody.innerHTML = `
                    <tr><td colspan="5">
                        <div class="empty-state">
                            <div class="empty-icon">👤</div>
                            <p>Tidak ada user ditemukan</p>
                        </div>
                    </td></tr>`;
                return;
            }

            tbody.innerHTML = users.map(user => `
                <tr>
                    <td>
                        <div class="user-cell">
                            <div class="avatar">${user.user_name ? user.user_name[0].toUpperCase() : '?'}</div>
                            <div>
                                <div class="user-name">${user.user_name || '—'}</div>
                                <div class="user-id">${user.id.slice(0, 18)}...</div>
                            </div>
                        </div>
                    </td>
                    <td>
                        <span class="face-file-cell ${!user.face_file ? 'empty' : ''}">
                            ${user.face_file || 'Tidak ada file'}
                        </span>
                    </td>
                    <td>
                        <span class="badge-enrolled ${user.is_enrolled ? 'yes' : 'no'}">
                            <span class="badge-dot"></span>
                            ${user.is_enrolled ? 'Enrolled' : 'Belum Enrolled'}
                        </span>
                    </td>
                    <td>
                        <span class="date-cell">${formatDate(user.created_at)}</span>
                    </td>
                    <td>
                        <div class="actions">
                            <button class="btn-icon edit" onclick="openEdit('${user.id}')" title="Edit">✏️</button>
                            <button class="btn-icon delete" onclick="openDelete('${user.id}', '${user.user_name || 'User'}')" title="Hapus">🗑️</button>
                        </div>
                    </td>
                </tr>
            `).join('');
        }

        // ===== FILTER + SEARCH =====
        function filterUsers() {
            const search = document.getElementById("searchInput").value.toLowerCase();
            const status = document.getElementById("filterEnrolled").value;

            let filtered = allUsers;

            if (search) {
                filtered = filtered.filter(u =>
                    (u.user_name || '').toLowerCase().includes(search)
                );
            }

            if (status !== 'all') {
                filtered = filtered.filter(u => String(u.is_enrolled) === status);
            }

            renderTable(filtered);
        }

        // ===== SORT =====
        function sortTable(field) {
            if (sortField === field) {
                sortAsc = !sortAsc;
            } else {
                sortField = field;
                sortAsc = true;
            }

            // Reset arrows
            document.querySelectorAll('.sort-arrow').forEach(el => {
                el.textContent = '↕';
                el.parentElement.classList.remove('sorted');
            });

            // Set active arrow
            const activeArrow = document.getElementById(`sort-${field}`);
            activeArrow.textContent = sortAsc ? '↑' : '↓';
            activeArrow.parentElement.classList.add('sorted');

            // Sort
            allUsers.sort((a, b) => {
                let valA = a[field];
                let valB = b[field];

                if (typeof valA === 'boolean') {
                    valA = valA ? 1 : 0;
                    valB = valB ? 1 : 0;
                }

                if (valA < valB) return sortAsc ? -1 : 1;
                if (valA > valB) return sortAsc ? 1 : -1;
                return 0;
            });

            filterUsers();
        }

        // ===== EDIT MODAL =====
        function openEdit(id) {
            const user = allUsers.find(u => u.id === id);
            if (!user) return;

            document.getElementById("editUserId").value = user.id;
            document.getElementById("editId").value = user.id;
            document.getElementById("editName").value = user.user_name || '';
            document.getElementById("editFaceFile").value = user.face_file || '';
            document.getElementById("editEnrolled").value = String(user.is_enrolled);

            openModal('editModal');
        }

        async function saveEdit() {
            const id = document.getElementById("editUserId").value;
            const name = document.getElementById("editName").value.trim();
            const faceFile = document.getElementById("editFaceFile").value.trim();
            const enrolled = document.getElementById("editEnrolled").value === 'true';

            if (!name) {
                showToast("Nama tidak boleh kosong", "error");
                return;
            }

            try {
                const res = await fetch(
                    `${SUPABASE_URL}/rest/v1/users?id=eq.${id}`,
                    {
                        method: "PATCH",
                        headers,
                        body: JSON.stringify({
                            user_name: name,
                            face_file: faceFile || null,
                            is_enrolled: enrolled
                        })
                    }
                );

                if (!res.ok) throw new Error(`HTTP ${res.status}`);

                closeModal('editModal');
                showToast("User berhasil diupdate", "success");
                await fetchUsers();

            } catch (e) {
                console.error(e);
                showToast("Gagal mengupdate user", "error");
            }
        }

        // ===== DELETE MODAL =====
        function openDelete(id, name) {
            document.getElementById("deleteUserId").value = id;
            document.getElementById("deleteUserName").textContent = name;
            openModal('deleteModal');
        }

        async function confirmDelete() {
            const id = document.getElementById("deleteUserId").value;

            try {
                const res = await fetch(
                    `${SUPABASE_URL}/rest/v1/users?id=eq.${id}`,
                    {
                        method: "DELETE",
                        headers
                    }
                );

                if (!res.ok) throw new Error(`HTTP ${res.status}`);

                closeModal('deleteModal');
                showToast("User berhasil dihapus", "success");
                await fetchUsers();

            } catch (e) {
                console.error(e);
                showToast("Gagal menghapus user", "error");
            }
        }

        // ===== MODAL HELPERS =====
        function openModal(id) {
            document.getElementById(id).classList.add('show');
        }

        function closeModal(id) {
            document.getElementById(id).classList.remove('show');
        }

        // Tutup modal kalau klik di luar
        document.querySelectorAll('.modal-overlay').forEach(overlay => {
            overlay.addEventListener('click', (e) => {
                if (e.target === overlay) overlay.classList.remove('show');
            });
        });

        // ===== TOAST =====
        let toastTimeout = null;

        function showToast(message, type = "success") {
            const toast = document.getElementById("toast");
            toast.className = `toast ${type} show`;
            toast.innerHTML = `<span>${type === 'success' ? '✅' : '❌'}</span> ${message}`;

            if (toastTimeout) clearTimeout(toastTimeout);
            toastTimeout = setTimeout(() => toast.classList.remove('show'), 3000);
        }

        // ===== HELPERS =====
        function formatDate(isoString) {
            if (!isoString) return '—';
            const d = new Date(isoString);
            return d.toLocaleDateString('id-ID', {
                day: '2-digit',
                month: 'short',
                year: 'numeric',
                hour: '2-digit',
                minute: '2-digit'
            });
        }

        function logout() {
            fetch('/auth/logout', { method: 'POST', redirect: 'follow' })
                .then(() => window.location.href = '/login');
        }

        // ===== INIT =====
        fetchUsers();
    </script>
    </body>
    </html>
    )r!   r   r   r  s    r   
users_pager  	  s0     M@H#>> L L	 L	r   c                  .    [         R                  " S5      $ )zGenerate unique session tokenru  )r  r  rN   r   r   generate_tokenr  ~  s    R  r   c                 (    U (       a  U [         ;   a  gg)z Check apakah session token validTFr!   r  s    r   get_admin_from_cookier    s    /9r   c                 (    U (       a
  U [         ;  a  gg)z.Dependency: redirect ke login jika belum loginNTr  r  s    r   require_adminr    s    M@r   c                   #    U R                  5       I Sh  vN nUR                  SS5      nUR                  SS5      nU[        :X  a_  U[        :X  aU  [	        5       n[
        R                  U5        [        [         R                  " SSS.5      SS	9nUR                  S
USSSS9  U$ [        [         R                  " SSS.5      SSS9$  N7f)zLogin adminNrs  r   rt  r   zLogin successfulr   rv  rf  rw  Trx  ry  )keyvaluerz  r{  r|  r   r}  r~  r  )
r(  rI  r  r  r  r!   r  r   r)  r  )rq  r  rs  rt  r  r-  s         r   r  r    s      DHHWb!Exx
B'HN : E"JJ)@RST)

 	 	 	
 JJ'>STU)
 	
-  s   CC
B4Cc                    #    U (       a  U [         ;   a  [         R                  U 5        [        SSS9nUR                  SS9  U$ 7f)zLogout adminr  r  r  rw  )r  r  )rw  r-  s     r   r  r    sA      /9.H#>H/Os   AAc                 v   #    U (       a  U [         ;   a  SS0$ [        [        R                  " SS05      SSS9$ 7f)z$Check status login (dipakai oleh JS)r  TFr~  rv  r  r  r  s    r   r  r    s@      /9&&

OU34% r  )U   r+   )or(  re   r{   r  r   r  concurrent.futuresr   r   r   dt_timeior   	threadingr   pytzr   r   numpyrJ   PILr	   fastapir
   r   r   r   r   fastapi.responsesr   r   r   r   r_   r   timezoner   r   r   boolr   SUPABASE_URLSUPABASE_KEYr  r  rq   r!   r   __annotations__rN  rh   r   makedirsCascadeClassifierrd   haarcascadesr   executorappr(   r%  rO   r9   rK   rP   dictro   rw   r   rL   r   r   r   r   rI   r   r   r   r   r   r   r   r  r
  r  r  r   r.  rR  rF  r'  rL  rI  rd  rp  postr  r  r  r  r  r  r  r  r  r  r  r  r  r  r  rN   r   r   <module>r     s~    	 	    1 # $    
    L L Y Y "==(!Q-13D 3 :?|4!ES !		 I %$$HHAA
 !,i!# !#F 
 %'bjj! &T$Z V
)0X  < < < $ 2"** 2s 2EDL 2

 &bjj eCJ6G1H41O [][e[e ,! ! !DT"**%5 $t* "6C "6C "6D "6Jd2::.> 4: )bjj )3 )3 )4 )^   0 
 "'8 |B&y B& B&R yj&I j& j&b0RZZ0@ 0fA&Ns uT4Z/@ 0 b b  E E -    .&,T&:    *0*> C  S
lzzt
n|| \* + <0 1 ,/ 0 l3#)$#7 S  4 $*4$8 R	c R	 R	h! 06d/C   (.d';   -
 
 
< .&,T&:    *0*> C  r   