load->database();
$this->load->library('ApprovalService');
}
private function companyId() { return (int)$this->session->userdata('company_id'); }
private function userId() { return (int)$this->session->userdata('user_id'); }
private function roleId() { return (int)$this->session->userdata('role_id'); }
private function can($action) { return is_master_admin_user() || check_permission('hr_attendance',$action) || check_permission('employees',$action); }
public function index()
{
$employees=$this->db->select('id,employee_code,full_name')->where('is_active',1);
if($this->db->field_exists('company_id','k_employees'))$employees->where('company_id',$this->companyId());
$data=array('active_menu'=>'attendance_monitoring','employees'=>$employees->order_by('full_name')->get('k_employees')->result(),'permissions'=>array('create'=>$this->can('can_create'),'submit'=>$this->can('can_submit')||$this->can('can_create'),'approve'=>$this->can('can_approve'),'update'=>$this->can('can_update')));
$this->load->view('partials/header',$data);$this->load->view('employees/attendance_monitoring',$data);$this->load->view('partials/footer');
}
public function get_data()
{
$date=$this->validDate($this->input->get('date'))?:date('Y-m-d');
$companySql=$this->db->field_exists('company_id','k_employees')?' AND e.company_id='.(int)$this->companyId():'';
$rows=$this->db->query("SELECT e.employee_code,e.full_name,sh.shift_name,a.id attendance_id,a.checkin_time,a.checkout_time,a.attendance_status
FROM k_employee_shift_schedules s JOIN k_employees e ON e.id=s.employee_id LEFT JOIN k_shifts sh ON sh.id=s.shift_id
LEFT JOIN k_attendances a ON a.employee_id=s.employee_id AND a.attendance_date=?
WHERE DATE(s.start_datetime)=? AND s.status IN('published','done') AND e.is_active=1{$companySql}
ORDER BY sh.checkin_time,e.full_name",array($date,$date))->result();
$data=array();$no=1;$labels=array('present'=>'Hadir','late'=>'Terlambat','alpha'=>'Alpa','annual'=>'Cuti','sick'=>'Sakit','permit'=>'Izin','maternity'=>'Melahirkan','unpaid'=>'Cuti Tidak Dibayar','holiday'=>'Libur','not_recorded'=>'Belum Absen');
foreach($rows as$r){$status=$r->attendance_id?$r->attendance_status:'not_recorded';$class=$status==='present'?'success':($status==='late'?'warning':($status==='alpha'?'danger':'secondary'));$data[]=array($no++,html_escape($r->employee_code),html_escape($r->full_name),html_escape($r->shift_name?:'-'),$r->checkin_time?date('H:i',strtotime($r->checkin_time)):'-',$r->checkout_time?date('H:i',strtotime($r->checkout_time)):'-',''.html_escape($labels[$status]??ucfirst($status)).'');}
return $this->json(true,'Jadwal kehadiran ditemukan.',array('data'=>$data));
}
public function manual_requests()
{
$q=$this->db->select('r.*,e.employee_code,e.full_name,u.nama creator_name')->from('manual_attendance_requests r')->join('k_employees e','e.id=r.employee_id')->join('users u','u.id=r.created_by','left')->where('r.company_id',$this->companyId());
if(trim((string)$this->input->get('status'))!=='')$q->where('r.status',trim((string)$this->input->get('status')));
$data=array();foreach($q->order_by('r.id','DESC')->get()->result()as$r){$actions=' ';
if(in_array($r->status,array('draft','rejected'),true)&&$this->can('can_create')&&((int)$r->created_by===$this->userId()||$this->can('can_update')))$actions.=' ';
if($r->status==='submitted'&&$this->can('can_approve'))$actions.=' ';
$data[]=array('request'=>$r->request_no,'employee'=>$r->employee_code.' - '.$r->full_name,'datetime'=>date('d/m/Y',strtotime($r->attendance_date)).' '.substr($r->attendance_time,0,5),'action'=>$r->attendance_action==='check_in'?'Masuk':'Pulang','reason'=>$r->reason,'status'=>$r->status,'creator'=>$r->creator_name?:'-','actions'=>$actions);}
return$this->json(true,'Daftar pengajuan ditemukan.',array('data'=>$data));
}
public function manual_detail($id)
{
$row=$this->request((int)$id,false);return$row?$this->json(true,'Detail pengajuan.',array('request'=>$row)):$this->json(false,'Pengajuan tidak ditemukan.',array(),404);
}
public function save_manual()
{
try{$employeeId=(int)$this->input->post('employee_id');$date=$this->validDate($this->input->post('attendance_date'));$time=$this->validTime($this->input->post('attendance_time'));$action=$this->input->post('attendance_action');$reason=trim((string)$this->input->post('reason',true));
if(!$employeeId||!$date||!$time||!in_array($action,array('check_in','check_out'),true)||$reason==='')throw new BusinessException('Karyawan, tanggal, jam, jenis absen, dan alasan wajib diisi.');
$employee=$this->db->where('id',$employeeId)->where('is_active',1);if($this->db->field_exists('company_id','k_employees'))$employee->where('company_id',$this->companyId());if(!$employee->get('k_employees')->row())throw new BusinessException('Karyawan aktif tidak ditemukan pada perusahaan ini.');
if($this->isLocked($date))throw new BusinessException('Periode absensi sudah dikunci untuk payroll.');
$duplicate=$this->db->where(array('company_id'=>$this->companyId(),'employee_id'=>$employeeId,'attendance_date'=>$date,'attendance_action'=>$action))->where_in('status',array('draft','submitted','approved'))->count_all_results('manual_attendance_requests');if($duplicate)throw new BusinessException('Pengajuan aktif untuk karyawan, tanggal, dan jenis absen tersebut sudah ada.');
$last=(int)$this->db->select_max('id','last_id')->get('manual_attendance_requests')->row()->last_id;$no='MAN-'.date('Ymd',strtotime($date)).'-'.str_pad((string)($last+1),5,'0',STR_PAD_LEFT);
$this->db->insert('manual_attendance_requests',array('company_id'=>$this->companyId(),'request_no'=>$no,'employee_id'=>$employeeId,'attendance_date'=>$date,'attendance_time'=>$time,'attendance_action'=>$action,'reason'=>$reason,'status'=>'draft','created_by'=>$this->userId(),'created_at'=>date('Y-m-d H:i:s')));$id=(int)$this->db->insert_id();log_activity('manual_attendance','create','Membuat draft absensi manual '.$no,'success');return$this->json(true,'Draft absensi manual berhasil dibuat.',array('id'=>$id));
}catch(Throwable$e){return$this->json(false,$e->getMessage(),array(),422);}
}
public function submit_manual($id)
{
return$this->transaction(function()use($id){$r=$this->request((int)$id,true);if(!$r)throw new BusinessException('Pengajuan tidak ditemukan.');if(!in_array($r->status,array('draft','rejected'),true))throw new BusinessException('Hanya draft atau pengajuan ditolak yang dapat diajukan.');if((int)$r->created_by!==$this->userId()&&!$this->can('can_update'))throw new BusinessException('Anda tidak berhak mengajukan dokumen ini.');if($this->isLocked($r->attendance_date))throw new BusinessException('Periode absensi sudah dikunci untuk payroll.');$approval=$this->approvalservice->create('hr','manual_attendance',$r->id,0,$this->userId(),array('request_no'=>$r->request_no,'employee_id'=>$r->employee_id,'attendance_date'=>$r->attendance_date,'attendance_time'=>$r->attendance_time,'attendance_action'=>$r->attendance_action,'reason'=>$r->reason));$this->db->where('id',$r->id)->update('manual_attendance_requests',array('status'=>'submitted','approval_request_id'=>$approval->id,'submitted_by'=>$this->userId(),'submitted_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>null,'updated_at'=>date('Y-m-d H:i:s')));return$this->json(true,'Pengajuan dikirim untuk persetujuan.');});
}
public function approve_manual($id)
{
return$this->transaction(function()use($id){$r=$this->request((int)$id,true);if(!$r||$r->status!=='submitted')throw new BusinessException('Pengajuan tidak sedang menunggu persetujuan.');if($this->isLocked($r->attendance_date))throw new BusinessException('Periode absensi sudah dikunci untuk payroll.');$approval=$this->approvalservice->act($r->approval_request_id,'approved',$this->userId(),$this->roleId(),trim((string)$this->input->post('notes',true))?:null);if($approval->status==='approved'){$attendanceId=$this->applyAttendance($r);$this->db->where('id',$r->id)->update('manual_attendance_requests',array('status'=>'approved','approved_by'=>$this->userId(),'approved_at'=>date('Y-m-d H:i:s'),'applied_attendance_id'=>$attendanceId,'updated_at'=>date('Y-m-d H:i:s')));}return$this->json(true,'Absensi manual disetujui dan masuk ke daftar kehadiran.');});
}
public function reject_manual($id)
{
return$this->transaction(function()use($id){$reason=trim((string)$this->input->post('reason',true));if($reason==='')throw new BusinessException('Alasan penolakan wajib diisi.');$r=$this->request((int)$id,true);if(!$r||$r->status!=='submitted')throw new BusinessException('Pengajuan tidak sedang menunggu persetujuan.');$this->approvalservice->act($r->approval_request_id,'rejected',$this->userId(),$this->roleId(),$reason);$this->db->where('id',$r->id)->update('manual_attendance_requests',array('status'=>'rejected','rejected_by'=>$this->userId(),'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>$reason,'updated_at'=>date('Y-m-d H:i:s')));return$this->json(true,'Pengajuan absensi manual ditolak.');});
}
private function applyAttendance($request)
{
$schedule=$this->db->select('s.*,sh.late_tolerance_minutes')->from('k_employee_shift_schedules s')->join('k_shifts sh','sh.id=s.shift_id','left')->where('s.employee_id',$request->employee_id)->where('DATE(s.start_datetime)',$request->attendance_date)->where_in('s.status',array('published','done'))->order_by('s.id')->get()->row();if(!$schedule)throw new BusinessException('Jadwal kerja karyawan pada tanggal tersebut tidak ditemukan atau belum dipublikasikan.');
$attendance=$this->db->query('SELECT * FROM k_attendances WHERE employee_id=? AND attendance_date=? FOR UPDATE',array($request->employee_id,$request->attendance_date))->row();$dateTime=$request->attendance_date.' '.$request->attendance_time;$note='[MANUAL '.$request->request_no.'] '.$request->reason;
if($request->attendance_action==='check_in'){
if($attendance&&!empty($attendance->checkin_time)&&$attendance->attendance_status!=='alpha')throw new BusinessException('Jam masuk sudah tercatat. Gunakan alur koreksi jika waktunya perlu diubah.');
$late=max(0,(int)floor((strtotime($dateTime)-(strtotime($schedule->start_datetime)+((int)$schedule->late_tolerance_minutes*60)))/60));$data=array('checkin_time'=>$dateTime,'late_minutes'=>$late,'attendance_status'=>$late>0?'late':'present','shift_id'=>$schedule->shift_id,'notes'=>trim(($attendance&&$attendance->notes?$attendance->notes."\n":'').$note));
if($attendance){$this->db->where('id',$attendance->id)->update('k_attendances',$data);return(int)$attendance->id;}$data['employee_id']=$request->employee_id;$data['attendance_date']=$request->attendance_date;$data['created_at']=date('Y-m-d H:i:s');$this->db->insert('k_attendances',$data);return(int)$this->db->insert_id();
}
if(!$attendance||empty($attendance->checkin_time))throw new BusinessException('Jam masuk belum tercatat. Ajukan jam masuk terlebih dahulu.');if(!empty($attendance->checkout_time))throw new BusinessException('Jam pulang sudah tercatat. Gunakan alur koreksi jika waktunya perlu diubah.');if(strtotime($dateTime)<=strtotime($attendance->checkin_time))throw new BusinessException('Jam pulang harus setelah jam masuk.');
$work=round((strtotime($dateTime)-strtotime($attendance->checkin_time))/3600,2);$early=max(0,(int)floor((strtotime($schedule->end_datetime)-strtotime($dateTime))/60));$overtime=max(0,round((strtotime($dateTime)-strtotime($schedule->end_datetime))/3600,2));$this->db->where('id',$attendance->id)->update('k_attendances',array('checkout_time'=>$dateTime,'work_hours'=>$work,'early_leave_minutes'=>$early,'overtime_hours'=>$overtime,'notes'=>trim(($attendance->notes?$attendance->notes."\n":'').$note)));return(int)$attendance->id;
}
private function request($id,$lock=false){return$this->db->query('SELECT r.*,e.employee_code,e.full_name FROM manual_attendance_requests r JOIN k_employees e ON e.id=r.employee_id WHERE r.id=? AND r.company_id=?'.($lock?' FOR UPDATE':''),array($id,$this->companyId()))->row();}
private function isLocked($date)
{
if ($this->db->table_exists('payroll_attendance_locks')) {
$range = $this->db->where(array('company_id'=>$this->companyId(),'status'=>'locked'))
->where('start_date <=',$date)->where('end_date >=',$date)
->get('payroll_attendance_locks')->row();
if ($range) return true;
}
return (bool)$this->db->where(array('period'=>substr($date,0,7),'status'=>'locked'))->get('attendance_period_locks')->row();
}
private function validDate($value){$d=DateTime::createFromFormat('Y-m-d',(string)$value);return$d&&$d->format('Y-m-d')===$value?$value:null;}
private function validTime($value){foreach(array('H:i','H:i:s')as$f){$d=DateTime::createFromFormat($f,(string)$value);if($d&&$d->format($f)===$value)return$d->format('H:i:s');}return null;}
private function transaction($callback){$this->db->trans_begin();try{$response=$callback();if($this->db->trans_status()===false)throw new RuntimeException('Transaksi database gagal.');$this->db->trans_commit();return$response;}catch(Throwable$e){$this->db->trans_rollback();return$this->json(false,$e->getMessage(),array(),$e instanceof BusinessException?422:500);}}
private function json($status,$message,$data=array(),$code=200){return$this->output->set_status_header($code)->set_content_type('application/json')->set_output(json_encode(array_merge(array('status'=>$status,'message'=>$message),$data)));}
}