update HR dan Payroll

This commit is contained in:
Wian Drs
2026-09-14 11:14:25 +07:00
parent ec94783db4
commit 63993ee76e
50 changed files with 2432 additions and 1473 deletions
@@ -1,65 +1,112 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
require_once APPPATH.'exceptions/BusinessException.php';
class Attendancemonitoring extends MY_Controller {
class Attendancemonitoring extends MY_Controller
{
public function __construct()
{
parent::__construct();
$this->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()
{
$data = [
"active_menu" => "attendance_monitoring"
];
$this->load->view('partials/header', $data);
$this->load->view('employees/attendance_monitoring');
$this->load->view('partials/footer');
$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->input->get('date');
if(!$date){
$date = date('Y-m-d');
}
$data = $this->db->query("
SELECT
a.*,
e.employee_code,
e.full_name,
s.shift_name
FROM k_attendances a
LEFT JOIN k_employees e ON e.id = a.employee_id
LEFT JOIN k_shifts s ON s.id = a.shift_id
WHERE a.attendance_date = '$date'
ORDER BY a.checkin_time DESC
")->result();
$rows = [];
$no = 1;
foreach($data as $d){
$rows[] = [
$no++,
$d->employee_code,
$d->full_name,
$d->shift_name,
$d->checkin_time,
$d->checkout_time,
$d->attendance_status
];
}
echo json_encode([
'data' => $rows
]);
$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)):'-','<span class="badge bg-'.$class.'">'.html_escape($labels[$status]??ucfirst($status)).'</span>');}
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='<button class="btn btn-sm btn-outline-primary manual-detail" data-id="'.$r->id.'"><i class="bi bi-eye"></i></button> ';
if(in_array($r->status,array('draft','rejected'),true)&&$this->can('can_create')&&((int)$r->created_by===$this->userId()||$this->can('can_update')))$actions.='<button class="btn btn-sm btn-warning manual-submit" data-id="'.$r->id.'">Ajukan</button> ';
if($r->status==='submitted'&&$this->can('can_approve'))$actions.='<button class="btn btn-sm btn-success manual-approve" data-id="'.$r->id.'">Approve</button> <button class="btn btn-sm btn-outline-danger manual-reject" data-id="'.$r->id.'">Reject</button>';
$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)));}
}
+41 -17
View File
@@ -3,18 +3,20 @@ defined('BASEPATH') OR exit('No direct script access allowed');
class Auth extends CI_Controller {
public function __construct()
{
parent::__construct();
public function __construct()
{
parent::__construct();
$this->load->library('UserSessionService');
}
public function index()
{
if ($this->session->userdata('logged_in')) {
redirect('dashboard');
}
$this->load->view('auth/login');
public function index()
{
if ($this->session->userdata('logged_in')) {
redirect('dashboard');
}
$notices=array('expired'=>'Masa login Anda telah berakhir. Silakan login kembali.','revoked'=>'Session perangkat telah diakhiri. Silakan login kembali.','device_limit'=>'Login ditolak karena batas perangkat aktif telah tercapai.');
$key=(string)$this->input->get('notice',true);
$this->load->view('auth/login',array('auth_notice'=>$notices[$key]??''));
}
public function process_login()
@@ -40,11 +42,25 @@ class Auth extends CI_Controller {
return;
}
$user = $this->db->get_where('v_users', ['username' => $username])->row();
$user = $this->db->get_where('v_users', ['username' => $username])->row();
$account = $user ? $this->db->get_where('users', array('id' => (int)$user->id))->row() : null;
if ($user && (int)$user->role_is_active === 1 && password_verify($password, $user->password)) {
$this->session->sess_regenerate(true);
try {
$loginSecurity = $this->usersessionservice->begin($account ?: $user);
} catch (Throwable $exception) {
log_message('error', 'Pencatatan session login gagal: ' . $exception->getMessage());
$this->session->set_flashdata('error', 'Login belum dapat diproses karena pencatatan keamanan session gagal.');
redirect('auth');
return;
}
if (!$loginSecurity['allowed']) {
$this->session->set_flashdata('error', $loginSecurity['message']);
redirect('auth');
return;
}
$this->session->unset_userdata(array('login_attempts', 'login_locked_until'));
// Decode permissions
@@ -62,7 +78,12 @@ class Auth extends CI_Controller {
'role_id' => (int) $user->role_id,
'is_super_admin'=> $role && (int)$role->is_super_admin === 1,
'permissions' => $permissions,
'logged_in' => TRUE
'logged_in' => TRUE,
'email' => $account->email ?? null,
'profile_photo' => $account->profile_photo ?? null,
'auth_login_key'=> $loginSecurity['login_key'],
'auth_started_at'=> $loginSecurity['login_at'],
'auth_expires_at'=> $loginSecurity['expires_at']
];
$this->session->set_userdata($session_data);
@@ -79,6 +100,8 @@ class Auth extends CI_Controller {
} else {
$this->usersessionservice->recordInvalid($account, $username);
$attempts = (int) $this->session->userdata('login_attempts') + 1;
$this->session->set_userdata('login_attempts', $attempts);
@@ -102,9 +125,9 @@ class Auth extends CI_Controller {
}
}
public function logout()
{
$username = $this->session->userdata('username');
public function logout()
{
$username = $this->session->userdata('username');
// 🔥 LOG LOGOUT (SEBELUM DESTROY SESSION)
log_activity(
@@ -114,7 +137,8 @@ class Auth extends CI_Controller {
'success'
);
$this->session->sess_destroy();
$this->usersessionservice->endCurrent('logged_out', 'Logout oleh pengguna.');
$this->session->sess_destroy();
redirect('auth');
}
}
+51 -14
View File
@@ -7,10 +7,6 @@ class Employees extends MY_Controller {
{
parent::__construct();
// Data karyawan memuat rekening, BPJS, pajak, dan gaji sehingga
// sementara dibatasi ke Admin sampai role HR khusus dikonfigurasi.
$this->requireAdmin();
$this->load->database();
$this->load->helper(['url', 'file']);
@@ -27,7 +23,13 @@ class Employees extends MY_Controller {
public function index()
{
$data = [
"active_menu" => "employees"
"active_menu" => "employees",
"can_manage_all_salary" => $this->canManageAllSalary(),
"employee_permissions" => array(
'create'=>$this->canEmployee('can_create'),
'update'=>$this->canEmployee('can_update'),
'delete'=>$this->canEmployee('can_delete')
)
];
$data['departments'] = $this->db
@@ -59,7 +61,7 @@ class Employees extends MY_Controller {
{
try {
$data = $this->db
$query = $this->db
->select("
e.*,
@@ -87,9 +89,10 @@ class Employees extends MY_Controller {
'left'
)
->order_by('e.department_id','ASC')
->get()
->result();
->order_by('e.department_id','ASC');
if ($this->db->field_exists('company_id','k_employees')) $query->where('e.company_id',(int)$this->session->userdata('company_id'));
$data = $query->get()->result();
foreach ($data as $employee) $this->maskRestrictedSalary($employee);
return $this->json([
'status' => true,
@@ -141,7 +144,9 @@ class Employees extends MY_Controller {
'left'
)
->where('e.id',$id)
->where('e.id',$id);
if ($this->db->field_exists('company_id','k_employees')) $this->db->where('e.company_id',(int)$this->session->userdata('company_id'));
$data = $this->db
->get()
->row();
@@ -153,6 +158,7 @@ class Employees extends MY_Controller {
]);
}
$this->maskRestrictedSalary($data);
return $this->json([
'status' => true,
'data' => $data
@@ -198,6 +204,7 @@ class Employees extends MY_Controller {
$data['employee_code'] = $employee_code;
$data['photo'] = $photo;
if ($this->db->field_exists('company_id','k_employees')) $data['company_id']=(int)$this->session->userdata('company_id');
$this->db->insert('k_employees',$data);
$employee_id = $this->db->insert_id();
@@ -242,6 +249,10 @@ class Employees extends MY_Controller {
]);
}
if (!empty($employee->salary_restricted) && !$this->canManageAllSalary()) {
return $this->json(['status'=>false,'message'=>'Gaji karyawan ini bersifat terbatas. Anda memerlukan permission Manage All Gaji untuk mengubah datanya.']);
}
$data = $this->prepareData();
if (!empty($_FILES['photo_file']['name'])) {
@@ -303,6 +314,10 @@ class Employees extends MY_Controller {
]);
}
if (!empty($employee->salary_restricted) && !$this->canManageAllSalary()) {
return $this->json(['status'=>false,'message'=>'Karyawan dengan gaji terbatas hanya dapat dihapus oleh pengguna dengan permission Manage All Gaji.']);
}
if (
!empty($employee->photo)
&&
@@ -392,7 +407,7 @@ class Employees extends MY_Controller {
// =========================================================
private function prepareData()
{
return [
$data = [
'nik' => trim($this->input->post('nik')),
@@ -438,8 +453,6 @@ class Employees extends MY_Controller {
'branch_id' => $this->input->post('branch_id'),
'basic_salary' => $this->input->post('basic_salary') ?: 0,
'bank_name' => trim(
$this->input->post('bank_name')
),
@@ -464,8 +477,32 @@ class Employees extends MY_Controller {
$this->input->post('fingerprint_user_id')
),
'is_active' => $this->input->post('is_active') ?: 1
'is_active' => $this->input->post('is_active') === '0' ? 0 : 1
];
if ($this->db->field_exists('salary_restricted','k_employees') && $this->canManageAllSalary()) {
$data['salary_restricted'] = $this->input->post('salary_restricted') ? 1 : 0;
}
return $data;
}
private function canManageAllSalary()
{
return is_master_admin_user() || check_permission('payroll','can_manage_all_salary');
}
private function canEmployee($action)
{
return is_master_admin_user() || check_permission('hr_employees',$action) || check_permission('employees',$action);
}
private function maskRestrictedSalary($employee)
{
if (!$employee || empty($employee->salary_restricted) || $this->canManageAllSalary()) return;
$employee->basic_salary = null;
$employee->bank_name = null;
$employee->bank_account_number = null;
$employee->bank_account_name = null;
$employee->salary_hidden = true;
}
// =========================================================
+137 -10
View File
@@ -1,15 +1,142 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
require_once APPPATH.'exceptions/BusinessException.php';
class Hrpayroll extends MY_Controller
{
public function __construct(){parent::__construct();$this->requirePermission('employees');$this->load->database();$this->load->library('PayrollService');}
private function uid(){return(int)$this->session->userdata('user_id');}private function go($ok,$m){$this->session->set_flashdata($ok?'success':'error',$m);redirect('hrpayroll');}
public function index(){$d=array('active_menu'=>'hr_payroll');$d['periods']=$this->db->select('p.*,COUNT(x.id) employee_count,COALESCE(SUM(x.gross_salary),0) gross,COALESCE(SUM(x.total_deductions),0) deductions,COALESCE(SUM(x.total_salary),0) net')->from('k_payroll_periods p')->join('k_payrolls x','x.payroll_period_id=p.id','left')->group_by('p.id')->order_by('p.start_date','DESC')->get()->result();$d['components']=$this->db->order_by('code')->get('payroll_components')->result();$d['locks']=$this->db->order_by('period','DESC')->get('attendance_period_locks')->result();$d['cash_accounts']=$this->db->where('is_active',1)->get('cash_accounts')->result();$this->load->view('partials/header',$d);$this->load->view('hr_payroll/index',$d);$this->load->view('partials/footer');}
public function lock_attendance(){try{$period=$this->input->post('period');if($this->db->get_where('attendance_period_locks',array('period'=>$period,'branch_id'=>null))->row())$this->db->where('period',$period)->where('branch_id IS NULL',null,false)->update('attendance_period_locks',array('status'=>'locked','locked_by'=>$this->uid(),'locked_at'=>date('Y-m-d H:i:s'),'notes'=>$this->input->post('notes',true)));else$this->db->insert('attendance_period_locks',array('period'=>$period,'status'=>'locked','locked_by'=>$this->uid(),'locked_at'=>date('Y-m-d H:i:s'),'notes'=>$this->input->post('notes',true)));$this->go(true,'Absensi '.$period.' dikunci.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function calculate(){try{$id=$this->payrollservice->calculate($this->input->post('month'),$this->uid());$this->go(true,'Draft payroll dihitung. Periode #'.$id);}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function workflow($id,$action){try{$this->payrollservice->workflow($id,$action,$this->uid());$this->go(true,'Workflow payroll berhasil: '.$action);}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function post($id){try{$this->payrollservice->post($id,$this->input->post('date'),$this->uid());$this->go(true,'Payroll berhasil diposting ke jurnal.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function pay($id){try{$this->payrollservice->pay($id,$this->input->post('date'),(int)$this->input->post('cash_account_id'),$this->uid());$this->go(true,'Payroll berhasil dibayar dan jurnal pembayaran diposting.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function component(){try{$id=(int)$this->input->post('id');$data=array('code'=>strtoupper(trim($this->input->post('code',true))),'name'=>$this->input->post('name',true),'component_type'=>$this->input->post('component_type'),'category'=>$this->input->post('category'),'formula_expression'=>$this->input->post('formula_expression',true)?:null,'fixed_amount'=>$this->input->post('fixed_amount')!==''?(float)$this->input->post('fixed_amount'):null,'taxable'=>$this->input->post('taxable')?1:0,'prorated'=>$this->input->post('prorated')?1:0,'expense_account_id'=>$this->input->post('expense_account_id')?:null,'liability_account_id'=>$this->input->post('liability_account_id')?:null,'is_active'=>1);if($id)$this->db->where('id',$id)->update('payroll_components',$data);else$this->db->insert('payroll_components',$data);$this->go(true,'Komponen payroll disimpan.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function slip($payrollId){$p=$this->db->select('x.*,e.full_name,e.employee_code,r.period_name,r.workflow_status')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->join('k_payroll_periods r','r.id=x.payroll_period_id')->where('x.id',$payrollId)->get()->row();if(!$p||!in_array($p->workflow_status,array('final','posted','paid'),true))show_error('Slip belum tersedia.',422);$lines=$this->db->where('payroll_id',$payrollId)->get('payroll_calculation_lines')->result();$this->load->view('hr_payroll/slip',array('payroll'=>$p,'lines'=>$lines));}
public function __construct()
{
parent::__construct();$this->load->database();$this->load->library(array('PayrollService','AuditService','FormulaService'));
if(is_file(FCPATH.'vendor/autoload.php'))require_once FCPATH.'vendor/autoload.php';
}
private function uid(){return(int)$this->session->userdata('user_id');}
private function company(){return(int)$this->session->userdata('company_id');}
private function allSalary(){return is_master_admin_user()||check_permission('payroll','can_manage_all_salary');}
private function can($action){return is_master_admin_user()||check_permission('payroll',$action)||check_permission('employees',$action);}
private function go($ok,$message,$section='periods'){$this->session->set_flashdata($ok?'success':'error',$message);redirect('hrpayroll?section='.$section);}
private function validDate($value){$date=DateTime::createFromFormat('Y-m-d',(string)$value);return$date&&$date->format('Y-m-d')===$value?$value:null;}
private function jsonError(Throwable$e){return json_response(false,$e->getMessage(),array(),$e instanceof BusinessException?422:500);}
public function index()
{
$section=$this->input->get('section',true)?:'periods';if(!in_array($section,array('periods','salaries','settings','adjustments'),true))$section='periods';
$company=$this->company();$visible=$this->allSalary()?'1=1':'COALESCE(e.salary_restricted,0)=0';
$periods=$this->db->query("SELECT p.*,COUNT(CASE WHEN {$visible} THEN x.id END) employee_count,COALESCE(SUM(CASE WHEN {$visible} THEN x.gross_salary ELSE 0 END),0) gross,COALESCE(SUM(CASE WHEN {$visible} THEN x.total_deductions ELSE 0 END),0) deductions,COALESCE(SUM(CASE WHEN {$visible} THEN x.total_salary ELSE 0 END),0) net,l.status attendance_lock_status FROM k_payroll_periods p LEFT JOIN k_payrolls x ON x.payroll_period_id=p.id LEFT JOIN k_employees e ON e.id=x.employee_id LEFT JOIN payroll_attendance_locks l ON l.payroll_period_id=p.id AND l.company_id=p.company_id WHERE p.company_id=? GROUP BY p.id,l.status ORDER BY COALESCE(p.attendance_start_date,p.start_date) DESC",array($company))->result();
$components=$this->db->where('company_id',$company)->order_by('component_type')->order_by('code')->get('payroll_components')->result();
$employees=$this->db->select('e.id,e.employee_code,e.full_name,e.department_id,e.position_id,e.is_active,e.salary_restricted,d.department_name,p.position_name')->from('k_employees e')->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')->where('e.company_id',$company)->order_by('e.full_name')->get()->result();
if(!$this->allSalary())$employees=array_values(array_filter($employees,function($e){return empty($e->salary_restricted);}));
$salaryRows=$this->salarySummaries($employees,$components);
$positions=$this->db->select('p.id,p.code,p.position_name,p.department_id,d.department_name')->from('k_positions p')->join('k_departments d','d.id=p.department_id','left')->where('p.is_active',1)->order_by('p.position_name')->get()->result();
$departments=$this->db->where('is_active',1)->order_by('department_name')->get('k_departments')->result();
$positionAssignments=$this->db->select('x.*,p.position_name,c.code component_code,c.name component_name,c.component_type')->from('position_payroll_components x')->join('k_positions p','p.id=x.position_id')->join('payroll_components c','c.id=x.component_id')->where('x.company_id',$company)->order_by('p.position_name')->order_by('c.name')->get()->result();
$rules=$this->db->select('r.*,c.code component_code,c.name component_name')->from('payroll_attendance_rules r')->join('payroll_components c','c.id=r.component_id','left')->where('r.company_id',$company)->order_by('r.condition_type')->order_by('r.effective_from','DESC')->get()->result();
$adjustments=$this->db->select('a.*,p.period_name,p.workflow_status,e.employee_code,e.full_name,c.code component_code,c.name component_name')->from('payroll_period_adjustments a')->join('k_payroll_periods p','p.id=a.payroll_period_id')->join('k_employees e','e.id=a.employee_id')->join('payroll_components c','c.id=a.component_id')->where('a.company_id',$company)->where('a.is_active',1)->order_by('a.id','DESC')->get()->result();
if(!$this->allSalary())$adjustments=array_values(array_filter($adjustments,function($row)use($employees){foreach($employees as$employee)if($employee->id==$row->employee_id)return true;return false;}));
$cashQuery=$this->db->where('is_active',1);if($this->db->field_exists('company_id','cash_accounts'))$cashQuery->where('company_id',$company);$cash=$cashQuery->order_by('name')->get('cash_accounts')->result();
$accounts=$this->db->where(array('company_id'=>$company,'is_active'=>1))->order_by('kode_akun')->get('accounts')->result();
$settings=$this->db->get_where('payroll_settings',array('company_id'=>$company))->row();
$data=array('active_menu'=>'hr_payroll','payroll_section'=>$section,'periods'=>$periods,'components'=>$components,'employees'=>$employees,'salary_rows'=>$salaryRows,'positions'=>$positions,'departments'=>$departments,'position_assignments'=>$positionAssignments,'attendance_rules'=>$rules,'adjustments'=>$adjustments,'cash_accounts'=>$cash,'accounts'=>$accounts,'payroll_settings'=>$settings,'can_manage_all_salary'=>$this->allSalary(),'permissions'=>array('create'=>$this->can('can_create'),'update'=>$this->can('can_update'),'approve'=>$this->can('can_approve'),'post'=>$this->can('can_post'),'export'=>$this->can('can_export')));
$this->load->view('partials/header',$data);$this->load->view('hr_payroll/index',$data);$this->load->view('partials/footer');
}
private function salarySummaries($employees,$components)
{
$componentMap=array();foreach($components as$c)$componentMap[$c->id]=$c;$today=date('Y-m-d');$rows=array();
foreach($employees as$employee){$direct=$this->db->where(array('company_id'=>$this->company(),'employee_id'=>$employee->id,'is_active'=>1))->where('effective_from <=',$today)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$today)->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->result();$position=$this->db->where(array('company_id'=>$this->company(),'position_id'=>$employee->position_id,'is_active'=>1))->where('effective_from <=',$today)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$today)->group_end()->order_by('effective_from','DESC')->get('position_payroll_components')->result();$resolved=array();foreach($position as$x)if(!isset($resolved[$x->component_id]))$resolved[$x->component_id]=$x;foreach($direct as$x)$resolved[$x->component_id]=$x;$earning=0;$deduction=0;$effective=null;foreach($resolved as$x){if(!isset($componentMap[$x->component_id]))continue;$value=(float)($x->amount_override!==null?$x->amount_override:($componentMap[$x->component_id]->default_value!==null?$componentMap[$x->component_id]->default_value:$componentMap[$x->component_id]->fixed_amount));if($x->calculation_method==='fixed'){if($componentMap[$x->component_id]->component_type==='earning')$earning+=$value;elseif($componentMap[$x->component_id]->component_type==='deduction')$deduction+=$value;}if(!$effective||$x->effective_from>$effective)$effective=$x->effective_from;}$employee->fixed_earnings=$earning;$employee->fixed_deductions=$deduction;$employee->estimated_net=$earning-$deduction;$employee->effective_from=$effective;$rows[]=$employee;}return$rows;
}
public function create_period()
{
try{if(!$this->can('can_create'))throw new BusinessException('Anda tidak mempunyai permission membuat periode payroll.');$name=trim((string)$this->input->post('period_name',true));$start=$this->validDate($this->input->post('attendance_start_date'));$end=$this->validDate($this->input->post('attendance_end_date'));$payment=$this->validDate($this->input->post('payment_date'));if($name===''||!$start||!$end||!$payment)throw new BusinessException('Nama periode, rentang kehadiran, dan tanggal pembayaran wajib diisi.');if($end<$start)throw new BusinessException('Tanggal akhir kehadiran tidak boleh sebelum tanggal awal.');$overlap=(int)$this->db->query("SELECT COUNT(*) total FROM k_payroll_periods WHERE company_id=? AND workflow_status<>'cancelled' AND COALESCE(attendance_start_date,start_date)<=? AND COALESCE(attendance_end_date,end_date)>=?",array($this->company(),$end,$start))->row()->total;if($overlap)throw new BusinessException('Rentang kehadiran tumpang tindih dengan periode payroll lain.');$row=array('company_id'=>$this->company(),'period_name'=>$name,'start_date'=>$start,'end_date'=>$end,'attendance_start_date'=>$start,'attendance_end_date'=>$end,'payment_date'=>$payment,'notes'=>trim((string)$this->input->post('notes',true))?:null,'status'=>'draft','workflow_status'=>'draft','generated_at'=>date('Y-m-d H:i:s'));$this->db->insert('k_payroll_periods',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','payroll_period',$id,'create',null,$row,$this->uid());$this->go(true,'Periode payroll berhasil dibuat.');}catch(Throwable$e){$this->go(false,$e->getMessage());}
}
public function lock_attendance()
{
try{$id=(int)$this->input->post('period_id');$period=$this->period($id);if($period->workflow_status!=='draft')throw new BusinessException('Kehadiran hanya dapat dikunci ketika payroll masih Draft.');$start=$period->attendance_start_date?:$period->start_date;$end=$period->attendance_end_date?:$period->end_date;$pending=array();if($this->db->table_exists('manual_attendance_requests')){$n=$this->db->where('company_id',$this->company())->where('status','submitted')->where('attendance_date >=',$start)->where('attendance_date <=',$end)->count_all_results('manual_attendance_requests');if($n)$pending[]=$n.' absensi manual';}if($this->db->table_exists('k_leave_requests')){$n=$this->db->where('company_id',$this->company())->where('workflow_status','submitted')->where('start_date <=',$end)->where('end_date >=',$start)->count_all_results('k_leave_requests');if($n)$pending[]=$n.' cuti/izin';}if($this->db->table_exists('attendance_corrections')){$n=$this->db->from('attendance_corrections c')->join('k_attendances a','a.id=c.attendance_id')->join('k_employees e','e.id=a.employee_id')->where(array('e.company_id'=>$this->company(),'c.status'=>'submitted'))->where('a.attendance_date >=',$start)->where('a.attendance_date <=',$end)->count_all_results();if($n)$pending[]=$n.' koreksi absensi';}if($pending)throw new BusinessException('Periode belum dapat dikunci karena masih ada '.implode(', ',$pending).' menunggu approval.');$existing=$this->db->get_where('payroll_attendance_locks',array('company_id'=>$this->company(),'payroll_period_id'=>$id))->row();$row=array('company_id'=>$this->company(),'payroll_period_id'=>$id,'start_date'=>$start,'end_date'=>$end,'status'=>'locked','locked_by'=>$this->uid(),'locked_at'=>date('Y-m-d H:i:s'),'notes'=>trim((string)$this->input->post('notes',true))?:null);if($existing)$this->db->where('id',$existing->id)->update('payroll_attendance_locks',$row);else$this->db->insert('payroll_attendance_locks',$row);$this->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('k_payroll_periods',array('attendance_locked_by'=>$this->uid(),'attendance_locked_at'=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s')));$this->auditservice->record('payroll','attendance_lock',$id,'lock',null,$row,$this->uid());$this->go(true,'Kehadiran '.$start.' sampai '.$end.' berhasil dikunci.');}catch(Throwable$e){$this->go(false,$e->getMessage());}
}
public function calculate(){try{$id=$this->payrollservice->calculate((int)$this->input->post('period_id'),$this->uid(),$this->allSalary(),$this->company());$this->go(true,'Draft payroll periode #'.$id.' berhasil dihitung.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function workflow($id,$action){try{$this->payrollservice->workflow($id,$action,$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Tahap payroll berhasil diperbarui.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function post($id){try{$this->payrollservice->post($id,$this->input->post('date'),$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Payroll berhasil diposting ke jurnal.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function pay($id){try{$this->payrollservice->pay($id,$this->input->post('date'),(int)$this->input->post('cash_account_id'),$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Payroll berhasil dibayar.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function period_detail($id)
{
$period=$this->period($id,false);if(!$period)return json_response(false,'Periode payroll tidak ditemukan.',array(),404);$q=$this->db->select('p.id,p.basic_salary,p.gross_salary,p.total_deductions,p.total_salary,p.total_present,p.total_alpha,p.total_overtime_hours,e.employee_code,e.full_name,e.salary_restricted')->from('k_payrolls p')->join('k_employees e','e.id=p.employee_id')->where(array('p.payroll_period_id'=>$period->id,'p.company_id'=>$this->company()));if(!$this->allSalary())$q->where('e.salary_restricted',0);return json_response(true,'Rincian payroll.',array('period'=>$period,'rows'=>$q->order_by('e.full_name')->get()->result()));
}
public function salary_detail($employeeId)
{
try{$employee=$this->employee((int)$employeeId);if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$lines=$this->db->select('x.*,c.code component_code,c.name component_name,c.component_type')->from('employee_payroll_components x')->join('payroll_components c','c.id=x.component_id')->where(array('x.company_id'=>$this->company(),'x.employee_id'=>$employee->id))->order_by('x.effective_from','DESC')->get()->result();return json_response(true,'Struktur gaji.',array('employee'=>$employee,'lines'=>$lines));}catch(Throwable$e){return$this->jsonError($e);}
}
public function save_salary_line()
{
try{if(!$this->can('can_update'))throw new BusinessException('Anda tidak mempunyai permission mengubah struktur gaji.');$employee=$this->employee((int)$this->input->post('employee_id'));if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$row=$this->assignmentInput('employee',$employee->id);$duplicate=$this->db->get_where('employee_payroll_components',array('company_id'=>$this->company(),'employee_id'=>$employee->id,'component_id'=>$row['component_id'],'effective_from'=>$row['effective_from']))->row();if($duplicate)throw new BusinessException('Komponen dengan tanggal mulai yang sama sudah ada. Gunakan tanggal efektif baru agar histori tidak tertimpa.');$this->db->trans_begin();$previous=$this->db->where(array('company_id'=>$this->company(),'employee_id'=>$employee->id,'component_id'=>$row['component_id'],'is_active'=>1))->where('effective_from <',$row['effective_from'])->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$row['effective_from'])->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->row();if($previous)$this->db->where('id',$previous->id)->update('employee_payroll_components',array('effective_to'=>date('Y-m-d',strtotime($row['effective_from'].' -1 day')),'updated_at'=>date('Y-m-d H:i:s')));$this->db->insert('employee_payroll_components',$row);$id=(int)$this->db->insert_id();if($row['component_id']===$this->basicComponentId())$this->db->where(array('id'=>$employee->id,'company_id'=>$this->company()))->update('k_employees',array('basic_salary'=>$row['amount_override']));$this->auditservice->record('payroll','employee_salary_component',$id,'create_version',$previous,$row,$this->uid());$this->db->trans_commit();return json_response(true,'Versi struktur gaji berhasil disimpan.');}catch(Throwable$e){$this->db->trans_rollback();return$this->jsonError($e);}
}
public function deactivate_salary_line($id)
{
try{$line=$this->db->get_where('employee_payroll_components',array('id'=>(int)$id,'company_id'=>$this->company()))->row();if(!$line)throw new BusinessException('Komponen gaji tidak ditemukan.');$employee=$this->employee($line->employee_id);if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$update=array('is_active'=>0,'effective_to'=>date('Y-m-d'),'updated_at'=>date('Y-m-d H:i:s'));$this->db->where('id',$line->id)->update('employee_payroll_components',$update);$this->auditservice->record('payroll','employee_salary_component',$line->id,'deactivate',$line,$update,$this->uid());return json_response(true,'Komponen dinonaktifkan tanpa menghapus histori.');}catch(Throwable$e){return$this->jsonError($e);}
}
public function component()
{
try{if(!$this->can('can_update'))throw new BusinessException('Anda tidak mempunyai permission mengelola komponen.');$id=(int)$this->input->post('id');$code=strtoupper(trim((string)$this->input->post('code',true)));$name=trim((string)$this->input->post('name',true));$type=$this->input->post('component_type');$method=$this->method($this->input->post('calculation_method'));$value=$this->decimal($this->input->post('default_value'));if($code===''||$name===''||!in_array($type,array('earning','deduction','employer_cost'),true))throw new BusinessException('Kode, nama, dan jenis komponen wajib diisi.');$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),'code'=>$code,'name'=>$name,'component_type'=>$type,'category'=>$this->input->post('category')?:'other','calculation_method'=>$method,'default_value'=>$value,'fixed_amount'=>$value,'formula_expression'=>$formula,'taxable'=>$this->input->post('taxable')?1:0,'prorated'=>$this->input->post('prorated')?1:0,'expense_account_id'=>$this->input->post('expense_account_id')?:null,'liability_account_id'=>$this->input->post('liability_account_id')?:null,'is_active'=>$this->input->post('is_active')?1:0,'effective_from'=>$this->validDate($this->input->post('effective_from'))?:date('Y-m-d'),'effective_to'=>$this->validDate($this->input->post('effective_to'))?:null,'updated_at'=>date('Y-m-d H:i:s'));$duplicate=$this->db->where(array('company_id'=>$this->company(),'code'=>$code))->where('id !=',$id)->count_all_results('payroll_components');if($duplicate)throw new BusinessException('Kode komponen sudah digunakan.');if($id){$before=$this->db->get_where('payroll_components',array('id'=>$id,'company_id'=>$this->company()))->row();if(!$before)throw new BusinessException('Komponen tidak ditemukan.');$this->db->where('id',$id)->update('payroll_components',$row);}else{$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('payroll_components',$row);$id=(int)$this->db->insert_id();$before=null;}$this->auditservice->record('payroll','payroll_component',$id,$before?'update':'create',$before,$row,$this->uid());$this->go(true,'Komponen payroll berhasil disimpan.','settings');}catch(Throwable$e){$this->go(false,$e->getMessage(),'settings');}
}
public function save_position_line()
{
try{$positionId=(int)$this->input->post('position_id');if(!$positionId||!$this->db->where('id',$positionId)->count_all_results('k_positions'))throw new BusinessException('Posisi tidak ditemukan.');$row=$this->assignmentInput('position',$positionId);if($this->db->get_where('position_payroll_components',array('company_id'=>$this->company(),'position_id'=>$positionId,'component_id'=>$row['component_id'],'effective_from'=>$row['effective_from']))->row())throw new BusinessException('Template komponen dengan tanggal efektif yang sama sudah ada.');$this->db->insert('position_payroll_components',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','position_salary_component',$id,'create',null,$row,$this->uid());$this->go(true,'Template posisi berhasil ditambahkan.','settings');}catch(Throwable$e){$this->go(false,$e->getMessage(),'settings');}
}
public function save_attendance_rule()
{
try{$condition=$this->input->post('condition_type');$method=$this->attendanceMethod($this->input->post('calculation_method'));$start=$this->validDate($this->input->post('effective_from'));if(!in_array($condition,array('alpha','late','early_leave','unpaid','missing_checkin','missing_checkout'),true)||!$start)throw new BusinessException('Kondisi dan tanggal efektif aturan tidak valid.');$componentId=$this->input->post('component_id')?(int)$this->input->post('component_id'):null;if($method!=='none'&&!$componentId)throw new BusinessException('Komponen potongan wajib dipilih bila aturan menghasilkan potongan.');$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),'component_id'=>$componentId,'rule_name'=>trim((string)$this->input->post('rule_name',true)),'condition_type'=>$condition,'calculation_method'=>$method,'tolerance_minutes'=>max(0,(int)$this->input->post('tolerance_minutes')),'rate_value'=>$this->decimal($this->input->post('rate_value')),'maximum_amount'=>$this->input->post('maximum_amount')!==''?$this->decimal($this->input->post('maximum_amount')):null,'formula_expression'=>$formula,'effective_from'=>$start,'effective_to'=>$this->validDate($this->input->post('effective_to'))?:null,'is_active'=>$this->input->post('is_active')?1:0,'notes'=>trim((string)$this->input->post('notes',true))?:null,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));if($row['rule_name']==='')throw new BusinessException('Nama aturan wajib diisi.');$this->db->insert('payroll_attendance_rules',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','attendance_rule',$id,'create_version',null,$row,$this->uid());$this->go(true,'Versi aturan kehadiran berhasil disimpan.','settings');}catch(Throwable$e){$this->go(false,$e->getMessage(),'settings');}
}
public function save_adjustment()
{
try{$period=$this->period((int)$this->input->post('payroll_period_id'),false);if($period->workflow_status!=='draft')throw new BusinessException('Penyesuaian hanya dapat dibuat pada payroll Draft.');$employee=$this->employee((int)$this->input->post('employee_id'));if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$component=$this->componentRow((int)$this->input->post('component_id'));$type=$this->input->post('adjustment_type');if(!in_array($type,array('earning','deduction'),true))throw new BusinessException('Jenis penyesuaian tidak valid.');$amount=$this->decimal($this->input->post('amount'));$notes=trim((string)$this->input->post('notes',true));if($amount<=0||$notes==='')throw new BusinessException('Nominal harus lebih dari nol dan keterangan wajib diisi.');$row=array('company_id'=>$this->company(),'payroll_period_id'=>$period->id,'employee_id'=>$employee->id,'component_id'=>$component->id,'adjustment_type'=>$type,'amount'=>$amount,'notes'=>$notes,'is_active'=>1,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));$this->db->insert('payroll_period_adjustments',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','period_adjustment',$id,'create',null,$row,$this->uid());$this->go(true,'Penyesuaian periode berhasil ditambahkan.','adjustments');}catch(Throwable$e){$this->go(false,$e->getMessage(),'adjustments');}
}
public function deactivate_adjustment($id)
{
try{$row=$this->db->select('a.*,p.workflow_status,e.salary_restricted')->from('payroll_period_adjustments a')->join('k_payroll_periods p','p.id=a.payroll_period_id')->join('k_employees e','e.id=a.employee_id')->where(array('a.id'=>(int)$id,'a.company_id'=>$this->company()))->get()->row();if(!$row)throw new BusinessException('Penyesuaian tidak ditemukan.');if($row->workflow_status!=='draft')throw new BusinessException('Penyesuaian payroll yang bukan Draft tidak dapat diubah.');if($row->salary_restricted&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$update=array('is_active'=>0,'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'));$this->db->where('id',$row->id)->update('payroll_period_adjustments',$update);$this->auditservice->record('payroll','period_adjustment',$row->id,'deactivate',$row,$update,$this->uid());$this->go(true,'Penyesuaian dinonaktifkan tanpa menghapus histori.','adjustments');}catch(Throwable$e){$this->go(false,$e->getMessage(),'adjustments');}
}
public function save_settings()
{
try{if(!$this->can('can_update'))throw new BusinessException('Anda tidak mempunyai permission mengubah pengaturan payroll.');$day=$this->input->post('default_payment_day');$day=$day===''?null:(int)$day;if($day!==null&&($day<1||$day>31))throw new BusinessException('Tanggal pembayaran default harus 1 sampai 31.');$before=$this->db->get_where('payroll_settings',array('company_id'=>$this->company()))->row();$row=array('separation_of_duties'=>$this->input->post('separation_of_duties')?1:0,'default_payment_day'=>$day,'bank_export_note'=>trim((string)$this->input->post('bank_export_note',true))?:null,'updated_at'=>date('Y-m-d H:i:s'));if($before)$this->db->where('id',$before->id)->update('payroll_settings',$row);else{$row['company_id']=$this->company();$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('payroll_settings',$row);}$this->auditservice->record('payroll','payroll_settings',$this->company(),'update',$before,$row,$this->uid());$this->go(true,'Pengaturan umum payroll berhasil disimpan.','settings');}catch(Throwable$e){$this->go(false,$e->getMessage(),'settings');}
}
private function assignmentInput($subject,$subjectId)
{
$component=$this->componentRow((int)$this->input->post('component_id'));$method=$this->method($this->input->post('calculation_method'));$amount=$this->decimal($this->input->post('amount_override'));$start=$this->validDate($this->input->post('effective_from'));$end=$this->validDate($this->input->post('effective_to'));if(!$start)throw new BusinessException('Tanggal mulai berlaku wajib diisi.');if($end&&$end<$start)throw new BusinessException('Tanggal selesai tidak boleh sebelum tanggal mulai.');$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),$subject.'_id'=>$subjectId,'component_id'=>$component->id,'calculation_method'=>$method,'amount_override'=>$amount,'formula_expression'=>$formula,'effective_from'=>$start,'effective_to'=>$end?:null,'is_active'=>1,'notes'=>trim((string)$this->input->post('notes',true))?:null,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));return$row;
}
private function method($method){$allowed=array('fixed','per_present_day','per_absent_day','per_minute','per_hour','percentage_basic','formula');if(!in_array($method,$allowed,true))throw new BusinessException('Metode perhitungan tidak valid.');return$method;}
private function attendanceMethod($method){$allowed=array('none','fixed','per_minute','per_hour','per_day','percentage_daily','formula');if(!in_array($method,$allowed,true))throw new BusinessException('Metode potongan tidak valid.');return$method;}
private function validateFormula($formula){if(!$formula)throw new BusinessException('Formula wajib diisi.');$variables=array('basic_salary'=>1,'calendar_days'=>1,'work_days'=>1,'present_days'=>1,'alpha_days'=>1,'unpaid_days'=>1,'unpaid_days_total'=>1,'late_minutes'=>1,'early_leave_minutes'=>1,'missing_checkin'=>1,'missing_checkout'=>1,'overtime_hours'=>1,'overtime_rate'=>1,'daily_rate'=>1,'condition_quantity'=>1,'rule_rate'=>1);$this->formulaservice->calculate($formula,$variables);}
private function decimal($value){$number=(float)str_replace(array('. ',','),array('','.'),(string)$value);if($number<0)throw new BusinessException('Nominal atau tarif tidak boleh negatif.');return round($number,2);}
private function employee($id){$row=$this->db->select('e.*,d.department_name,p.position_name')->from('k_employees e')->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')->where(array('e.id'=>$id,'e.company_id'=>$this->company()))->get()->row();if(!$row)throw new BusinessException('Karyawan tidak ditemukan pada perusahaan aktif.');return$row;}
private function componentRow($id){$row=$this->db->get_where('payroll_components',array('id'=>$id,'company_id'=>$this->company()))->row();if(!$row)throw new BusinessException('Komponen payroll tidak ditemukan.');return$row;}
private function basicComponentId(){$row=$this->db->get_where('payroll_components',array('company_id'=>$this->company(),'code'=>'BASIC'))->row();return$row?(int)$row->id:0;}
private function period($id,$throw=true){$row=$this->db->get_where('k_payroll_periods',array('id'=>(int)$id,'company_id'=>$this->company()))->row();if(!$row&&$throw)throw new BusinessException('Periode payroll tidak ditemukan.');return$row;}
public function slip($payrollId)
{
$payroll=$this->db->select('x.*,e.full_name,e.employee_code,e.salary_restricted,r.period_name,r.workflow_status,r.attendance_start_date,r.attendance_end_date,r.payment_date')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->join('k_payroll_periods r','r.id=x.payroll_period_id')->where(array('x.id'=>(int)$payrollId,'x.company_id'=>$this->company(),'r.company_id'=>$this->company()))->get()->row();if(!$payroll||!in_array($payroll->workflow_status,array('final','posted','paid'),true))show_error('Slip belum tersedia.',422);if($payroll->salary_restricted&&!$this->allSalary())show_error('Gaji ini bersifat terbatas.',403);$lines=$this->db->where('payroll_id',$payrollId)->get('payroll_calculation_lines')->result();$this->load->view('hr_payroll/slip',array('payroll'=>$payroll,'lines'=>$lines));
}
public function export_pdf($id)
{
try{$period=$this->exportablePeriod($id);$rows=$this->payrollRows($id);$company=$this->db->get_where('companies',array('id'=>$this->company()))->row();$html=$this->load->view('hr_payroll/register_pdf',array('period'=>$period,'rows'=>$rows,'company'=>$company),true);$dompdf=new Dompdf\Dompdf();$dompdf->loadHtml($html);$dompdf->setPaper('A4','landscape');$dompdf->render();$dompdf->stream('payroll-'.$period->id.'.pdf',array('Attachment'=>true));}catch(Throwable$e){show_error($e->getMessage(),422);}
}
public function export_excel($id,$bank='general')
{
try{$period=$this->exportablePeriod($id);$bank=strtolower($bank);if(!in_array($bank,array('general','bri','bca'),true))throw new BusinessException('Format bank tidak tersedia.');$rows=$this->payrollRows($id,$bank==='general'?null:$bank);if(!$rows)throw new BusinessException('Tidak ada data payroll untuk format yang dipilih.');$spreadsheet=new PhpOffice\PhpSpreadsheet\Spreadsheet();$sheet=$spreadsheet->getActiveSheet();$sheet->setTitle($bank==='general'?'Payroll Register':strtoupper($bank).' Source');$headers=$bank==='general'?array('No','Kode Karyawan','Nama Karyawan','Bank','Nomor Rekening','Gaji Pokok','Bruto','Potongan','Neto','Tanggal Efektif'):array('No','Nomor Rekening','Nama Penerima','Nominal Neto','Berita Pembayaran','Kode Karyawan','Tanggal Efektif');$sheet->fromArray($headers,null,'A1');$line=2;foreach($rows as$i=>$row){$values=$bank==='general'?array($i+1,$row->employee_code,$row->full_name,$row->bank_name,$row->bank_account_number,(float)$row->basic_salary,(float)$row->gross_salary,(float)$row->total_deductions,(float)$row->total_salary,$period->payment_date):array($i+1,$row->bank_account_number,$row->bank_account_name?:$row->full_name,(float)$row->total_salary,'Gaji '.$period->period_name,$row->employee_code,$period->payment_date);$sheet->fromArray($values,null,'A'.$line++);$accountColumn=$bank==='general'?'E':'B';$sheet->setCellValueExplicit($accountColumn.($line-1),(string)$row->bank_account_number,PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);}$sheet->getStyle('A1:'.$sheet->getHighestColumn().'1')->getFont()->setBold(true);foreach(range('A',$sheet->getHighestColumn())as$column)$sheet->getColumnDimension($column)->setAutoSize(true);if($bank!=='general'){$guide=$spreadsheet->createSheet();$guide->setTitle('PETUNJUK');$guide->fromArray(array(array('File sumber '.strtoupper($bank)),array('Gunakan converter/checksum resmi portal bank perusahaan bila diwajibkan.'),array('Periksa nama, rekening, nominal, dan tanggal efektif sebelum otorisasi.')),null,'A1');$guide->getColumnDimension('A')->setWidth(100);}header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');header('Content-Disposition: attachment; filename="payroll-'.$bank.'-'.$period->id.'.xlsx"');header('Cache-Control: max-age=0');(new PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet))->save('php://output');exit;}catch(Throwable$e){show_error($e->getMessage(),422);}
}
private function exportablePeriod($id){$period=$this->period($id);if(!in_array($period->workflow_status,array('final','posted','paid'),true))throw new BusinessException('Export tersedia setelah payroll berstatus Final.');if(!$this->allSalary()&&$this->hasRestrictedPeriod($id))throw new BusinessException('Export periode ini memuat gaji terbatas.');return$period;}
private function hasRestrictedPeriod($id){return$this->db->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->where(array('x.payroll_period_id'=>(int)$id,'x.company_id'=>$this->company(),'e.salary_restricted'=>1))->count_all_results()>0;}
private function payrollRows($id,$bank=null){$q=$this->db->select('x.*,e.employee_code,e.full_name,e.bank_name,e.bank_account_number,e.bank_account_name,e.salary_restricted')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->where(array('x.payroll_period_id'=>(int)$id,'x.company_id'=>$this->company()));if(!$this->allSalary())$q->where('e.salary_restricted',0);if($bank){$q->group_start()->like('e.bank_name',$bank);if($bank==='bri')$q->or_like('e.bank_name','Rakyat Indonesia');if($bank==='bca')$q->or_like('e.bank_name','Central Asia');$q->group_end();}$rows=$q->order_by('e.full_name')->get()->result();if($bank){$missing=array();foreach($rows as$row)if(trim((string)$row->bank_account_number)===''||trim((string)$row->bank_account_name)==='')$missing[]=$row->full_name;if($missing)throw new BusinessException('Data rekening belum lengkap: '.implode(', ',$missing).'.');}return$rows;}
}
+164 -250
View File
@@ -1,284 +1,198 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Leaverequests extends MY_Controller {
class Leaverequests extends MY_Controller
{
private $leaveTypes = array('annual','sick','permit','maternity','unpaid');
public function __construct()
{
parent::__construct();
$this->load->database();
$this->load->library(array('CompanyContext','ApprovalService','AuditService','TransactionService','NumberingService'));
}
// ==========================================
// PAGE
// ==========================================
private function company(){return (int)$this->companycontext->id();}
private function uid(){return (int)$this->session->userdata('user_id');}
private function roleId()
{
$role=(int)$this->session->userdata('role_id');
if($role)return $role;
$user=$this->db->select('role_id')->get_where('users',array('id'=>$this->uid()))->row();
return $user?(int)$user->role_id:0;
}
private function can($action){return is_master_admin_user()||check_permission('employees',$action);}
private function permissions(){return array('create'=>$this->can('can_create'),'update'=>$this->can('can_update'),'approve'=>$this->can('can_approve'),'export'=>$this->can('can_export'));}
public function index()
{
$data = [
"active_menu" => "leave_requests"
];
$data['employees'] = $this->db
->where('is_active',1)
->order_by('full_name','ASC')
->get('k_employees')
->result();
$this->load->view('partials/header', $data);
$this->load->view('employees/leave_requests', $data);
$company=$this->company();
$employees=$this->db->select('id,employee_code,fingerprint_user_id,full_name')->where(array('company_id'=>$company,'is_active'=>1))->order_by('full_name')->get('k_employees')->result();
$counts=$this->workflowCounts();
$data=array('active_menu'=>'leave_requests','employees'=>$employees,'permissions'=>$this->permissions(),'counts'=>$counts);
$this->load->view('partials/header',$data);
$this->load->view('employees/leave_requests',$data);
$this->load->view('partials/footer');
}
// ==========================================
// DATATABLE
// ==========================================
public function get_data()
{
$this->db->select('
lr.*,
e.full_name,
e.fingerprint_user_id
');
$this->db->from('k_leave_requests lr');
$this->db->join(
'k_employees e',
'e.id=lr.employee_id',
'left'
);
$this->db->order_by('lr.id','DESC');
$list = $this->db->get()->result();
$data = [];
$no = 1;
foreach($list as $r){
// =====================================
// LEAVE TYPE
// =====================================
$leave_type = '-';
if($r->leave_type == 'annual'){
$leave_type = 'Cuti Tahunan';
}
if($r->leave_type == 'sick'){
$leave_type = 'Sakit';
}
if($r->leave_type == 'permit'){
$leave_type = 'Izin';
}
if($r->leave_type == 'maternity'){
$leave_type = 'Cuti Melahirkan';
}
if($r->leave_type == 'unpaid'){
$leave_type = 'Cuti Tidak Dibayar';
}
// =====================================
// STATUS
// =====================================
if($r->approval_status == 'approved'){
$status = '
<span class="badge bg-success">
Disetujui
</span>
';
} elseif($r->approval_status == 'rejected'){
$status = '
<span class="badge bg-danger">
Ditolak
</span>
';
} else {
$status = '
<span class="badge bg-warning text-dark">
Pending
</span>
';
}
$data[] = [
$no++,
$r->fingerprint_user_id,
$r->full_name,
$leave_type,
$r->start_date,
$r->end_date,
$r->total_days . ' Hari',
$status,
'
<button class="btn btn-warning btn-sm btn-edit"
data-id="'.$r->id.'">
Edit
</button>
<button class="btn btn-danger btn-sm btn-delete"
data-id="'.$r->id.'">
Delete
</button>
'
];
}
echo json_encode([
"data" => $data
]);
$status=trim((string)$this->input->get('status'));
$type=trim((string)$this->input->get('type'));
$employee=(int)$this->input->get('employee_id');
$this->db->select('lr.id,lr.request_no,lr.employee_id,lr.leave_type,lr.start_date,lr.end_date,lr.total_days,lr.reason,lr.approval_status,lr.workflow_status,lr.submitted_at,lr.approved_at,lr.rejected_at,lr.rejection_reason,lr.created_by,lr.submitted_by,e.full_name,e.employee_code,e.fingerprint_user_id,d.department_name,p.position_name,creator.nama creator_name,approver.nama approver_name,rejector.nama rejector_name')
->from('k_leave_requests lr')->join('k_employees e','e.id=lr.employee_id','left')
->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')
->join('users creator','creator.id=lr.created_by','left')->join('users approver','approver.id=lr.approved_by','left')->join('users rejector','rejector.id=lr.rejected_by','left')
->where('lr.company_id',$this->company());
if(in_array($status,array('draft','submitted','approved','rejected','cancelled'),true))$this->db->where('lr.workflow_status',$status);
if(in_array($type,$this->leaveTypes,true))$this->db->where('lr.leave_type',$type);
if($employee>0)$this->db->where('lr.employee_id',$employee);
$rows=$this->db->order_by('lr.id','DESC')->get()->result();
return json_response(true,'Daftar pengajuan cuti.',array('data'=>$rows,'summary'=>$this->workflowCounts()));
}
// ==========================================
// DETAIL
// ==========================================
public function detail($id)
{
$data = $this->db
->get_where(
'k_leave_requests',
['id'=>$id]
)
->row();
echo json_encode($data);
$leave=$this->find((int)$id);
if(!$leave)return json_response(false,'Pengajuan cuti tidak ditemukan.',array(),404);
$history=array();
$history=$this->db->select('aa.action,aa.notes,aa.created_at,aa.step_order,u.nama user_name,aws.name step_name,ar.request_no approval_no')
->from('approval_actions aa')->join('approval_requests ar','ar.id=aa.request_id')
->join('users u','u.id=aa.user_id','left')->join('approval_workflow_steps aws','aws.workflow_id=ar.workflow_id AND aws.step_order=aa.step_order','left')
->where(array('ar.entity_type'=>'leave_request','ar.entity_id'=>$leave->id))->order_by('aa.id')->get()->result();
return json_response(true,'Detail pengajuan cuti.',array('leave'=>$leave,'history'=>$history,'permissions'=>$this->permissions()));
}
// ==========================================
// SAVE
// ==========================================
public function save()
{
if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);
$id=(int)$this->input->post('id');
$this->requirePermission('employees',$id?'can_update':'can_create');
try{
$start_date = $this->input->post('start_date');
$end_date = $this->input->post('end_date');
$total_days = (
strtotime($end_date)
-
strtotime($start_date)
) / 86400 + 1;
$data = [
'employee_id' => $this->input->post('employee_id'),
'leave_type' => $this->input->post('leave_type'),
'start_date' => $start_date,
'end_date' => $end_date,
'total_days' => $total_days,
'reason' => $this->input->post('reason'),
'approval_status' => $this->input->post('approval_status')
];
$this->db->insert(
'k_leave_requests',
$data
);
echo json_encode([
'status' => true,
'message'=> 'Leave request berhasil ditambahkan'
]);
} catch(Exception $e){
echo json_encode([
'status' => false,
'message'=> $e->getMessage()
]);
}
$saved=$this->transactionservice->run(function()use($id){
$before=$id?$this->lock($id):null;
if($id&&!in_array($before->workflow_status,array('draft','rejected'),true))throw new BusinessException('Hanya Draft atau pengajuan yang ditolak yang dapat diperbaiki.');
$employeeId=(int)$this->input->post('employee_id');
$employee=$this->db->get_where('k_employees',array('id'=>$employeeId,'company_id'=>$this->company(),'is_active'=>1))->row();
if(!$employee)throw new BusinessException('Karyawan aktif tidak ditemukan.');
$type=trim((string)$this->input->post('leave_type'));
if(!in_array($type,$this->leaveTypes,true))throw new BusinessException('Jenis cuti atau izin tidak valid.');
$start=$this->validDate($this->input->post('start_date'),'Tanggal mulai');
$end=$this->validDate($this->input->post('end_date'),'Tanggal selesai');
if($end<$start)throw new BusinessException('Tanggal selesai tidak boleh sebelum tanggal mulai.');
$days=(int)((strtotime($end)-strtotime($start))/86400)+1;
if($days<1||$days>366)throw new BusinessException('Rentang cuti harus antara 1 sampai 366 hari.');
$reason=trim((string)$this->input->post('reason'));
if($reason==='')throw new BusinessException('Alasan pengajuan wajib diisi.');
if(mb_strlen($reason)>2000)throw new BusinessException('Alasan pengajuan maksimal 2.000 karakter.');
$overlap=$this->db->where(array('company_id'=>$this->company(),'employee_id'=>$employeeId))->where_in('workflow_status',array('submitted','approved'))->where('start_date <=',$end)->where('end_date >=',$start);
if($id)$overlap->where('id !=',$id);
if($overlap->count_all_results('k_leave_requests'))throw new BusinessException('Karyawan sudah mempunyai pengajuan aktif pada rentang tanggal tersebut.');
$row=array('employee_id'=>$employeeId,'leave_type'=>$type,'start_date'=>$start,'end_date'=>$end,'total_days'=>$days,'reason'=>$reason,'approval_status'=>'pending','workflow_status'=>'draft','rejection_reason'=>null,'rejected_by'=>null,'rejected_at'=>null,'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'));
if($id){$row['version']=(int)$before->version+1;$this->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('k_leave_requests',$row);$leaveId=$id;$auditAction='update';}
else{$row['company_id']=$this->company();$row['request_no']=$this->numberingservice->next('leave_request',$start);$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$row['version']=1;$this->db->insert('k_leave_requests',$row);$leaveId=(int)$this->db->insert_id();$auditAction='create';}
$after=$this->find($leaveId);
$this->auditservice->record('hr','leave_request',$leaveId,$auditAction,$before,$after,$this->uid());
return $leaveId;
});
return json_response(true,$id?'Draft pengajuan berhasil diperbarui.':'Draft pengajuan berhasil dibuat.',array('id'=>$saved));
}catch(Throwable$e){return business_exception_response($e);}
}
// ==========================================
// UPDATE
// ==========================================
public function update()
public function submit($id)
{
try{
$id = $this->input->post('id');
$start_date = $this->input->post('start_date');
$end_date = $this->input->post('end_date');
$total_days = (
strtotime($end_date)
-
strtotime($start_date)
) / 86400 + 1;
$data = [
'employee_id' => $this->input->post('employee_id'),
'leave_type' => $this->input->post('leave_type'),
'start_date' => $start_date,
'end_date' => $end_date,
'total_days' => $total_days,
'reason' => $this->input->post('reason'),
'approval_status' => $this->input->post('approval_status')
];
$this->db->where('id',$id);
$this->db->update(
'k_leave_requests',
$data
);
echo json_encode([
'status' => true,
'message'=> 'Leave request berhasil diupdate'
]);
} catch(Exception $e){
echo json_encode([
'status' => false,
'message'=> $e->getMessage()
]);
}
$this->requirePermission('employees','can_create');
return $this->action(function()use($id){
$before=$this->lock((int)$id);
if($before->workflow_status!=='draft')throw new BusinessException('Hanya Draft yang dapat diajukan.');
$request=$this->approvalservice->create('hr','leave_request',$before->id,0,$this->uid(),array('company_id'=>$this->company(),'request_no'=>$before->request_no,'employee_id'=>$before->employee_id,'employee_name'=>$before->full_name,'leave_type'=>$before->leave_type,'start_date'=>$before->start_date,'end_date'=>$before->end_date,'total_days'=>$before->total_days));
$update=array('workflow_status'=>'submitted','approval_status'=>'pending','approval_request_id'=>(int)$request->id,'submitted_by'=>$this->uid(),'submitted_at'=>date('Y-m-d H:i:s'),'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$before->version+1);
$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('k_leave_requests',$update);
$this->auditservice->record('hr','leave_request',$before->id,'submit',$before,$update,$this->uid());
return $before->id;
},'Pengajuan cuti berhasil dikirim untuk persetujuan.');
}
// ==========================================
// DELETE
// ==========================================
public function delete($id)
public function approve($id)
{
$this->db->delete(
'k_leave_requests',
['id'=>$id]
);
$this->requirePermission('employees','can_approve');
return $this->action(function()use($id){
$before=$this->lock((int)$id);
if($before->workflow_status!=='submitted')throw new BusinessException('Pengajuan tidak sedang menunggu persetujuan.');
$request=$this->approvalservice->pendingForEntity('leave_request',$before->id);
if(!$request)throw new BusinessException('Permintaan persetujuan tidak ditemukan.');
$result=$this->approvalservice->act($request->id,'approved',$this->uid(),$this->roleId(),trim((string)$this->input->post('notes'))?:null);
$update=array('updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$before->version+1);
if($result->status==='approved'){$update['workflow_status']='approved';$update['approval_status']='approved';$update['approved_by']=$this->uid();$update['approved_at']=date('Y-m-d H:i:s');}
$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('k_leave_requests',$update);
$this->auditservice->record('hr','leave_request',$before->id,'approve',$before,$update,$this->uid());
return $before->id;
},'Pengajuan cuti berhasil disetujui.');
}
echo json_encode([
'status' => true,
'message'=> 'Leave request berhasil dihapus'
]);
public function reject($id)
{
$this->requirePermission('employees','can_approve');
return $this->action(function()use($id){
$reason=trim((string)$this->input->post('reason'));
if($reason==='')throw new BusinessException('Alasan penolakan wajib diisi.');
$before=$this->lock((int)$id);
if($before->workflow_status!=='submitted')throw new BusinessException('Pengajuan tidak sedang menunggu persetujuan.');
$request=$this->approvalservice->pendingForEntity('leave_request',$before->id);
if(!$request)throw new BusinessException('Permintaan persetujuan tidak ditemukan.');
$this->approvalservice->act($request->id,'rejected',$this->uid(),$this->roleId(),$reason);
$update=array('workflow_status'=>'rejected','approval_status'=>'rejected','rejected_by'=>$this->uid(),'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>$reason,'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$before->version+1);
$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('k_leave_requests',$update);
$this->auditservice->record('hr','leave_request',$before->id,'reject',$before,$update,$this->uid());
return $before->id;
},'Pengajuan cuti ditolak dan dapat diperbaiki kembali.');
}
public function cancel($id)
{
if(!$this->can('can_update')&&!$this->can('can_create'))return json_response(false,'Anda tidak memiliki izin untuk membatalkan Draft.',array(),403);
return $this->action(function()use($id){
$before=$this->lock((int)$id);
if(!in_array($before->workflow_status,array('draft','rejected'),true))throw new BusinessException('Hanya Draft atau pengajuan ditolak yang dapat dibatalkan.');
$update=array('workflow_status'=>'cancelled','cancelled_by'=>$this->uid(),'cancelled_at'=>date('Y-m-d H:i:s'),'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$before->version+1);
$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('k_leave_requests',$update);
$this->auditservice->record('hr','leave_request',$before->id,'cancel',$before,$update,$this->uid());
return $before->id;
},'Draft pengajuan berhasil dibatalkan.');
}
private function action($callback,$message)
{
if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);
try{$result=$this->transactionservice->run($callback);return json_response(true,$message,array('result'=>$result));}catch(Throwable$e){return business_exception_response($e);}
}
private function lock($id)
{
$row=$this->db->query('SELECT lr.*,e.full_name FROM k_leave_requests lr LEFT JOIN k_employees e ON e.id=lr.employee_id WHERE lr.id=? AND lr.company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();
if(!$row)throw new BusinessException('Pengajuan cuti tidak ditemukan.');
return $row;
}
private function find($id)
{
return $this->db->select('lr.*,e.full_name,e.employee_code,e.fingerprint_user_id,d.department_name,p.position_name,ar.workflow_id,ar.request_no approval_no,ar.current_step,ar.status approval_request_status,creator.nama creator_name,submitter.nama submitter_name,approver.nama approver_name,rejector.nama rejector_name')
->from('k_leave_requests lr')->join('k_employees e','e.id=lr.employee_id','left')->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')
->join('approval_requests ar','ar.id=lr.approval_request_id','left')->join('users creator','creator.id=lr.created_by','left')->join('users submitter','submitter.id=lr.submitted_by','left')->join('users approver','approver.id=lr.approved_by','left')->join('users rejector','rejector.id=lr.rejected_by','left')
->where(array('lr.id'=>(int)$id,'lr.company_id'=>$this->company()))->get()->row();
}
private function validDate($value,$label)
{
$value=trim((string)$value);$date=DateTime::createFromFormat('Y-m-d',$value);
if(!$date||$date->format('Y-m-d')!==$value)throw new BusinessException($label.' tidak valid.');
return $value;
}
private function workflowCounts()
{
$counts=array('all'=>0,'draft'=>0,'submitted'=>0,'approved'=>0,'rejected'=>0,'cancelled'=>0);
foreach($this->db->select('workflow_status,COUNT(*) total')->where('company_id',$this->company())->group_by('workflow_status')->get('k_leave_requests')->result() as $row){$counts[$row->workflow_status]=(int)$row->total;$counts['all']+=(int)$row->total;}
return $counts;
}
}
+9 -600
View File
@@ -1,606 +1,15 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Payroll extends MY_Controller {
public function __construct()
/**
* Compatibility bridge untuk bookmark/URL Payroll lama.
* Seluruh proses payroll sekarang hanya dijalankan oleh Hrpayroll + PayrollService.
*/
class Payroll extends MY_Controller
{
public function _remap($method, $parameters = array())
{
parent::__construct();
$this->requireAdmin();
$this->load->database();
date_default_timezone_set('Asia/Jakarta');
}
// =====================================================
// INDEX
// =====================================================
public function index()
{
$data = [
'active_menu' => 'payroll'
];
$this->load->view('partials/header',$data);
$this->load->view('employees/payroll');
$this->load->view('partials/footer');
}
// =====================================================
// GET DATA
// =====================================================
public function get_data()
{
$month = $this->input->post('month');
$this->db->select("
p.*,
e.full_name,
pp.period_name,
pp.start_date,
pp.end_date
");
$this->db->from('k_payrolls p');
$this->db->join(
'k_employees e',
'e.id = p.employee_id'
);
$this->db->join(
'k_payroll_periods pp',
'pp.id = p.payroll_period_id'
);
if($month){
$this->db->where(
'DATE_FORMAT(pp.start_date,"%Y-%m")',
$month
);
}
$this->db->order_by(
'e.full_name',
'ASC'
);
$rows = $this->db->get()->result();
$data = [];
$no = 1;
foreach($rows as $row){
$pendapatan =
(
$row->allowance_amount
+
$row->bonus_amount
+
$row->overtime_amount
);
$potongan =
(
$row->deduction_amount
+
$row->bpjs_kesehatan_amount
+
$row->bpjs_ketenagakerjaan_amount
+
$row->tax_amount
+
$row->attendance_cut_amount
);
$status = '';
if($row->payment_status == 'paid'){
$status = '
<span class="badge bg-success">
Paid
</span>
';
} else {
$status = '
<span class="badge bg-warning text-dark">
Draft
</span>
';
}
$data[] = [
$no++,
$row->full_name,
'Rp '.number_format(
$row->basic_salary,
0,
',',
'.'
),
'Rp '.number_format(
$pendapatan,
0,
',',
'.'
),
'Rp '.number_format(
$potongan,
0,
',',
'.'
),
'Rp '.number_format(
$row->total_salary,
0,
',',
'.'
),
$status,
'
<button
class="btn btn-sm btn-warning btn-edit"
data-id="'.$row->id.'">
<i class="fa fa-edit"></i>
Edit
</button>
'
];
}
echo json_encode([
'data' => $data
]);
}
// =====================================================
// DETAIL
// =====================================================
public function detail($id)
{
$this->db->select("
p.*,
e.full_name
");
$this->db->from('k_payrolls p');
$this->db->join(
'k_employees e',
'e.id = p.employee_id'
);
$this->db->where(
'p.id',
$id
);
$payroll = $this->db->get()->row();
if(!$payroll){
echo json_encode([
'status' => false,
'message' => 'Payroll tidak ditemukan'
]);
return;
}
$periodControl=$this->db->get_where('k_payroll_periods',array('id'=>$payroll->payroll_period_id))->row();
if($periodControl&&in_array($periodControl->workflow_status,array('final','posted','paid'),true)){echo json_encode(array('status'=>false,'message'=>'Payroll final terkunci. Gunakan pembatalan resmi.'));return;}
$items = $this->db
->where('payroll_id',$id)
->order_by('id','ASC')
->get('k_payroll_items')
->result();
echo json_encode([
'status' => true,
'payroll' => $payroll,
'employee' => [
'full_name' => $payroll->full_name
],
'items' => $items
]);
}
// =====================================================
// SAVE ITEMS
// =====================================================
public function save_items()
{
$payrollId = $this->input->post('payroll_id');
$items = $this->input->post('items');
$payroll = $this->db
->where('id',$payrollId)
->get('k_payrolls')
->row();
if(!$payroll){
echo json_encode([
'status' => false,
'message' => 'Payroll tidak ditemukan'
]);
return;
}
$periodControl=$this->db->get_where('k_payroll_periods',array('id'=>$payroll->payroll_period_id))->row();
if($periodControl&&in_array($periodControl->workflow_status,array('final','posted','paid'),true)){echo json_encode(array('status'=>false,'message'=>'Payroll final terkunci. Gunakan pembatalan resmi.'));return;}
// =========================================
// DELETE OLD ITEMS
// =========================================
$this->db
->where('payroll_id',$payrollId)
->delete('k_payroll_items');
$allowance = 0;
$bonus = 0;
$overtime = 0;
$deduction = 0;
$bpjs = 0;
$tax = 0;
if($items){
foreach($items as $item){
$amount = (float)$item['amount'];
$insert = [
'payroll_id' => $payrollId,
'item_type' => $item['item_type'],
'item_name' => $item['item_name'],
'amount' => $amount
];
$this->db->insert(
'k_payroll_items',
$insert
);
switch($item['item_type']){
case 'allowance':
$allowance += $amount;
break;
case 'bonus':
$bonus += $amount;
break;
case 'overtime':
$overtime += $amount;
break;
case 'deduction':
$deduction += $amount;
break;
case 'bpjs':
$bpjs += $amount;
break;
case 'tax':
$tax += $amount;
break;
}
}
}
// =========================================
// TOTAL
// =========================================
$totalSalary =
(
$payroll->basic_salary
+
$allowance
+
$bonus
+
$overtime
)
-
(
$deduction
+
$bpjs
+
$tax
+
$payroll->attendance_cut_amount
);
// =========================================
// UPDATE PAYROLL
// =========================================
$this->db
->where('id',$payrollId)
->update(
'k_payrolls',
[
'allowance_amount' => $allowance,
'bonus_amount' => $bonus,
'overtime_amount' => $overtime,
'deduction_amount' => $deduction,
'bpjs_kesehatan_amount' => $bpjs,
'tax_amount' => $tax,
'total_salary' => $totalSalary
]
);
echo json_encode([
'status' => true,
'message' => 'Payroll berhasil disimpan'
]);
}
// =====================================================
// GENERATE PAYROLL
// =====================================================
public function generate_payroll()
{
$month = $this->input->post('month');
if(!$month){
echo json_encode([
'status' => false,
'message' => 'Bulan wajib dipilih'
]);
return;
}
$startDate = date(
'Y-m-01',
strtotime($month)
);
$endDate = date(
'Y-m-t',
strtotime($month)
);
// =========================================
// PERIOD
// =========================================
$period = $this->db
->where('start_date',$startDate)
->where('end_date',$endDate)
->get('k_payroll_periods')
->row();
if(!$period){
$this->db->insert(
'k_payroll_periods',
[
'period_name' => date(
'F Y',
strtotime($startDate)
),
'start_date' => $startDate,
'end_date' => $endDate,
'status' => 'draft',
'generated_at' => date('Y-m-d H:i:s')
]
);
$periodId = $this->db->insert_id();
} else {
$periodId = $period->id;
}
// =========================================
// EMPLOYEES
// =========================================
$employees = $this->db
->where('is_active',1)
->get('k_employees')
->result();
foreach($employees as $employee){
// =====================================
// ATTENDANCE
// =====================================
$attendance = $this->db
->select("
COUNT(*) as total_work_days,
SUM(attendance_status='present')
as total_present,
SUM(attendance_status='alpha')
as total_alpha,
SUM(overtime_hours)
as total_overtime_hours
")
->where('employee_id',$employee->id)
->where('attendance_date >=',$startDate)
->where('attendance_date <=',$endDate)
->get('k_attendances')
->row();
$dailySalary =
$employee->basic_salary / 30;
$alphaCut =
(
$attendance->total_alpha
* $dailySalary
);
$overtimeRate = 25000;
$overtimeAmount =
(
$attendance->total_overtime_hours
* $overtimeRate
);
$totalSalary =
(
$employee->basic_salary
+ $overtimeAmount
)
- $alphaCut;
$exist = $this->db
->where(
'payroll_period_id',
$periodId
)
->where(
'employee_id',
$employee->id
)
->get('k_payrolls')
->row();
$data = [
'payroll_period_id' => $periodId,
'employee_id' => $employee->id,
'basic_salary' => $employee->basic_salary,
'total_work_days' => (
$attendance->total_work_days ?? 0
),
'total_present' => (
$attendance->total_present ?? 0
),
'total_alpha' => (
$attendance->total_alpha ?? 0
),
'total_overtime_hours' => (
$attendance->total_overtime_hours ?? 0
),
'attendance_cut_amount' => $alphaCut,
'overtime_amount' => $overtimeAmount,
'total_salary' => $totalSalary
];
if($exist){
$this->db
->where('id',$exist->id)
->update(
'k_payrolls',
$data
);
} else {
$this->db->insert(
'k_payrolls',
$data
);
$payrollId = $this->db->insert_id();
// =================================
// AUTO ITEM OVERTIME
// =================================
if($overtimeAmount > 0){
$this->db->insert(
'k_payroll_items',
[
'payroll_id' => $payrollId,
'item_type' => 'overtime',
'item_name' => 'Lembur',
'amount' => $overtimeAmount
]
);
}
// =================================
// AUTO ITEM ALPHA
// =================================
if($alphaCut > 0){
$this->db->insert(
'k_payroll_items',
[
'payroll_id' => $payrollId,
'item_type' => 'deduction',
'item_name' => 'Potongan Alpha',
'amount' => $alphaCut
]
);
}
}
}
echo json_encode([
'status' => true,
'message' => 'Payroll berhasil digenerate'
]);
$this->session->set_flashdata('success', 'Payroll lama telah disatukan ke Payroll profesional.');
redirect('hrpayroll');
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/** Pemeriksaan CLI terarah. Seluruh fixture selalu di-rollback. */
class Payrollcheck extends CI_Controller
{
public function exports()
{
if(!$this->input->is_cli_request())show_404();
$this->load->database();if(is_file(FCPATH.'vendor/autoload.php'))require_once FCPATH.'vendor/autoload.php';
$period=$this->db->order_by('id')->get('k_payroll_periods')->row();if(!$period){echo"SKIP: periode payroll belum tersedia.\n";return;}
$company=$this->db->get_where('companies',array('id'=>$period->company_id))->row();$rows=$this->db->select('x.*,e.employee_code,e.full_name,e.bank_name,e.bank_account_number')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->where('x.payroll_period_id',$period->id)->limit(3)->get()->result();
$html=$this->load->view('hr_payroll/register_pdf',array('period'=>$period,'rows'=>$rows,'company'=>$company),true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','landscape');$pdf->render();if(strlen($pdf->output())<500)throw new RuntimeException('Render PDF payroll gagal.');
$book=new PhpOffice\PhpSpreadsheet\Spreadsheet();$book->getActiveSheet()->fromArray(array(array('Kode','Nama','Neto'),array($rows?$rows[0]->employee_code:'-',$rows?$rows[0]->full_name:'-', $rows?(float)$rows[0]->total_salary:0)),null,'A1');$file=tempnam(sys_get_temp_dir(),'payroll-check-').'.xlsx';(new PhpOffice\PhpSpreadsheet\Writer\Xlsx($book))->save($file);if(!is_file($file)||filesize($file)<1000)throw new RuntimeException('Render XLSX payroll gagal.');unlink($file);echo"PASS: satu PDF register dan satu XLSX payroll berhasil dirender; file uji dihapus.\n";
}
public function index()
{
if(!$this->input->is_cli_request())show_404();
$this->load->database();$this->load->library('PayrollService');
$company=$this->db->order_by('id')->get('companies')->row();
if(!$company){echo"SKIP: perusahaan tidak tersedia.\n";return;}
$employee=$this->db->where(array('company_id'=>$company->id,'is_active'=>1,'salary_restricted'=>0))->order_by('id')->get('k_employees')->row();
$user=$this->db->order_by('id')->get('users')->row();
if(!$employee||!$user){echo"SKIP: karyawan biasa atau user tidak tersedia.\n";return;}
$access=$this->db->get_where('user_companies',array('company_id'=>$company->id))->row();if(!$access){echo"SKIP: akses user perusahaan tidak tersedia.\n";return;}$user=$this->db->get_where('users',array('id'=>$access->user_id))->row();$this->session->set_userdata(array('company_id'=>(int)$company->id,'user_id'=>(int)$user->id));
$this->db->trans_begin(true);
try{
$start='2098-08-14';$end='2098-09-13';$now=date('Y-m-d H:i:s');
$this->db->insert('k_payroll_periods',array('company_id'=>$company->id,'period_name'=>'[TEST ROLLBACK] Flexible payroll','start_date'=>$start,'end_date'=>$end,'attendance_start_date'=>$start,'attendance_end_date'=>$end,'payment_date'=>'2098-09-25','status'=>'draft','workflow_status'=>'draft','generated_at'=>$now));$periodId=(int)$this->db->insert_id();
$componentIds=array();foreach(array(array('SMOKE_EARN','[TEST] Pendapatan','earning'),array('SMOKE_DED','[TEST] Potongan Tetap','deduction'),array('SMOKE_ALPHA','[TEST] Potongan Alpha','deduction'),array('SMOKE_BONUS','[TEST] Bonus Periode','earning'))as$component){$this->db->insert('payroll_components',array('company_id'=>$company->id,'code'=>$component[0].'-'.bin2hex(random_bytes(2)),'name'=>$component[1],'component_type'=>$component[2],'category'=>'other','calculation_method'=>'fixed','default_value'=>0,'fixed_amount'=>0,'taxable'=>0,'prorated'=>0,'is_active'=>1,'effective_from'=>$start,'created_at'=>$now));$componentIds[]=(int)$this->db->insert_id();}
foreach(array(array($componentIds[0],100),array($componentIds[1],10))as$assignment)$this->db->insert('employee_payroll_components',array('company_id'=>$company->id,'employee_id'=>$employee->id,'component_id'=>$assignment[0],'calculation_method'=>'fixed','amount_override'=>$assignment[1],'effective_from'=>$start,'is_active'=>1,'notes'=>'[TEST ROLLBACK]','created_at'=>$now));
$this->db->insert('payroll_attendance_rules',array('company_id'=>$company->id,'component_id'=>$componentIds[2],'rule_name'=>'[TEST] Alpha per hari','condition_type'=>'alpha','calculation_method'=>'per_day','rate_value'=>25,'effective_from'=>$start,'is_active'=>1,'created_at'=>$now));
$this->db->insert('payroll_period_adjustments',array('company_id'=>$company->id,'payroll_period_id'=>$periodId,'employee_id'=>$employee->id,'component_id'=>$componentIds[3],'adjustment_type'=>'earning','amount'=>50,'notes'=>'[TEST ROLLBACK] Bonus','is_active'=>1,'created_by'=>$user->id,'created_at'=>$now));
$this->db->insert('k_attendances',array('employee_id'=>$employee->id,'attendance_date'=>'2098-09-01','attendance_status'=>'alpha','late_minutes'=>0,'early_leave_minutes'=>0));
$this->db->insert('k_attendances',array('employee_id'=>$employee->id,'attendance_date'=>'2098-09-02','attendance_status'=>'late','late_minutes'=>30,'early_leave_minutes'=>0));
$this->db->insert('payroll_attendance_locks',array('company_id'=>$company->id,'payroll_period_id'=>$periodId,'start_date'=>$start,'end_date'=>$end,'status'=>'locked','locked_by'=>$user->id,'locked_at'=>$now));
$this->payrollservice->calculate($periodId,$user->id,true,$company->id);$first=$this->db->select('COUNT(*) count')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->where('p.payroll_period_id',$periodId)->get()->row();
$this->payrollservice->calculate($periodId,$user->id,true,$company->id);$second=$this->db->select('COUNT(*) count')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->where('p.payroll_period_id',$periodId)->get()->row();
$codes=$this->db->select('c.name,l.amount')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->join('payroll_components c','c.id=l.component_id')->where('p.payroll_period_id',$periodId)->where_in('c.id',$componentIds)->get()->result();$found=array();foreach($codes as$row)$found[$row->name]=(float)$row->amount;
if((int)$first->count!==(int)$second->count)throw new RuntimeException('Recalculate menggandakan atau menghilangkan baris.');
if(($found['[TEST] Pendapatan']??0)!==100.0||($found['[TEST] Potongan Tetap']??0)!==10.0||($found['[TEST] Potongan Alpha']??0)!==25.0||($found['[TEST] Bonus Periode']??0)!==50.0)throw new RuntimeException('Pendapatan, potongan, aturan alpha, atau bonus tidak sesuai.');
$snapshot=$this->db->select('calculation_snapshot')->get_where('k_payrolls',array('payroll_period_id'=>$periodId,'employee_id'=>$employee->id))->row();if(!$snapshot||strpos($snapshot->calculation_snapshot,$start)===false||strpos($snapshot->calculation_snapshot,$end)===false)throw new RuntimeException('Snapshot rentang fleksibel tidak tersimpan.');
$late=$this->db->where(array('company_id'=>$company->id,'condition_type'=>'late','calculation_method'=>'none'))->count_all_results('payroll_attendance_rules');if(!$late)throw new RuntimeException('Default terlambat tanpa potongan tidak tersedia.');
$beforeJournal=(int)$this->db->count_all('journals');$this->db->where('id',$periodId)->update('k_payroll_periods',array('workflow_status'=>'final'));if((int)$this->db->count_all('journals')!==$beforeJournal)throw new RuntimeException('Finalisasi membuat jurnal sebelum Posting.');$journalId=$this->payrollservice->post($periodId,'2026-09-13',$user->id,$company->id,true);$balance=$this->db->select('SUM(debit-kredit) difference',false)->get_where('journal_details',array('journal_id'=>$journalId))->row();if(!$balance||abs((float)$balance->difference)>.01)throw new RuntimeException('Jurnal payroll tidak balance.');$doubleBlocked=false;try{$this->payrollservice->post($periodId,'2026-09-13',$user->id,$company->id,true);}catch(Throwable$expected){$doubleBlocked=true;}if(!$doubleBlocked)throw new RuntimeException('Double posting tidak diblokir.');
echo"PASS: periode 14 Agustus–13 September, assignment, alpha, bonus, snapshot, recalculate, jurnal balance, dan idempotency posting.\n";
}catch(Throwable$e){$this->db->trans_rollback();fwrite(STDERR,'FAIL: '.$e->getMessage().PHP_EOL);exit(1);}
$this->db->trans_rollback();echo"ROLLBACK: seluruh fixture payroll dibatalkan.\n";
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Profile extends MY_Controller
{
public function __construct()
{
parent::__construct();
}
public function index($tab='settings')
{
$tab=$this->input->get('tab',true)?:$tab;if(!in_array($tab,array('settings','devices'),true))$tab='settings';
$user=$this->db->get_where('users',array('id'=>$this->uid()))->row();if(!$user)show_404();
$data=array('active_menu'=>'profile','profile_tab'=>$tab,'user'=>$user,'sessions'=>$this->usersessionservice->activeSessions($user->id),'attempts'=>$this->usersessionservice->attempts($user->id),'active_count'=>$this->usersessionservice->activeCount($user->id));
$this->load->view('partials/header',$data);$this->load->view('profile/index',$data);$this->load->view('partials/footer');
}
public function update_profile()
{
$this->postOnly();$user=$this->user();$newPhoto=null;
try{
$name=trim((string)$this->input->post('nama',true));$email=strtolower(trim((string)$this->input->post('email',true)));
if($name===''||mb_strlen($name)>255)throw new RuntimeException('Nama wajib diisi dan maksimal 255 karakter.');
if($email!==''&&!filter_var($email,FILTER_VALIDATE_EMAIL))throw new RuntimeException('Format email tidak valid.');
if($email!==''&&$this->db->where('email',$email)->where('id !=',$user->id)->get('users')->row())throw new RuntimeException('Email sudah digunakan pengguna lain.');
$newPhoto=$this->uploadPhoto();$update=array('nama'=>$name,'email'=>$email?:null,'profile_updated_at'=>date('Y-m-d H:i:s'));if($newPhoto)$update['profile_photo']=$newPhoto;
$this->db->where('id',$user->id)->update('users',$update);if(!$this->db->trans_status())throw new RuntimeException('Profil gagal disimpan.');
if($newPhoto&&!empty($user->profile_photo))$this->removeOldPhoto($user->profile_photo);
$this->session->set_userdata(array('nama'=>$name,'email'=>$email?:null,'profile_photo'=>$newPhoto?:$user->profile_photo));
log_activity('Profile','update','Memperbarui profil pengguna sendiri','success');$this->session->set_flashdata('success','Profil berhasil diperbarui.');
}catch(Throwable$e){if($newPhoto&&is_file(FCPATH.$newPhoto))@unlink(FCPATH.$newPhoto);$this->session->set_flashdata('error',$e->getMessage());}
redirect('profile');
}
public function update_security()
{
$this->postOnly();$hours=(int)$this->input->post('session_duration_hours');$limit=(int)$this->input->post('max_active_devices');
try{
if(!in_array($hours,array(24,72,168),true))throw new RuntimeException('Durasi session tidak valid.');
if($limit<1||$limit>10)throw new RuntimeException('Batas perangkat harus antara 1 sampai 10.');
$this->db->where('id',$this->uid())->update('users',array('session_duration_hours'=>$hours,'max_active_devices'=>$limit,'profile_updated_at'=>date('Y-m-d H:i:s')));
log_activity('Profile','security_setting','Mengubah durasi session menjadi '.$hours.' jam dan batas perangkat menjadi '.$limit,'success');
$this->session->set_flashdata('success','Pengaturan login berhasil disimpan dan berlaku mulai login berikutnya. Session yang sedang aktif tetap memakai batas waktu awalnya.');
}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}
redirect('profile');
}
public function update_password()
{
$this->postOnly();$user=$this->user();
try{
$current=(string)$this->input->post('current_password');$new=(string)$this->input->post('new_password');$confirm=(string)$this->input->post('confirm_password');
if(!password_verify($current,$user->password))throw new RuntimeException('Password saat ini tidak sesuai.');
if(strlen($new)<8||!preg_match('/[A-Za-z]/',$new)||!preg_match('/\d/',$new))throw new RuntimeException('Password baru minimal 8 karakter dan harus mengandung huruf serta angka.');
if($new!==$confirm)throw new RuntimeException('Konfirmasi password baru tidak sama.');
if(password_verify($new,$user->password))throw new RuntimeException('Password baru harus berbeda dari password saat ini.');
$this->db->where('id',$user->id)->update('users',array('password'=>password_hash($new,PASSWORD_DEFAULT),'profile_updated_at'=>date('Y-m-d H:i:s')));
$this->usersessionservice->revokeOthers($user->id);log_activity('Profile','password','Mengubah password dan mengakhiri session perangkat lain','success');
$this->session->set_flashdata('success','Password berhasil diubah. Session pada perangkat lain telah diakhiri.');
}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}
redirect('profile');
}
public function revoke_session($id)
{
$this->postOnly();
try{
$result=$this->usersessionservice->revoke((int)$id,$this->uid());log_activity('Profile','revoke_session','Mengakhiri session perangkat ID '.(int)$id,'success');
if($result['current']){$this->session->sess_destroy();redirect('auth?notice=revoked');return;}
$this->session->set_flashdata('success','Session perangkat berhasil diakhiri.');
}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}
redirect('profile?tab=devices');
}
private function uploadPhoto()
{
if(empty($_FILES['profile_photo'])||$_FILES['profile_photo']['error']===UPLOAD_ERR_NO_FILE)return null;
$file=$_FILES['profile_photo'];if($file['error']!==UPLOAD_ERR_OK)throw new RuntimeException('Foto profil gagal diunggah.');if((int)$file['size']>2*1024*1024)throw new RuntimeException('Ukuran foto profil maksimal 2 MB.');
$mime=(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);$allowed=array('image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp');if(!isset($allowed[$mime]))throw new RuntimeException('Foto profil hanya menerima JPG, PNG, atau WEBP.');
$dir=FCPATH.'uploads/accounting/profiles/';if(!is_dir($dir)&&!mkdir($dir,0755,true))throw new RuntimeException('Folder foto profil tidak dapat dibuat.');
$relative='uploads/accounting/profiles/'.bin2hex(random_bytes(20)).'.'.$allowed[$mime];if(!move_uploaded_file($file['tmp_name'],FCPATH.$relative))throw new RuntimeException('Foto profil tidak dapat disimpan.');return$relative;
}
private function removeOldPhoto($path){$prefix='uploads/accounting/profiles/';if(strpos((string)$path,$prefix)===0&&is_file(FCPATH.$path))@unlink(FCPATH.$path);}
private function user(){$row=$this->db->get_where('users',array('id'=>$this->uid()))->row();if(!$row)throw new RuntimeException('Pengguna tidak ditemukan.');return$row;}
private function uid(){return(int)$this->session->userdata('user_id');}
private function postOnly(){if(strtoupper($this->input->method())!=='POST')show_404();}
}
+1 -1
View File
@@ -29,7 +29,7 @@ class Roles extends MY_Admin_Controller
if($existing&&!empty($existing->is_super_admin))return json_response(false,'Permission Master Admin bersifat penuh dan tidak dapat dibatasi.',array(),422);
$duplicate=$this->db->where('nama_role',$name)->where('id !=',$id)->count_all_results('roles');if($duplicate)return json_response(false,'Nama role sudah digunakan.',array(),422);
$posted=(array)$this->input->post('permissions');$permissions=array();
foreach($this->features as$key=>$label){$selected=array_values(array_intersect((array)(isset($posted[$key])?$posted[$key]:array()),array_keys($this->actions)));if($selected){if(!in_array('can_view',$selected,true))array_unshift($selected,'can_view');$permissions[$key]=$selected;}}
foreach($this->features as$key=>$label){$allowedActions=array_keys($this->actions);if($key==='payroll')$allowedActions[]='can_manage_all_salary';$selected=array_values(array_intersect((array)(isset($posted[$key])?$posted[$key]:array()),$allowedActions));if($selected){if(!in_array('can_view',$selected,true))array_unshift($selected,'can_view');$permissions[$key]=$selected;}}
if(!$permissions)return json_response(false,'Role wajib mempunyai minimal satu permission.',array(),422);
$data=array('nama_role'=>$name,'description'=>$description,'permissions'=>json_encode($permissions),'is_active'=>$this->input->post('is_active')?1:0,'updated_at'=>date('Y-m-d H:i:s'));
if($id)$this->db->where('id',$id)->update('roles',$data);else{$data['is_system']=0;$data['is_super_admin']=0;$data['created_at']=date('Y-m-d H:i:s');$this->db->insert('roles',$data);$id=$this->db->insert_id();}