/* [NALA_ABSEN_V1][NALA_ABSEN_V2] Absensi staff — punch PIN 4 aksi, multi-sesi + istirahat, papan harian, rekap, kelola (jadwal 2 jendela). Copy UI FORMAL (Anda/silakan). IIFE-wrapped (babel-standalone share global scope). Expose: window.AbsenTab */ (() => { const {useState, useEffect} = React; const jGet = (u)=>fetch(u,{credentials:'include'}).then(async r=>{const d=await r.json().catch(()=>({}));if(!r.ok){const e=new Error(typeof d.detail==='string'?d.detail:(d.detail?.error||r.statusText));e.status=r.status;throw e}return d}); const jPost = (u,b)=>fetch(u,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}).then(async r=>{const d=await r.json().catch(()=>({}));if(!r.ok){const e=new Error(typeof d.detail==='string'?d.detail:(d.detail?.error||r.statusText));e.status=r.status;throw e}return d}); const jPatch = (u,b)=>fetch(u,{method:'PATCH',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}).then(async r=>{const d=await r.json().catch(()=>({}));if(!r.ok){const e=new Error(typeof d.detail==='string'?d.detail:(d.detail?.error||r.statusText));e.status=r.status;throw e}return d}); const jDel = (u)=>fetch(u,{method:'DELETE',credentials:'include'}).then(async r=>{const d=await r.json().catch(()=>({}));if(!r.ok){const e=new Error(typeof d.detail==='string'?d.detail:(d.detail?.error||r.statusText));e.status=r.status;throw e}return d}); const hhmm = (iso)=> iso ? iso.slice(11,16) : '–'; const fmtMin = (m)=> m>=60 ? `${Math.floor(m/60)}j ${m%60}m` : `${m}m`; const ACTION_LABEL = {in:'MASUK', break_out:'ISTIRAHAT', break_in:'KEMBALI', out:'PULANG'}; const STATUS_META = { hadir: {label:'Hadir', cls:'ab-pill ab-hadir'}, telat: {label:'Telat', cls:'ab-pill ab-telat'}, istirahat: {label:'Istirahat', cls:'ab-pill ab-break'}, belum: {label:'Belum absen', cls:'ab-pill ab-belum'}, libur: {label:'Libur', cls:'ab-pill ab-libur'}, }; /* ---------------- PUNCH + BOARD ---------------- */ function PunchBoard(){ const [board,setBoard]=useState(null); const [selId,setSelId]=useState(null); const [pin,setPin]=useState(''); const [busy,setBusy]=useState(false); const [msg,setMsg]=useState(null); const load=()=>jGet('/api/absen/today').then(setBoard).catch(e=>setMsg({ok:false,text:e.message})); useEffect(()=>{load(); const iv=setInterval(load,60000); return ()=>clearInterval(iv)},[]); const punch=async(action)=>{ if(!selId) return; if(pin.length<4){setMsg({ok:false,text:'PIN minimal 4 digit'});return} setBusy(true);setMsg(null); try{ const r=await jPost('/api/absen/punch',{staff_id:selId,action,pin}); setMsg({ok:true,text:`${r.name} — ${ACTION_LABEL[action]} tercatat ${hhmm(r.at)}${r.late?' (TELAT)':''}`}); setPin('');setSelId(null);load(); }catch(e){setMsg({ok:false,text:e.message})} setBusy(false); }; if(!board) return
Memuat…
; const sel = selId ? board.rows.find(r=>r.staff_id===selId) : null; const openSession = sel && sel.sessions.some(s=>!s.checkout_at); const onBreak = sel && !!sel.on_break_since; const canIn = sel && !openSession; const canBreak = sel && openSession && !onBreak; const canBack = sel && onBreak; const canOut = sel && openSession && !onBreak; return

Absen — Pilih Nama Anda

{board.rows.map(r=>( ))}
{sel &&
{sel.name} · jadwal hari ini: {sel.is_off?'Libur':(sel.schedule||'-')} {onBreak && · sedang istirahat sejak {hhmm(sel.on_break_since)}}
setPin(e.target.value.replace(/\D/g,''))}/>
} {msg &&
{msg.text}
}

Hari Ini — {board.date}

toleransi {board.tolerance_min} mnt · maks istirahat {board.max_break_min} mnt
{board.rows.map(r=>{ const m=STATUS_META[r.status]||STATUS_META.belum; const sesi=r.sessions.map(s=>`${hhmm(s.checkin_at)}–${s.checkout_at?hhmm(s.checkout_at):'…'}`).join(', '); return ; })}
NamaJadwalSesiIstirahatStatus
{r.name} {r.is_off?'Libur':(r.schedule||'-')}{r.override && *} {sesi||'–'}{r.outside_ip && 🏠} {r.on_break_since ? sejak {hhmm(r.on_break_since)} : (r.breaks_min>0 ? {r.breaks_min} mnt{r.over_break && } : '–')} {m.label}
; } /* ---------------- REKAP ---------------- */ function RekapView(){ const now=new Date(); const [ym,setYm]=useState(`${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`); const [data,setData]=useState(null); useEffect(()=>{ const [y,m]=ym.split('-'); jGet(`/api/absen/rekap?year=${y}&month=${parseInt(m,10)}`).then(setData).catch(()=>setData(null)); },[ym]); return

Rekap Bulanan

setYm(e.target.value)} style={{width:170}}/>
{!data ?
Memuat…
: {data.rows.map(r=>( ))}
NamaHadirTelatLiburRata-rata masukTotal jam kerjaIstirahat
{r.name}{r.hadir} {r.telat>0?{r.telat}:0} {r.libur}{r.avg_checkin||'–'} {r.total_kerja_min>0?fmtMin(r.total_kerja_min):'–'} {r.total_istirahat_min>0?fmtMin(r.total_istirahat_min):'–'}
}
; } /* ---------------- KELOLA (admin/manager) ---------------- */ function KelolaView(){ const [staff,setStaff]=useState([]); const [ovs,setOvs]=useState([]); const [cfg,setCfg]=useState(null); const [msg,setMsg]=useState(null); const [newStaff,setNewStaff]=useState({name:'',default_start:'07:00',default_end:'15:00',default_start2:'',default_end2:'',pin:''}); const [ovForm,setOvForm]=useState({staff_id:'',work_date:'',start_time:'',end_time:'',start_time2:'',end_time2:'',is_off:false,note:''}); const loadAll=()=>{ jGet('/api/absen/staff').then(setStaff); jGet('/api/absen/overrides').then(setOvs); jGet('/api/absen/config').then(setCfg); }; useEffect(loadAll,[]); const flash=(ok,text)=>{setMsg({ok,text});setTimeout(()=>setMsg(null),4000)}; const saveStaff=async(s,patch)=>{try{await jPatch(`/api/absen/staff/${s.id}`,patch);flash(true,`${s.name} tersimpan`);loadAll()}catch(e){flash(false,e.message)}}; const setPinFor=async(s)=>{const p=prompt(`PIN baru untuk ${s.name} (4-6 digit):`);if(!p)return; if(!/^\d{4,6}$/.test(p)){flash(false,'PIN harus 4-6 digit angka');return} await saveStaff(s,{pin:p})}; const addStaff=async()=>{try{ const b={name:newStaff.name,default_start:newStaff.default_start,default_end:newStaff.default_end}; if(newStaff.default_start2&&newStaff.default_end2){b.default_start2=newStaff.default_start2;b.default_end2=newStaff.default_end2} if(newStaff.pin) b.pin=newStaff.pin; await jPost('/api/absen/staff',b);setNewStaff({name:'',default_start:'07:00',default_end:'15:00',default_start2:'',default_end2:'',pin:''});flash(true,'Staff ditambahkan');loadAll(); }catch(e){flash(false,e.message)}}; const addOv=async()=>{try{ const b={staff_id:parseInt(ovForm.staff_id,10),work_date:ovForm.work_date,is_off:ovForm.is_off,note:ovForm.note||null}; if(!ovForm.is_off){ b.start_time=ovForm.start_time;b.end_time=ovForm.end_time; if(ovForm.start_time2&&ovForm.end_time2){b.start_time2=ovForm.start_time2;b.end_time2=ovForm.end_time2} } await jPost('/api/absen/overrides',b);setOvForm({staff_id:'',work_date:'',start_time:'',end_time:'',start_time2:'',end_time2:'',is_off:false,note:''});flash(true,'Override tersimpan');loadAll(); }catch(e){flash(false,e.message)}}; const saveCfg=async()=>{try{await jPost('/api/absen/config',{tolerance_min:parseInt(cfg.tolerance_min,10),max_break_min:parseInt(cfg.max_break_min,10),hotel_ips:cfg.hotel_ips});flash(true,'Config tersimpan')}catch(e){flash(false,e.message)}}; const timePair=(s,f1,f2,label)=>( {const v=e.target.value;if(v!==(s[f1]||''))saveStaff(s,{[f1]:v})}}/> {'–'} {const v=e.target.value;if(v!==(s[f2]||''))saveStaff(s,{[f2]:v})}}/> ); return
{msg &&
{msg.text}
}

Roster Staff

{staff.map(s=>( ))}
NamaJam kerja 1Jam kerja 2 (opsional)PINAktif
{s.name} {timePair(s,'default_start','default_end','Jam kerja 1')} {timePair(s,'default_start2','default_end2','Jam kerja 2')} {(s.default_start2||s.default_end2) && } {s.pin_set?'✓ diatur':belum} saveStaff(s,{active:e.target.checked})}/>
setNewStaff({...newStaff,name:e.target.value})} style={{width:150}}/> setNewStaff({...newStaff,default_start:e.target.value})} style={{width:96}}/> setNewStaff({...newStaff,default_end:e.target.value})} style={{width:96}}/> jam 2: setNewStaff({...newStaff,default_start2:e.target.value})} style={{width:96}}/> setNewStaff({...newStaff,default_end2:e.target.value})} style={{width:96}}/> setNewStaff({...newStaff,pin:e.target.value.replace(/\D/g,'')})} style={{width:105}}/>

Override Tanggal (jadwal khusus / libur)

setOvForm({...ovForm,work_date:e.target.value})}/> {!ovForm.is_off && <> setOvForm({...ovForm,start_time:e.target.value})} style={{width:96}}/> setOvForm({...ovForm,end_time:e.target.value})} style={{width:96}}/> jam 2: setOvForm({...ovForm,start_time2:e.target.value})} style={{width:96}}/> setOvForm({...ovForm,end_time2:e.target.value})} style={{width:96}}/> } setOvForm({...ovForm,note:e.target.value})} style={{width:130}}/>
{ovs.length===0 ?
Belum ada override 60 hari ke depan
: {ovs.map(o=>( ))}
TanggalStaffJadwalCatatan
{o.work_date}{o.name} {o.is_off?Libur :`${o.start_time}–${o.end_time}`+(o.start_time2?` & ${o.start_time2}–${o.end_time2}`:'')} {o.note||''}
}
{cfg &&

Config

Daftar IP kosong = penanda "di luar hotel" nonaktif. Silakan isi setelah observasi IP asli dari absen pertama.
}
; } /* ---------------- SHELL ---------------- */ function AbsenTab({user}){ const [sub,setSub]=useState('punch'); const kelola = user && (user.role==='admin'||user.role==='manager'); return
{kelola && }
{sub==='punch' && } {sub==='rekap' && } {sub==='kelola' && kelola && }
; } window.AbsenTab = AbsenTab; })();