U
    =i                    @   s
  U d dl Z d dlZd dlZd dlZd dlZd dlZd dlmZ d dlm	Z	mZ d dlmZ
 d dlmZ d dlmZ d dlZd dlZd dlZd dlZd dlmZmZmZmZ d dlmZ d d	lmZ d d
lmZmZm Z m!Z!m"Z" e#dZ$e
dd Z%e
dd Z&e'dddZ(dZ)dZ*ee)e*ZdZ+dZ,e- Z.e-e/ e0d< dZ1dZ2dZ3ej4e2dd e5ej6j7d Z8eddZ9e Z:G dd dZ;e; Z<g a=e>ej? e0d < g a@e>eA e0d!< e ZBd"d# ZCeC  e/e/d$d%d&ZDeEe'd'd(d)ZFdej?eGeEdB d+d,d-ZHej?d.d/d0ZIdej?e>eJe/eJf  dB ej?d1d2d3ZKej?eAd4d5d6ZLe>ej? e>eA d7d8d9ZMe/e/eAd:d;d<ZNe>ej? e>eA d7d=d>ZOej?e/e/e'd?d@dAZPG dBdC dCZQeQ ZRdddd dDdDdEZSdFdG ZTdHdI ZUe:VdJe!dKdLdMZWe:VdNe!dKdOdPZXe>ej? dQdRdSZYdTdU ZZe/eJeAe'f dVdWdXZ[e:\dYdZd[ Z]e:\d\d]d^ Z^e:_d_e d`dadbZ`e:_dcedddfe/dedfdgZae:\dhedddfe/dedidjZbdkZcdlZddmZednZfe:j\doedpdqdr Zge:j\dsedpdtdu Zhe:j\dvedpdwdx Zie:j\dyedpedddfe/dedzd{Zje:\d|edddfe/ded}d~Zkdd Zledddfe/deddZmedddfe/deddZne:_d_e d`ddbZ`e:_dcedddfe/deddgZae:\dhedddfe/deddjZbeodkrd dlpZpepjqe:ddd dS )    N)ThreadPoolExecutor)datedatetime)time)BytesIO)Lock)HTMLResponseRedirectResponseResponseStreamingResponse)Image)create_client)CookieFastAPIRequest	WebSocketWebSocketDisconnectzAsia/Jakarta      returnc                  C   s&   t t } t|   ko tkS   S )z)Cek apakah sekarang dalam jam operasional)r   nowTIMEZONEr   OPERATION_STARTOPERATION_END)r    r   )/home/afrizal/public_html/fastapi/main.pyis_operation_hours!   s    r   z(https://vtuamuvlfnouzekjhtsk.supabase.coZ.sb_publishable_0LJO9qBDgV29zXMPaS44Iw_3d4GP9uwzadmin@example.comZadmin123active_sessions
   facesg?T)exist_okz#haarcascade_frontalface_default.xml   )max_workersc                   @   s\   e Zd ZdZdd Zdd Zeeef dddZe	j
d	B dd
dZed	B dddZd	S )FrameBufferzE
    Simpan frame terbaru di memory.
    Thread-safe pakai Lock.
    c                 C   s*   d | _ d | _g | _g | _t | _d | _d S N)framedisplay_framer    
face_cropsr   lock
jpeg_bytesselfr   r   r   __init__K   s    zFrameBuffer.__init__c              	   C   s4   | j $ || _|| _|| _|| _|| _W 5 Q R X d S r%   )r)   r&   r'   r    r(   r*   )r,   r&   r'   r    r(   r*   r   r   r   updateS   s    zFrameBuffer.updater   c              
   C   s8   | j ( t| jdd | jD fW  5 Q R  S Q R X dS )z*Ambil snapshot face_crops + faces saat inic                 S   s   g | ]}|  qS r   )copy).0cr   r   r   
<listcomp>^   s     z0FrameBuffer.get_latest_crops.<locals>.<listcomp>N)r)   listr    r(   r+   r   r   r   get_latest_crops[   s    zFrameBuffer.get_latest_cropsNc              
   C   s6   | j & | jdk	r| j ndW  5 Q R  S Q R X dS )z!Ambil snapshot raw frame saat iniN)r)   r&   r/   r+   r   r   r   get_latest_frame`   s    zFrameBuffer.get_latest_framec              
   C   s$   | j  | jW  5 Q R  S Q R X d S r%   )r)   r*   r+   r   r   r   get_jpege   s    zFrameBuffer.get_jpeg)__name__
__module____qualname____doc__r-   r.   tupler3   r4   npndarrayr5   bytesr6   r   r   r   r   r$   E   s   r$   KNOWN_ENCODINGSKNOWN_USERSc               
   C   s4  zt dddd } g }g }| jD ]}|d s:q,tjt	|d }tj
|shtd|  q,t|}|jdkr|dd}|D ]&}|| ||d	 |d
 d qq,t |a|aW 5 Q R X tdt| dttdd |D  d W n2 tk
r. } ztd|  W 5 d}~X Y nX dS )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_idrG   u   ✅ Loaded z encodings, c                 s   s   | ]}|d  V  qdS )rG   Nr   )r0   ur   r   r   	<genexpr>   s     z!load_all_faces.<locals>.<genexpr>z useru   ❌ load_all_faces error: N)supabasetableselecteqexecutedataospathjoin	FACES_DIRexistsprintr<   loadndimreshapeappend
faces_lockr?   r@   lenset	Exception)rA   Znew_encodingsZ	new_usersrI   rR   Zencser   r   r   load_all_facesu   s>    
 



&r`   )namer   c                 C   s   t dd|   S )Nz
[^a-z0-9_]_)resubstriplower)ra   r   r   r   normalize_name   s    rg   )image_bytesr   c                 C   s4   zt t|   W dS  tk
r.   Y dS X d S )NTF)r   openr   verifyr^   )rh   r   r   r   is_valid_image   s
    rk   U   )r&   qualityr   c                 C   s(   t d| t j|g\}}|r$| S d S )Nz.jpg)cv2ZimencodeZIMWRITE_JPEG_QUALITYtobytes)r&   rm   successencodedr   r   r   encode_to_jpeg   s    rr   )r&   c                 C   s   t | t j}tj|ddd}g }|D ]~\}}}}td| }td|| }	td|| }
t| jd || | }t| jd || | }|	| |
||	|f  q&||fS )zK
    Deteksi wajah + crop.
    Ini CEPAT (~5-15ms), aman di main loop.
    g?   )ZscaleFactorZminNeighborsg?r   rD   )
rn   cvtColorZCOLOR_BGR2GRAYface_cascadeZdetectMultiScaleintmaxminshaperZ   )r&   Zgrayr    r(   xywhpadx1y1Zx2y2r   r   r   detect_faces   s    r   )r&   labelsr   c              
   C   s   |   }t|D ]\}\}}}}|r>|t|k r>|| \}	}
nd\}	}
t|||f|| || f|
d |	rt||	|t|d dftjd|
d q|S )zg
    Draw bounding box. Kalau labels None, box kuning polos.
    labels: list of (text, color_bgr)
    ) )r      r   r"   r      g333333?)r/   	enumerater\   rn   Z	rectangleZputTextrw   ZFONT_HERSHEY_SIMPLEX)r&   r    r   displayirz   r{   r|   r}   textcolorr   r   r   
draw_boxes   s"    "	r   )face_bgrr   c           
   
   C   s   t 6 ttdkr&ddiW  5 Q R  S tt}tt}W 5 Q R X t| tj}t	|}t|dkrlddiS |d }t
t||}tt|}t|| }|tkr|| }	d|	d |	d t|ddS ddiS )	uI   
    Identify 1 wajah. BLOCKING — panggil via asyncio.to_thread().
    r   statusunknown
recognizedrH   rG   rs   )r   rH   ra   distance)r[   r\   r?   r3   r@   rn   rt   COLOR_BGR2RGBface_recognitionface_encodingsZface_distancer<   arrayrv   Zargminfloat	THRESHOLDround)
r   Zencodings_snapshotZusers_snapshotface_rgb	encodingsr   Z	distancesZbest_idxbest_distanceuserr   r   r   identify_face_sync   s*    
r   )r(   r   c                 C   s$   g }| D ]}t |}|| q|S )uA   
    Recognize semua wajah. BLOCKING — jalankan di thread.
    )r   rZ   )r(   resultsZcropresultr   r   r   run_recognition_sync  s
    r   )rH   rG   r   c              
   C   s   t  }t  }z~tddd| d|	 
 }|jrTd|	 dW S td| ||	 |dd
  d	|	 |dd
W S  tk
r } z$td|  dt|d W Y S d}~X Y nX dS )ud   
    Record attendance ke DB. BLOCKING — jalankan di thread.
    Cek duplikat di DB langsung.
    
attendancerF   rH   checkin_dateZalready_checked_in)r   r   z%H:%M:%S)rH   rG   r   Zcheckin_timeZ
checked_in)r   r   r   u   ❌ Attendance error: errorr   messageN)r   todayr   r   r   rK   rL   rM   rN   	isoformatrO   rP   insertstrftimer^   rV   str)rH   rG   r   r   existingr_   r   r   r   record_attendance_sync  s:    
  

r   c                 C   s8   t | }|D ]&}|d dkrt|d |d |d< q|S )zj
    Gabung: recognition + attendance dalam 1 blocking function.
    Panggil via asyncio.to_thread().
    r   r   rH   ra   r   )r   r   )r(   r   rr   r   r   do_recognition_and_attendanceB  s
    r   )	face_croprH   rG   r   c                 C   s  t | t j}t|}t|dkr<tdt| d dS t||}|sXtd dS |d }t|}| d| d}t	j
t|}	t	j
|	rt|	}
|
jdkr|
dd	}
t|
|g}nt|g}t|	| td
t| d| d td|ddd|  dS )uS   
    Enroll 1 wajah + update DB. BLOCKING — panggil via asyncio.to_thread().
    rD   u    ⚠️  Enroll gagal: ditemukan z wajahFu2   ⚠️  Enroll gagal: tidak bisa generate encodingr   rb   z.npyrE   u   ✅ Enroll #z untuk ''rA   T)rC   rB   rF   )rn   rt   r   r   face_locationsr\   rV   r   rg   rQ   rR   rS   rT   rU   r<   rW   rX   rY   Zvstackr   saverK   rL   r.   rN   rO   )r   rH   rG   r   r   r   Znew_encodingZ	safe_namefilenamerR   r   Zall_encr   r   r   do_enroll_syncQ  s4    


 r   c                   @   s>   e Zd Zdd ZedddZedddZedd	d
ZdS )ConnectionManagerc                 C   s
   g | _ d S r%   )active_connectionsr+   r   r   r   r-     s    zConnectionManager.__init__	websocketc                    s4   |  I d H  | j| tdt| j d d S )Nu   ✅ WS connected (total: ))acceptr   rZ   rV   r\   r,   r   r   r   r   connect  s    zConnectionManager.connectc                 C   s0   || j kr,| j | tdt| j  d d S )Nu   ❌ WS disconnected (total: r   )r   removerV   r\   r   r   r   r   
disconnect  s    
zConnectionManager.disconnect)r   c              	      s\   g }| j D ]8}z||I d H  W q
 tk
r@   || Y q
X q
|D ]}| | qHd S r%   )r   	send_textr^   rZ   r   )r,   r   Zdeadwsr   r   r   broadcast_text  s    
z ConnectionManager.broadcast_textN)	r7   r8   r9   r-   r   r   r   r   r   r   r   r   r   r     s   r   F)moderH   rG   enroll_countcapture_requested
processingc                   C   s4   d t d< d t d< d t d< dt d< dt d< dt d< d S )	Nr   rH   rG   r   r   Fr   r   )SESSIONr   r   r   r   reset_session  s    r   c                     sX   t  } tdI dH  t  }|| kr|r,dnd}t|I dH  td|  |} qdS )z
    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   rV   )Z
last_stateZcurrent_stater   r   r   r   schedule_broadcaster  s    r   z
/ws/streamr   c              	      s  t | I d H  zzr|  I d H }|d dkr6q|d dkrd|kr|d }|sXqtj|tjd}t|tj	}|d krqt
|\}}t||}t|}t||||| td dkrtd rtd	 stt  |d dkrd
|kr|d
  }	|	dkr2t rdnd}
| |
I d H  td|
  q|	dkr^t  t tddiI d H  q|	dkrtd dkrdtd< td qqW n tk
r   Y nX W 5 t |  X d S )Ntypewebsocket.disconnectwebsocket.receiver>   )Zdtyper   ENROLLr   r   r   isWorkingTime?r   r   "   📞 ESP32 query working time → stopcaptureTu!   📸 Capture requested dari ESP32)r   r   r   receiver<   Z
frombufferZuint8rn   ZimdecodeZIMREAD_COLORr   r   rr   bufferr.   r   r   create_taskprocess_enroll_capturere   r   r   rV   r   r   jsondumpsr   )r   r   rawZ	img_arrayr&   r    r(   r   jpegr   responser   r   r   	ws_stream  sV    




r   z/ws/cmdc              
      s|  t | I d H  zXz:|  I d H }|d dkr6qN|d dkrd|kr|d  }td| d |dkrt  t t	ddiI d H  q|dkrt
 rd	nd
}| |I d H  td|  q|dkrt t	ddddI d H  td q|dkr(t t	ddddI d H  td q|dkrt
 srt t	dddddI d H  td tdI d H  t \}}t|dkr| t	ddddI d H  t
 std  t t	ddddI d H  q| t	d!d"d#I d H  tt|| qzt|}W n tjk
r0   Y qY nX |d}|d$kr|d%pTd& }|s~| t	d'd(d#I d H  qtd)|s| t	d'd*d#I d H  qztt|I d H \}	}
W nL tk
r } z,| t	d't|d#I d H  W Y qW 5 d }~X Y nX |	d+}|rtjt|}tj |rt!"|}|j#d,kr`|$d,d-}t|t%kr| t	d'd| d.t| d/d#I d H  qt  d0t&d1< |	d2 t&d3< |t&d4< dt&d5< t t	d6|dt%d7I d H  td8| d q|d9kr$t&d1 d0krLd:t&d;< td< q|d=krt  t t	ddiI d H  qW n t'k
rf   Y nX W 5 t |  X d S )>Nr   r   r   r   u   📨 CMD: 'r   r   r   r   r   r   Z
ForceStartcommandesp32ZSTARTr   targetactionu&   📞 ESP32 ForceStart command receivedZ	ForceStopSTOPu%   📞 ESP32 ForceStop command receivedZ	recognizeZ
TEMP_STARTr   )r   r   r   timeoutu"   ⏳ Waiting for stream to start...g      @r   recognize_resultZno_facezTidak ada wajah terdeteksi)r   r   r   u1   🛑 Stopping temporary stream (no face detected)Zrecognize_statuszMemproses...r   r   Zstart_enrollnamar   r   zNama tidak boleh kosongz^[a-zA-Z0-9_ ]+$zNama tidak validrC   rD   rE   z' sudah ter-enroll (z encodings)r   r   rF   rH   rG   r   Zenroll_started)r   ra   counttotalu   🔵 Enroll started: 'r   Tr   u%   📸 Capture requested dari dashboardZstop_enroll)(r   r   r   r   re   rV   r   r   r   r   r   r   r   r   r   r4   r\   r   run_recognize_taskloadsJSONDecodeErrorgetrc   match	to_threadget_or_create_user_syncr^   r   rQ   rR   rS   rT   isfiler<   rW   rX   rY   ENROLL_TARGETr   r   )r   r   r   r   r    r(   rP   cmdr   r   createdr_   rC   Znpy_pathr   r   r   r   ws_cmd  s.   
	

	


	
	





 







 
r   )r(   c           	         sl  zt t|I dH }g }|D ].}|d dkr@||d df q|d qt }|dk	rt|| |}t|}tj |t_	|t_
W 5 Q R X ttd|dI dH  td	d
d |D   t sttddddI dH  W n~ tk
rf } z^td|  ttddt| dI dH  t sVttddddI dH  W 5 d}~X Y nX dS )zARecognition + attendance di background thread, broadcast hasilnyaNr   r   ra   )r   r   r   )ZUnknown)r   r   r   r   )r   r    u   🎯 Recognize done: c                 S   s   g | ]}| d dqS )ra   r   )r   )r0   r   r   r   r   r2     s     z&run_recognize_task.<locals>.<listcomp>r   r   r   r   u   ❌ Recognition error: r   zRecognition error: r   )r   r   r   rZ   r   r5   r   rr   r)   r'   r*   r   r   r   r   rV   r   r^   r   )	r    r(   r   r   r   r&   Zlabeledr   r_   r   r   r   r     sR    


r   c                     s  dt d< dt d< zƐzft \} }t|dkrdttddt| dd	I d
H  W W zd
S t	t
|d t d t d I d
H }|sttddd	I d
H  W W ,d
S t d  d7  < t d }td| dt  ttd|tt d dI d
H  |tkrzt d }t  t	tI d
H  ttd|d| ddI d
H  ttddiI d
H  td| d W nX tk
r } z8td|  ttddt| d	I d
H  W 5 d
}~X Y nX W 5 dt d< X d
S )z%Proses 1 enroll capture di backgroundTr   Fr   rD   Zenroll_warningz
Ditemukan z wajah. Harus tepat 1.r   Nr   rH   rG   zGagal enroll. Coba lagi.r   u   📸 Enroll progress: /Zenroll_progress)r   r   r   ra   Zenroll_donezEnroll 'z
' selesai!)r   ra   r   r   r   u   ✅ Enroll selesai: 'r   u   ❌ Enroll error: r   zEnroll error: )r   r   r4   r\   r   r   r   r   r   r   r   rV   r   r   r`   r^   r   )rb   r(   rp   r   r   r_   r   r   r   r   (  st    
   





	 r   )rG   r   c                 C   sh   t ddd|  }|jr0|jd dfS t d| dd d }|jsZtd|jd dfS )	NrA   *rG   r   F)rG   rB   rC   zGagal membuat userT)rK   rL   rM   rN   rO   rP   r   RuntimeError)rG   r   resr   r   r   r   x  s    

r   z/streamc                  C   s   dd } t |  ddS )Nc                  s   s*   t  } | rd|  d V  td q d S )Ns%   --frame
Content-Type: image/jpeg

s   
g?)r   r6   r   r   )r   r   r   r   	generator  s    zstream_video.<locals>.generatorz)multipart/x-mixed-replace; boundary=frame)
media_type)r   )r   r   r   r   stream_video  s
     r   z/imagec                  C   sJ   t  } | rt| ddS tjdddd}t }||d t| ddS )Nz
image/jpegcontentr   ZRGB)i@     )   r  r   )r   ZJPEG)r   r6   r
   r   newr   r   getvalue)r   Zblankbufr   r   r   	get_image  s    r  z/auth/login)requestc                    s   |   I d H }|dtkrl|dtkrltd}t| tt 	ddidd}|j
d|d	d
dd |S tt 	ddddddS )Nemailpassword    r   rp   application/jsonr   session_tokenTlaxQ )httponlysamesitemax_ager   Invalid credentialsr     r  status_coder   )r   r   ADMIN_EMAILADMIN_PASSWORDsecrets	token_hexr   addr
   r   
set_cookie)r  bodytokenrespr   r   r   login  s*    

     r   z/auth/logout)defaultr  c                    s(   | rt |  tddd}|d |S )N/login/  urlr  r  r   discardr	   Zdelete_cookie)r  r  r   r   r   logout  s
    

r)  z/auth/checkc                    s,   | r| t krddiS ttddidddS )NauthenticatedTFr  r  r  r   r
   r   r   r"  r   r   r   
check_auth  s    r,  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>
r   )Zresponse_classc                      s   t S )zHalaman rekap bulanan)
html_rekapr   r   r   r   	get_rekap
  s    r.  z/detailc                      s   t S )zHalaman detail absensi per nama)html_detailr   r   r   r   
get_detail#
  s    r0  r#  c                   C   s   t S )zHalaman login - PUBLIC)
html_loginr   r   r   r   
login_page)
  s    r2  z
/dashboardc                 C   s   | r| t krtdddS tS )zAdmin Dashboard - PROTECTEDr#  r$  r%  )r   r	   html_dashboardr"  r   r   r   	dashboard/
  s    r4  z/usersc                 C   s    | r| t krtdddS tdS )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"  r   r   r   
users_page?
  s    r5  c                   C   s
   t dS )zGenerate unique session tokenr  )r  r  r   r   r   r   generate_token  s    r6  c                 C   s   | r| t krdS dS )z Check apakah session token validTFr   r"  r   r   r   get_admin_from_cookie  s    r8  c                 C   s   | r| t krdS dS )z.Dependency: redirect ke login jika belum loginNTr7  r"  r   r   r   require_admin  s    r9  c                    s   |   I dH }|dd}|dd}|tkrv|tkrvt }t| tt ddddd	}|j	d
|dddd |S tt ddddddS dS )zLogin adminNr	  r   r
  rp   zLogin successfulr   r  r   r  Tr  r  )keyvaluer  r  r  r   r  r  r  )
r   r   r  r  r6  r   r  r
   r   r  )r  r  r	  r
  r  r   r   r   r   r     s.    
c                    s2   | r| t krt |  tddd}|jdd |S )zLogout adminr#  r$  r%  r  )r:  r'  )r  r   r   r   r   r)  	  s
    
c                    s,   | r| t krddiS ttddidddS )z$Check status login (dipakai oleh JS)r*  TFr  r  r  r+  r"  r   r   r   r,    s    __main__z0.0.0.0i@  )hostport)rl   )N)rr   r   rQ   rc   r  r   concurrent.futuresr   r   r   Zdt_timeior   	threadingr   rn   r   Znumpyr<   ZpytzZfastapi.responsesr   r	   r
   r   ZPILr   rK   r   Zfastapir   r   r   r   r   timezoner   r   r   boolr   ZSUPABASE_URLZSUPABASE_KEYr  r  r]   r   r   __annotations__r   rT   r   makedirsZCascadeClassifierrP   Zhaarcascadesru   executorappr$   r   r?   r3   r=   r@   dictr[   r`   rg   r>   rk   rv   rr   r   r;   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r  postr   r)  r,  r-  r/  r1  r3  r.  r0  r2  r4  r5  r6  r8  r9  r7   uvicornrunr   r   r   r   <module>   s
   


	


%,  "'.
	N Q9P

   ~ 8       


       

