/* [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
| Nama | Jadwal | Sesi | Istirahat | Status |
{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
| {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…
:
| Nama | Hadir | Telat | Libur | Rata-rata masuk | Total jam kerja | Istirahat |
{data.rows.map(r=>(
| {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}
}
{cfg &&
}
;
}
/* ---------------- 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;
})();