diff --git a/.env.example b/.env.example index d6a700f..1532552 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ CI_ENV=development APP_BASE_URL=http://localhost/accounting_dev/ APP_ENCRYPTION_KEY=generate-a-random-secret-at-least-32-characters COOKIE_SECURE=false +# Batas teknis cookie/session storage. Batas login tiap user tetap divalidasi server. +SESSION_EXPIRATION=604800 WORKFLOW_REQUIRE_SEPARATE_APPROVER=false DB_HOST=172.0.0.1 diff --git a/.phpunit.result.cache b/.phpunit.result.cache index dcb9717..630a54f 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":1,"defects":{"ApplicationContractTest::testInternalControllersUseAuthenticatedBaseController":3,"ApplicationContractTest::testResponsiveShellHasDesktopAndMobileRules":3,"ApplicationContractTest::testUiPreventsRepeatedSubmitAndHandlesAjaxFailure":4,"AccountingDatabaseTest::testAccountingInvariantsAgainstDevelopmentDatabase":3,"ApplicationContractTest::testPurchaseReturnsAreSeparatedAndIntegrated":3,"AccountingDatabaseTest::testPurchaseAdjustmentQueriesAndReportsRunAgainstActualSchema":3,"ApplicationContractTest::testMasterAdminHasFullAccessAndApprovalOverride":3,"ApplicationContractTest::testFixedAssetsUseSingleProfessionalWorkflow":3},"times":{"ApplicationContractTest::testInternalControllersUseAuthenticatedBaseController":0.087,"ApplicationContractTest::testAjaxSessionExpiryReturnsJson401":0.056,"ApplicationContractTest::testResponsiveShellHasDesktopAndMobileRules":0.11,"ApplicationContractTest::testUiPreventsRepeatedSubmitAndHandlesAjaxFailure":0.001,"AccountingDatabaseTest::testAccountingInvariantsAgainstDevelopmentDatabase":0.9,"FormulaServiceTest::testPayrollFormulaHonorsPrecedenceAndVariables":0.013,"FormulaServiceTest::testProrataFormulaAndRounding":0,"FormulaServiceTest::testUnknownVariableIsZeroForBackwardCompatibleFormula":0,"FormulaServiceTest::testRejectsDivisionByZero":0.003,"FormulaServiceTest::testRejectsUnsafeTokens":0,"ApplicationContractTest::testMoneyInputsUseCentralParserBeforeAjax":0.157,"ApplicationContractTest::testMasterAdminHasFullAccessAndApprovalOverride":0.001,"ApplicationContractTest::testBalanceSheetAndProfitLossRemainInNavigation":0,"ApplicationContractTest::testProfessionalReportsHaveIndonesianFinancialStatements":0.001,"ApplicationContractTest::testPurchasePaymentsSeparatePrincipalAndBankCharge":0.002,"ApplicationContractTest::testPurchaseReturnsAreSeparatedAndIntegrated":0.003,"AccountingDatabaseTest::testPurchaseAdjustmentInvariantsAgainstDevelopmentDatabase":0.47,"ApplicationContractTest::testPurchaseAdjustmentsSupportReversalAuditAndProfessionalReports":0.002,"AccountingDatabaseTest::testPurchaseAdjustmentQueriesAndReportsRunAgainstActualSchema":0.615,"ApplicationContractTest::testProfessionalInventoryUsesOneLedgerAndSafeWorkflow":0.003,"AccountingDatabaseTest::testProfessionalInventoryRuntimeAgainstDevelopmentDatabase":0.603,"ApplicationContractTest::testInventoryFinalizationCoversRequiredOperationalScenarios":0.003,"ApplicationContractTest::testInventoryWriteEndpointsAndButtonsArePermissionAware":0.001,"AccountingDatabaseTest::testInventoryIntegrityAndNegativeStockGuardAgainstDevelopmentDatabase":0.611,"AccountingDatabaseTest::testInventoryReconciliationAlwaysProducesAuditableNumbers":0.224,"ApplicationContractTest::testFixedAssetsUseSingleProfessionalWorkflow":0.065,"AccountingDatabaseTest::testFixedAssetIntegrityAndRuntimeQueries":0.473}} \ No newline at end of file +{"version":1,"defects":{"ApplicationContractTest::testInternalControllersUseAuthenticatedBaseController":3,"ApplicationContractTest::testResponsiveShellHasDesktopAndMobileRules":3,"ApplicationContractTest::testUiPreventsRepeatedSubmitAndHandlesAjaxFailure":4,"AccountingDatabaseTest::testAccountingInvariantsAgainstDevelopmentDatabase":3,"ApplicationContractTest::testPurchaseReturnsAreSeparatedAndIntegrated":3,"AccountingDatabaseTest::testPurchaseAdjustmentQueriesAndReportsRunAgainstActualSchema":3,"ApplicationContractTest::testMasterAdminHasFullAccessAndApprovalOverride":3,"ApplicationContractTest::testFixedAssetsUseSingleProfessionalWorkflow":3},"times":{"ApplicationContractTest::testInternalControllersUseAuthenticatedBaseController":0.087,"ApplicationContractTest::testAjaxSessionExpiryReturnsJson401":0.056,"ApplicationContractTest::testResponsiveShellHasDesktopAndMobileRules":0.11,"ApplicationContractTest::testUiPreventsRepeatedSubmitAndHandlesAjaxFailure":0.001,"AccountingDatabaseTest::testAccountingInvariantsAgainstDevelopmentDatabase":0.9,"FormulaServiceTest::testPayrollFormulaHonorsPrecedenceAndVariables":0.008,"FormulaServiceTest::testProrataFormulaAndRounding":0,"FormulaServiceTest::testUnknownVariableIsZeroForBackwardCompatibleFormula":0,"FormulaServiceTest::testRejectsDivisionByZero":0.002,"FormulaServiceTest::testRejectsUnsafeTokens":0,"ApplicationContractTest::testMoneyInputsUseCentralParserBeforeAjax":0.157,"ApplicationContractTest::testMasterAdminHasFullAccessAndApprovalOverride":0.001,"ApplicationContractTest::testBalanceSheetAndProfitLossRemainInNavigation":0,"ApplicationContractTest::testProfessionalReportsHaveIndonesianFinancialStatements":0.001,"ApplicationContractTest::testPurchasePaymentsSeparatePrincipalAndBankCharge":0.002,"ApplicationContractTest::testPurchaseReturnsAreSeparatedAndIntegrated":0.003,"AccountingDatabaseTest::testPurchaseAdjustmentInvariantsAgainstDevelopmentDatabase":0.47,"ApplicationContractTest::testPurchaseAdjustmentsSupportReversalAuditAndProfessionalReports":0.002,"AccountingDatabaseTest::testPurchaseAdjustmentQueriesAndReportsRunAgainstActualSchema":0.615,"ApplicationContractTest::testProfessionalInventoryUsesOneLedgerAndSafeWorkflow":0.003,"AccountingDatabaseTest::testProfessionalInventoryRuntimeAgainstDevelopmentDatabase":0.603,"ApplicationContractTest::testInventoryFinalizationCoversRequiredOperationalScenarios":0.003,"ApplicationContractTest::testInventoryWriteEndpointsAndButtonsArePermissionAware":0.001,"AccountingDatabaseTest::testInventoryIntegrityAndNegativeStockGuardAgainstDevelopmentDatabase":0.611,"AccountingDatabaseTest::testInventoryReconciliationAlwaysProducesAuditableNumbers":0.224,"ApplicationContractTest::testFixedAssetsUseSingleProfessionalWorkflow":0.065,"AccountingDatabaseTest::testFixedAssetIntegrityAndRuntimeQueries":0.473}} \ No newline at end of file diff --git a/application/config/accounting.php b/application/config/accounting.php index a64a43b..ac3ec43 100644 --- a/application/config/accounting.php +++ b/application/config/accounting.php @@ -69,6 +69,7 @@ $config['document_numbering'] = array( ,'payroll' => array('prefix' => 'PRL', 'padding' => 6) ,'payroll_payment' => array('prefix' => 'PRP', 'padding' => 6) ,'technician_equipment' => array('prefix' => 'TEQ', 'padding' => 6) + ,'leave_request' => array('prefix' => 'LV', 'padding' => 6) ); $config['require_separate_journal_approver'] = filter_var( diff --git a/application/config/config.php b/application/config/config.php index fee412e..b7419fe 100644 --- a/application/config/config.php +++ b/application/config/config.php @@ -391,7 +391,9 @@ if (ENVIRONMENT === 'production' && $config['encryption_key'] === 'development-o $config['sess_driver'] = app_env('SESSION_DRIVER', 'database') ?: 'database'; $config['sess_cookie_name'] = 'accounting_session'; $config['sess_samesite'] = 'Lax'; -$config['sess_expiration'] = 7200; +// Cookie/storage memakai batas teknis terpanjang (7 hari). Hard expiry setiap +// pengguna tetap dihitung dari login pertama oleh UserSessionService. +$config['sess_expiration'] = (int) (app_env('SESSION_EXPIRATION', 604800) ?: 604800); $config['sess_save_path'] = $config['sess_driver'] === 'database' ? 'ci_sessions' : sys_get_temp_dir(); $config['sess_match_ip'] = FALSE; $config['sess_time_to_update'] = 300; diff --git a/application/config/permissions.php b/application/config/permissions.php index d58ee5e..05628f2 100644 --- a/application/config/permissions.php +++ b/application/config/permissions.php @@ -18,7 +18,12 @@ $config['permission_features'] = array( 'asset' => 'Aset Tetap', 'jurnal' => 'Jurnal & Periode Accounting', 'reports' => 'Laporan Profesional', 'budgets' => 'Budget', - 'employees' => 'HR, Absensi & Payroll' + 'hr_organization' => 'HR - Departemen & Posisi', + 'hr_employees' => 'HR - Data Karyawan', + 'hr_schedules' => 'HR - Jadwal, Shift & Hari Libur', + 'hr_attendance' => 'HR - Absensi & Pengajuan Manual', + 'hr_leave' => 'HR - Cuti & Izin', + 'payroll' => 'Payroll Control' ); $config['permission_actions'] = array( 'can_view' => 'Lihat', 'can_create' => 'Buat', 'can_update' => 'Ubah', diff --git a/application/config/routes.php b/application/config/routes.php index 8e164ca..aa0ff65 100644 --- a/application/config/routes.php +++ b/application/config/routes.php @@ -55,6 +55,14 @@ $route['translate_uri_dashes'] = FALSE; $route['login'] = 'auth/index'; $route['logout'] = 'auth/logout'; +$route['profile'] = 'profile/index/settings'; +$route['profile/update-profile'] = 'profile/update_profile'; +$route['profile/update-security'] = 'profile/update_security'; +$route['profile/update-password'] = 'profile/update_password'; +$route['profile/revoke-session/(:num)'] = 'profile/revoke_session/$1'; + +// Payroll profesional memakai satu controller utama; URL lama tetap membuka halaman yang sama. +$route['payroll'] = 'hrpayroll/index'; // untuk webhook absensi $route['iclock/cdata'] = 'AttendanceWebhook/cdata'; diff --git a/application/controllers/Attendancemonitoring.php b/application/controllers/Attendancemonitoring.php index 526a40f..7f4e1f1 100644 --- a/application/controllers/Attendancemonitoring.php +++ b/application/controllers/Attendancemonitoring.php @@ -1,65 +1,112 @@ 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)):'-',''.html_escape($labels[$status]??ucfirst($status)).'');} + return $this->json(true,'Jadwal kehadiran ditemukan.',array('data'=>$data)); } + + public function manual_requests() + { + $q=$this->db->select('r.*,e.employee_code,e.full_name,u.nama creator_name')->from('manual_attendance_requests r')->join('k_employees e','e.id=r.employee_id')->join('users u','u.id=r.created_by','left')->where('r.company_id',$this->companyId()); + if(trim((string)$this->input->get('status'))!=='')$q->where('r.status',trim((string)$this->input->get('status'))); + $data=array();foreach($q->order_by('r.id','DESC')->get()->result()as$r){$actions=' '; + if(in_array($r->status,array('draft','rejected'),true)&&$this->can('can_create')&&((int)$r->created_by===$this->userId()||$this->can('can_update')))$actions.=' '; + if($r->status==='submitted'&&$this->can('can_approve'))$actions.=' '; + $data[]=array('request'=>$r->request_no,'employee'=>$r->employee_code.' - '.$r->full_name,'datetime'=>date('d/m/Y',strtotime($r->attendance_date)).' '.substr($r->attendance_time,0,5),'action'=>$r->attendance_action==='check_in'?'Masuk':'Pulang','reason'=>$r->reason,'status'=>$r->status,'creator'=>$r->creator_name?:'-','actions'=>$actions);} + return$this->json(true,'Daftar pengajuan ditemukan.',array('data'=>$data)); + } + + public function manual_detail($id) + { + $row=$this->request((int)$id,false);return$row?$this->json(true,'Detail pengajuan.',array('request'=>$row)):$this->json(false,'Pengajuan tidak ditemukan.',array(),404); + } + + public function save_manual() + { + try{$employeeId=(int)$this->input->post('employee_id');$date=$this->validDate($this->input->post('attendance_date'));$time=$this->validTime($this->input->post('attendance_time'));$action=$this->input->post('attendance_action');$reason=trim((string)$this->input->post('reason',true)); + if(!$employeeId||!$date||!$time||!in_array($action,array('check_in','check_out'),true)||$reason==='')throw new BusinessException('Karyawan, tanggal, jam, jenis absen, dan alasan wajib diisi.'); + $employee=$this->db->where('id',$employeeId)->where('is_active',1);if($this->db->field_exists('company_id','k_employees'))$employee->where('company_id',$this->companyId());if(!$employee->get('k_employees')->row())throw new BusinessException('Karyawan aktif tidak ditemukan pada perusahaan ini.'); + if($this->isLocked($date))throw new BusinessException('Periode absensi sudah dikunci untuk payroll.'); + $duplicate=$this->db->where(array('company_id'=>$this->companyId(),'employee_id'=>$employeeId,'attendance_date'=>$date,'attendance_action'=>$action))->where_in('status',array('draft','submitted','approved'))->count_all_results('manual_attendance_requests');if($duplicate)throw new BusinessException('Pengajuan aktif untuk karyawan, tanggal, dan jenis absen tersebut sudah ada.'); + $last=(int)$this->db->select_max('id','last_id')->get('manual_attendance_requests')->row()->last_id;$no='MAN-'.date('Ymd',strtotime($date)).'-'.str_pad((string)($last+1),5,'0',STR_PAD_LEFT); + $this->db->insert('manual_attendance_requests',array('company_id'=>$this->companyId(),'request_no'=>$no,'employee_id'=>$employeeId,'attendance_date'=>$date,'attendance_time'=>$time,'attendance_action'=>$action,'reason'=>$reason,'status'=>'draft','created_by'=>$this->userId(),'created_at'=>date('Y-m-d H:i:s')));$id=(int)$this->db->insert_id();log_activity('manual_attendance','create','Membuat draft absensi manual '.$no,'success');return$this->json(true,'Draft absensi manual berhasil dibuat.',array('id'=>$id)); + }catch(Throwable$e){return$this->json(false,$e->getMessage(),array(),422);} + } + + public function submit_manual($id) + { + return$this->transaction(function()use($id){$r=$this->request((int)$id,true);if(!$r)throw new BusinessException('Pengajuan tidak ditemukan.');if(!in_array($r->status,array('draft','rejected'),true))throw new BusinessException('Hanya draft atau pengajuan ditolak yang dapat diajukan.');if((int)$r->created_by!==$this->userId()&&!$this->can('can_update'))throw new BusinessException('Anda tidak berhak mengajukan dokumen ini.');if($this->isLocked($r->attendance_date))throw new BusinessException('Periode absensi sudah dikunci untuk payroll.');$approval=$this->approvalservice->create('hr','manual_attendance',$r->id,0,$this->userId(),array('request_no'=>$r->request_no,'employee_id'=>$r->employee_id,'attendance_date'=>$r->attendance_date,'attendance_time'=>$r->attendance_time,'attendance_action'=>$r->attendance_action,'reason'=>$r->reason));$this->db->where('id',$r->id)->update('manual_attendance_requests',array('status'=>'submitted','approval_request_id'=>$approval->id,'submitted_by'=>$this->userId(),'submitted_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>null,'updated_at'=>date('Y-m-d H:i:s')));return$this->json(true,'Pengajuan dikirim untuk persetujuan.');}); + } + + public function approve_manual($id) + { + return$this->transaction(function()use($id){$r=$this->request((int)$id,true);if(!$r||$r->status!=='submitted')throw new BusinessException('Pengajuan tidak sedang menunggu persetujuan.');if($this->isLocked($r->attendance_date))throw new BusinessException('Periode absensi sudah dikunci untuk payroll.');$approval=$this->approvalservice->act($r->approval_request_id,'approved',$this->userId(),$this->roleId(),trim((string)$this->input->post('notes',true))?:null);if($approval->status==='approved'){$attendanceId=$this->applyAttendance($r);$this->db->where('id',$r->id)->update('manual_attendance_requests',array('status'=>'approved','approved_by'=>$this->userId(),'approved_at'=>date('Y-m-d H:i:s'),'applied_attendance_id'=>$attendanceId,'updated_at'=>date('Y-m-d H:i:s')));}return$this->json(true,'Absensi manual disetujui dan masuk ke daftar kehadiran.');}); + } + + public function reject_manual($id) + { + return$this->transaction(function()use($id){$reason=trim((string)$this->input->post('reason',true));if($reason==='')throw new BusinessException('Alasan penolakan wajib diisi.');$r=$this->request((int)$id,true);if(!$r||$r->status!=='submitted')throw new BusinessException('Pengajuan tidak sedang menunggu persetujuan.');$this->approvalservice->act($r->approval_request_id,'rejected',$this->userId(),$this->roleId(),$reason);$this->db->where('id',$r->id)->update('manual_attendance_requests',array('status'=>'rejected','rejected_by'=>$this->userId(),'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>$reason,'updated_at'=>date('Y-m-d H:i:s')));return$this->json(true,'Pengajuan absensi manual ditolak.');}); + } + + private function applyAttendance($request) + { + $schedule=$this->db->select('s.*,sh.late_tolerance_minutes')->from('k_employee_shift_schedules s')->join('k_shifts sh','sh.id=s.shift_id','left')->where('s.employee_id',$request->employee_id)->where('DATE(s.start_datetime)',$request->attendance_date)->where_in('s.status',array('published','done'))->order_by('s.id')->get()->row();if(!$schedule)throw new BusinessException('Jadwal kerja karyawan pada tanggal tersebut tidak ditemukan atau belum dipublikasikan.'); + $attendance=$this->db->query('SELECT * FROM k_attendances WHERE employee_id=? AND attendance_date=? FOR UPDATE',array($request->employee_id,$request->attendance_date))->row();$dateTime=$request->attendance_date.' '.$request->attendance_time;$note='[MANUAL '.$request->request_no.'] '.$request->reason; + if($request->attendance_action==='check_in'){ + if($attendance&&!empty($attendance->checkin_time)&&$attendance->attendance_status!=='alpha')throw new BusinessException('Jam masuk sudah tercatat. Gunakan alur koreksi jika waktunya perlu diubah.'); + $late=max(0,(int)floor((strtotime($dateTime)-(strtotime($schedule->start_datetime)+((int)$schedule->late_tolerance_minutes*60)))/60));$data=array('checkin_time'=>$dateTime,'late_minutes'=>$late,'attendance_status'=>$late>0?'late':'present','shift_id'=>$schedule->shift_id,'notes'=>trim(($attendance&&$attendance->notes?$attendance->notes."\n":'').$note)); + if($attendance){$this->db->where('id',$attendance->id)->update('k_attendances',$data);return(int)$attendance->id;}$data['employee_id']=$request->employee_id;$data['attendance_date']=$request->attendance_date;$data['created_at']=date('Y-m-d H:i:s');$this->db->insert('k_attendances',$data);return(int)$this->db->insert_id(); + } + if(!$attendance||empty($attendance->checkin_time))throw new BusinessException('Jam masuk belum tercatat. Ajukan jam masuk terlebih dahulu.');if(!empty($attendance->checkout_time))throw new BusinessException('Jam pulang sudah tercatat. Gunakan alur koreksi jika waktunya perlu diubah.');if(strtotime($dateTime)<=strtotime($attendance->checkin_time))throw new BusinessException('Jam pulang harus setelah jam masuk.'); + $work=round((strtotime($dateTime)-strtotime($attendance->checkin_time))/3600,2);$early=max(0,(int)floor((strtotime($schedule->end_datetime)-strtotime($dateTime))/60));$overtime=max(0,round((strtotime($dateTime)-strtotime($schedule->end_datetime))/3600,2));$this->db->where('id',$attendance->id)->update('k_attendances',array('checkout_time'=>$dateTime,'work_hours'=>$work,'early_leave_minutes'=>$early,'overtime_hours'=>$overtime,'notes'=>trim(($attendance->notes?$attendance->notes."\n":'').$note)));return(int)$attendance->id; + } + + private function request($id,$lock=false){return$this->db->query('SELECT r.*,e.employee_code,e.full_name FROM manual_attendance_requests r JOIN k_employees e ON e.id=r.employee_id WHERE r.id=? AND r.company_id=?'.($lock?' FOR UPDATE':''),array($id,$this->companyId()))->row();} + private function isLocked($date) + { + if ($this->db->table_exists('payroll_attendance_locks')) { + $range = $this->db->where(array('company_id'=>$this->companyId(),'status'=>'locked')) + ->where('start_date <=',$date)->where('end_date >=',$date) + ->get('payroll_attendance_locks')->row(); + if ($range) return true; + } + return (bool)$this->db->where(array('period'=>substr($date,0,7),'status'=>'locked'))->get('attendance_period_locks')->row(); + } + private function validDate($value){$d=DateTime::createFromFormat('Y-m-d',(string)$value);return$d&&$d->format('Y-m-d')===$value?$value:null;} + private function validTime($value){foreach(array('H:i','H:i:s')as$f){$d=DateTime::createFromFormat($f,(string)$value);if($d&&$d->format($f)===$value)return$d->format('H:i:s');}return null;} + private function transaction($callback){$this->db->trans_begin();try{$response=$callback();if($this->db->trans_status()===false)throw new RuntimeException('Transaksi database gagal.');$this->db->trans_commit();return$response;}catch(Throwable$e){$this->db->trans_rollback();return$this->json(false,$e->getMessage(),array(),$e instanceof BusinessException?422:500);}} + private function json($status,$message,$data=array(),$code=200){return$this->output->set_status_header($code)->set_content_type('application/json')->set_output(json_encode(array_merge(array('status'=>$status,'message'=>$message),$data)));} } diff --git a/application/controllers/Auth.php b/application/controllers/Auth.php index cb29405..675cfb0 100644 --- a/application/controllers/Auth.php +++ b/application/controllers/Auth.php @@ -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'); } } diff --git a/application/controllers/Employees.php b/application/controllers/Employees.php index 185b081..e16bfbf 100644 --- a/application/controllers/Employees.php +++ b/application/controllers/Employees.php @@ -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; } // ========================================================= diff --git a/application/controllers/Hrpayroll.php b/application/controllers/Hrpayroll.php index 3a19455..0fa24cf 100644 --- a/application/controllers/Hrpayroll.php +++ b/application/controllers/Hrpayroll.php @@ -1,15 +1,142 @@ 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;} } diff --git a/application/controllers/Leaverequests.php b/application/controllers/Leaverequests.php index f068348..3fa1bec 100644 --- a/application/controllers/Leaverequests.php +++ b/application/controllers/Leaverequests.php @@ -1,284 +1,198 @@ 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 = ' - - Disetujui - - '; - - } elseif($r->approval_status == 'rejected'){ - - $status = ' - - Ditolak - - '; - - } else { - - $status = ' - - Pending - - '; - } - - $data[] = [ - $no++, - $r->fingerprint_user_id, - $r->full_name, - $leave_type, - $r->start_date, - $r->end_date, - $r->total_days . ' Hari', - $status, - ' - - - - ' - ]; - } - - 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; } } diff --git a/application/controllers/Payroll.php b/application/controllers/Payroll.php index 37971a1..34aa8fe 100644 --- a/application/controllers/Payroll.php +++ b/application/controllers/Payroll.php @@ -1,606 +1,15 @@ 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 = ' - - Paid - - '; - - } else { - - $status = ' - - Draft - - '; - } - - $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, - - ' - - ' - ]; - } - - 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'); } } diff --git a/application/controllers/Payrollcheck.php b/application/controllers/Payrollcheck.php new file mode 100644 index 0000000..aad3834 --- /dev/null +++ b/application/controllers/Payrollcheck.php @@ -0,0 +1,50 @@ +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"; + } +} diff --git a/application/controllers/Profile.php b/application/controllers/Profile.php new file mode 100644 index 0000000..03fbaeb --- /dev/null +++ b/application/controllers/Profile.php @@ -0,0 +1,89 @@ +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();} +} diff --git a/application/controllers/Roles.php b/application/controllers/Roles.php index 6237041..5d7f59d 100644 --- a/application/controllers/Roles.php +++ b/application/controllers/Roles.php @@ -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();} diff --git a/application/core/MY_Controller.php b/application/core/MY_Controller.php index efdcae3..e9bd1d9 100644 --- a/application/core/MY_Controller.php +++ b/application/core/MY_Controller.php @@ -11,6 +11,12 @@ class MY_Controller extends CI_Controller if (!$this->session->userdata('logged_in')) { $this->rejectUnauthenticatedRequest(); } + $this->load->library('UserSessionService'); + $sessionCheck=$this->usersessionservice->validateCurrent(); + if(!$sessionCheck['valid']){ + $reason=$sessionCheck['reason']==='expired'?'expired':($sessionCheck['reason']==='device_limit'?'device_limit':'revoked'); + $this->terminateAuthenticatedSession($reason); + } if ($this->db->table_exists('companies')) { $this->load->library('CompanyContext'); try { @@ -48,9 +54,9 @@ class MY_Controller extends CI_Controller 'inventoryprofessional'=>'items','asset'=>'asset','fixedassets'=>'asset','lokasiasset'=>'asset', 'cashbank'=>'cash_bank','jurnal'=>'jurnal','basejurnal'=>'jurnal','accounts'=>'jurnal', 'accountingperiods'=>'jurnal','professionalreports'=>'reports','budgets'=>'budgets', - 'taxes'=>'taxes','roles'=>'setting','users'=>'setting','employees'=>'employees','generateschedule'=>'employees', - 'attendancemonitoring'=>'employees','attendancemonthly'=>'employees','attendancmonthly'=>'employees', - 'shifts'=>'employees','holidays'=>'employees','leaverequests'=>'employees','hrpayroll'=>'employees','organizationmaster'=>'employees' + 'taxes'=>'taxes','roles'=>'setting','users'=>'setting','employees'=>array('hr_employees','employees'),'generateschedule'=>array('hr_schedules','employees'), + 'attendancemonitoring'=>array('hr_attendance','employees'),'attendancemonthly'=>array('hr_attendance','employees'),'attendancmonthly'=>array('hr_attendance','employees'), + 'shifts'=>array('hr_schedules','employees'),'holidays'=>array('hr_schedules','employees'),'leaverequests'=>array('hr_leave','employees'),'hrpayroll'=>array('payroll','employees'),'payroll'=>array('payroll','employees'),'organizationmaster'=>array('hr_organization','employees') ); if (!isset($features[$class])) return; $action = 'can_view'; @@ -90,6 +96,52 @@ class MY_Controller extends CI_Controller } if (in_array($method, array('save_lines','revise'), true)) $action = 'can_update'; } + if ($class === 'leaverequests') { + if (in_array($method, array('get_data','detail'), true)) $action = 'can_view'; + if ($method === 'save') $action = 'can_view'; + if ($method === 'submit') $action = 'can_create'; + if (in_array($method, array('approve','reject'), true)) $action = 'can_approve'; + if ($method === 'cancel') $action = 'can_view'; + } + if ($class === 'attendancemonitoring') { + if (in_array($method, array('get_data','manual_requests','manual_detail','employees'), true)) $action = 'can_view'; + if ($method === 'save_manual') $action = 'can_create'; + if ($method === 'submit_manual') $action = 'can_create'; + if (in_array($method, array('approve_manual','reject_manual'), true)) $action = 'can_approve'; + if ($method === 'cancel_manual') $action = 'can_update'; + } + if ($class === 'employees') { + if (in_array($method,array('getall','detail'),true)) $action='can_view'; + if ($method==='store') $action='can_create'; + if ($method==='update') $action='can_update'; + if ($method==='delete') $action='can_delete'; + } + if ($class === 'organizationmaster') { + if (in_array($method,array('index','data'),true)) $action='can_view'; + if (in_array($method,array('save_department','save_position'),true)) $action=$this->input->post('id')?'can_update':'can_create'; + if (strpos($method,'delete_')===0) $action='can_delete'; + } + if ($class === 'generateschedule') { + if (in_array($method,array('index','calendar_data','get_employees','detail_schedule'),true)) $action='can_view'; + if ($method==='save_manual_schedule') $action='can_create'; + if ($method==='update_schedule_shift') $action='can_update'; + if ($method==='delete_schedule') $action='can_delete'; + } + if (in_array($class,array('shifts','holidays'),true)) { + if (in_array($method,array('index','get_data','detail'),true)) $action='can_view'; + if ($method==='save') $action='can_create'; + if ($method==='update') $action='can_update'; + if ($method==='delete') $action='can_delete'; + } + if (in_array($class,array('attendancemonthly','attendancmonthly'),true)) $action='can_view'; + if ($class === 'hrpayroll') { + if (in_array($method, array('index','period_detail','salary_detail','slip'), true)) $action = 'can_view'; + if (strpos($method,'export')!==false) $action = 'can_export'; + if (in_array($method, array('create_period','calculate','save_adjustment'), true)) $action = 'can_create'; + if (in_array($method, array('component','save_salary_line','deactivate_salary_line','save_position_line','save_attendance_rule','save_settings','deactivate_adjustment'), true)) $action = 'can_update'; + if ($method === 'workflow') $action = 'can_approve'; + if (in_array($method, array('lock_attendance','post','pay'), true)) $action = 'can_post'; + } if ($class === 'sales') { if (in_array($method, array('invoice_detail','delivery_detail','return_detail','lookup_customers','lookup_items','lookup_barcodes','warehouses'), true)) $action = 'can_view'; if (in_array($method, array('append_invoice','delete_invoice','update_delivery'), true)) $action = 'can_update'; @@ -135,13 +187,36 @@ class MY_Controller extends CI_Controller exit; } + protected function terminateAuthenticatedSession($reason) + { + $messages=array( + 'expired'=>'Masa login Anda telah berakhir. Silakan login kembali.', + 'device_limit'=>'Session dihentikan karena batas perangkat aktif telah tercapai.', + 'revoked'=>'Session perangkat ini telah diakhiri. Silakan login kembali.', + ); + $message=$messages[$reason]??$messages['revoked']; + $this->usersessionservice->endCurrent($reason==='expired'?'expired':'revoked',$message); + $this->session->sess_destroy(); + if($this->input->is_ajax_request()){ + $this->output->set_status_header(401)->set_content_type('application/json')->set_output(json_encode(array('status'=>false,'message'=>$message,'redirect'=>base_url('auth'))))->_display(); + exit; + } + redirect('auth?notice='.rawurlencode($reason)); + exit; + } + protected function requirePermission($feature, $action = 'can_view') { if (is_master_admin_user()) { return true; } - if (!check_permission($feature, $action)) { + $features = is_array($feature) ? $feature : array($feature); + $allowed = false; + foreach ($features as $permissionFeature) { + if (check_permission($permissionFeature, $action)) { $allowed = true; break; } + } + if (!$allowed) { if ($this->input->is_ajax_request()) { $this->output->set_status_header(403)->set_content_type('application/json','utf-8')->set_output(json_encode(array('status'=>false,'message'=>'Anda tidak memiliki izin untuk tindakan ini.')))->_display(); exit; diff --git a/application/libraries/ApprovalService.php b/application/libraries/ApprovalService.php index 456bc67..57efc37 100644 --- a/application/libraries/ApprovalService.php +++ b/application/libraries/ApprovalService.php @@ -77,7 +77,9 @@ class ApprovalService 'stock_document'=>array('inventoryprofessional?focus='.$id,'Buka Dokumen Persediaan'), 'cash_transaction'=>array('cashbank?focus='.$id,'Buka Transaksi Kas/Bank'), 'payroll_period'=>array('hrpayroll?focus='.$id,'Buka Payroll'), + 'manual_attendance'=>array('attendancemonitoring?focus='.$id,'Buka Absensi Manual'), 'attendance_correction'=>array('attendancemonitoring?focus='.$id,'Buka Koreksi Absensi'), + 'leave_request'=>array('leaverequests?focus='.$id,'Buka Pengajuan Cuti'), 'budget'=>array('budgets?focus='.$id,'Buka Budget') ); if(isset($map[$type]))return $map[$type]; diff --git a/application/libraries/NavigationService.php b/application/libraries/NavigationService.php index a0abfad..59ed89c 100644 --- a/application/libraries/NavigationService.php +++ b/application/libraries/NavigationService.php @@ -2,9 +2,11 @@ defined('BASEPATH') OR exit('No direct script access allowed'); class NavigationService { - private $CI;private $pendingBadge=null;private $inventoryDocumentBadge=null;private $technicianBadge=null;private $payableBadge=null;private $payableBadgeClass='';private $returnBadge=null;private $returnBadgeClass='';private $budgetBadge=null;private $salesBadges=null;public function __construct(){$this->CI=&get_instance();} + private $CI;private $pendingBadge=null;private $inventoryDocumentBadge=null;private $technicianBadge=null;private $payableBadge=null;private $payableBadgeClass='';private $returnBadge=null;private $returnBadgeClass='';private $budgetBadge=null;private $leaveBadge=null;private $attendanceBadge=null;private $salesBadges=null;public function __construct(){$this->CI=&get_instance();} private function allowed($feature){if(is_master_admin_user())return true;foreach((array)$feature as$name)if(check_permission($name,'can_view'))return true;return false;} public function items(){ + $employeeUrl=$this->allowed(array('hr_employees','employees'))?'employees':'organizationmaster'; + $attendanceUrl=$this->allowed(array('hr_attendance','employees'))?'attendancemonitoring':($this->allowed(array('hr_schedules','employees'))?'generateschedule':'leaverequests'); $items=array( array('key'=>'dashboard','label'=>'Dashboard','icon'=>'bi-speedometer2','url'=>'','feature'=>'dashboard'), array('key'=>'approvals','label'=>'Approval','icon'=>'bi-inbox','url'=>'approvals','feature'=>'approvals','badge'=>$this->approvalBadge()), @@ -15,12 +17,14 @@ class NavigationService array('key'=>'accounting','label'=>'Accounting','icon'=>'bi-journal-text','feature'=>'jurnal','children'=>array(array('key'=>'jurnal','label'=>'Jurnal Umum','url'=>'jurnal'),array('key'=>'accounts','label'=>'Daftar Akun','url'=>'accounts'),array('key'=>'basejurnal','label'=>'Base Jurnal','url'=>'basejurnal'),array('key'=>'accounting_periods','label'=>'Periode Accounting','url'=>'accountingperiods'))), array('key'=>'professional_reports','label'=>'Laporan Profesional','icon'=>'bi-bar-chart','url'=>'professionalreports','feature'=>'reports'), array('key'=>'budgets','label'=>'Budget','icon'=>'bi-bullseye','url'=>'budgets','feature'=>'budgets','badge'=>$this->budgetApprovalBadge(),'badge_class'=>'nav-badge-warning','badge_title'=>'Pengingat pembayaran budget yang perlu diperhatikan'), - array('key'=>'hr','label'=>'HR & Payroll','icon'=>'bi-people','feature'=>'employees','children'=>array(array('key'=>'organization_master','label'=>'Departemen & Posisi','url'=>'organizationmaster'),array('key'=>'employees','label'=>'Data Karyawan','url'=>'employees'),array('key'=>'generate_schedule','label'=>'Jadwal Kerja','url'=>'generateschedule'),array('key'=>'attendance_monitoring','label'=>'Monitoring Absensi','url'=>'attendancemonitoring'),array('key'=>'attendance_monthly','label'=>'Absensi Bulanan','url'=>'attendancemonthly'),array('key'=>'shift','label'=>'Shift','url'=>'shifts'),array('key'=>'holidays','label'=>'Hari Libur','url'=>'holidays'),array('key'=>'leave_requests','label'=>'Cuti & Izin','url'=>'leaverequests'),array('key'=>'hr_payroll','label'=>'Payroll Control','url'=>'hrpayroll'))), + array('key'=>'hr','label'=>'HR & Payroll','icon'=>'bi-people','feature'=>array('hr_organization','hr_employees','hr_schedules','hr_attendance','hr_leave','payroll','employees'),'badge'=>$this->leaveApprovalBadge()+$this->manualAttendanceBadge(),'badge_class'=>'nav-badge-warning','badge_title'=>'Pengajuan HR menunggu tindakan','children'=>array(array('key'=>'employees','label'=>'Karyawan','url'=>$employeeUrl,'feature'=>array('hr_employees','hr_organization','employees'),'active_keys'=>array('employees','organization_master')),array('key'=>'attendance_monitoring','label'=>'Absen & Kehadiran','url'=>$attendanceUrl,'feature'=>array('hr_schedules','hr_attendance','hr_leave','employees'),'active_keys'=>array('generate_schedule','attendance_monitoring','attendance_monthly','shift','holidays','leave_requests'),'badge'=>$this->leaveApprovalBadge()+$this->manualAttendanceBadge(),'badge_class'=>'nav-badge-warning','badge_title'=>'Pengajuan absensi atau cuti menunggu tindakan'),array('key'=>'hr_payroll','label'=>'Payroll','url'=>'hrpayroll','feature'=>array('payroll','employees')))), array('key'=>'companies','label'=>'Company Setting','icon'=>'bi-buildings','url'=>'companies','feature'=>'companies','admin'=>true), array('key'=>'settings','label'=>'Pengaturan','icon'=>'bi-gear','feature'=>'setting','admin'=>true,'children'=>array(array('key'=>'users','label'=>'Pengguna','url'=>'users'),array('key'=>'roles','label'=>'Role & Permission','url'=>'roles'))) );$out=array();foreach($items as$item){if(!empty($item['admin'])&&!is_master_admin_user())continue;if(!$this->allowed($item['feature']))continue;if(!empty($item['children'])){$children=array();foreach($item['children']as$c){if(!empty($c['admin'])&&!is_master_admin_user())continue;if(!empty($c['feature'])&&!$this->allowed($c['feature']))continue;$children[]=$c;}$item['children']=$children;if(!$children)continue;}$out[]=$item;}return$out; } private function approvalBadge(){if(!$this->CI->db->table_exists('approval_requests'))return 0;return(int)$this->CI->db->where('status','pending')->count_all_results('approval_requests');} + private function leaveApprovalBadge(){if($this->leaveBadge!==null)return$this->leaveBadge;if(!$this->CI->db->table_exists('k_leave_requests')||!$this->CI->db->field_exists('workflow_status','k_leave_requests'))return$this->leaveBadge=0;$this->CI->db->where('workflow_status','submitted');if($this->CI->db->field_exists('company_id','k_leave_requests'))$this->CI->db->where('company_id',(int)$this->CI->session->userdata('company_id'));return$this->leaveBadge=(int)$this->CI->db->count_all_results('k_leave_requests');} + private function manualAttendanceBadge(){if($this->attendanceBadge!==null)return$this->attendanceBadge;if(!$this->CI->db->table_exists('manual_attendance_requests'))return$this->attendanceBadge=0;$this->CI->db->where('status','submitted')->where('company_id',(int)$this->CI->session->userdata('company_id'));return$this->attendanceBadge=(int)$this->CI->db->count_all_results('manual_attendance_requests');} private function salesBadge($name){if($this->salesBadges===null){$c=(int)$this->CI->session->userdata('company_id');$this->salesBadges=array('draft'=>0,'approval'=>0,'delivery'=>0,'receivable'=>0,'overdue'=>0,'return'=>0);if($this->CI->db->table_exists('invoices')){$where=$this->CI->db->field_exists('company_id','invoices')?' AND company_id='.(int)$c:'';$r=$this->CI->db->query("SELECT SUM(workflow_status='draft') draft_count,SUM(workflow_status='submitted') approval_count,SUM(workflow_status='posted' AND sisa_piutang>0) receivable_count,SUM(workflow_status='posted' AND sisa_piutang>0 AND jatuh_temporow();if($r){$this->salesBadges['draft']=(int)$r->draft_count;$this->salesBadges['approval']=(int)$r->approval_count;$this->salesBadges['receivable']=(int)$r->receivable_count;$this->salesBadges['overdue']=(int)$r->overdue_count;}}if($this->CI->db->table_exists('sales_deliveries')){$this->CI->db->where('status','draft');if($this->CI->db->field_exists('company_id','sales_deliveries'))$this->CI->db->where('company_id',$c);$this->salesBadges['delivery']=(int)$this->CI->db->count_all_results('sales_deliveries');}if($this->CI->db->table_exists('sales_returns')){$this->CI->db->where_in('status',array('draft','submitted','approved','received'));if($this->CI->db->field_exists('company_id','sales_returns'))$this->CI->db->where('company_id',$c);$this->salesBadges['return']=(int)$this->CI->db->count_all_results('sales_returns');}}if($name==='all')return$this->salesBadges['draft']+$this->salesBadges['approval']+$this->salesBadges['delivery']+$this->salesBadges['receivable']+$this->salesBadges['return'];return$this->salesBadges[$name]??0;} private function budgetApprovalBadge(){if($this->budgetBadge!==null)return$this->budgetBadge;if(!$this->allowed('budgets'))return$this->budgetBadge=0;$company=(int)$this->CI->session->userdata('company_id');$total=0;if($this->CI->db->table_exists('budgets')&&(is_master_admin_user()||check_permission('budgets','can_approve'))){$this->CI->db->where('status','submitted');if($this->CI->db->field_exists('company_id','budgets'))$this->CI->db->where('company_id',$company);$total+=(int)$this->CI->db->count_all_results('budgets');}if($this->CI->db->table_exists('account_budget_entries')&&$this->CI->db->table_exists('account_budget_reminder_completions')){$period=date('Y-m');$row=$this->CI->db->query("SELECT COUNT(*) total FROM account_budget_entries e WHERE e.company_id=? AND e.is_active=1 AND e.reminder_enabled=1 AND ((e.entry_type='recurring' AND e.period<=?) OR (e.entry_type='override' AND e.period=?)) AND LEAST(e.reminder_day,DAY(LAST_DAY(CURDATE())))<=LEAST(DAY(LAST_DAY(CURDATE())),DAY(CURDATE())+7) AND NOT EXISTS(SELECT 1 FROM account_budget_reminder_completions c WHERE c.company_id=e.company_id AND c.budget_entry_id=e.id AND c.period=?)",array($company,$period,$period,$period))->row();$total+=(int)($row?$row->total:0);}return$this->budgetBadge=$total;} private function pendingItemBadge(){if($this->pendingBadge!==null)return$this->pendingBadge;if(!$this->CI->db->table_exists('items'))return$this->pendingBadge=0;$company=(int)$this->CI->session->userdata('company_id');if($this->CI->db->table_exists('item_barcodes'))return$this->pendingBadge=(int)$this->CI->db->query("SELECT COUNT(DISTINCT i.id) total FROM items i LEFT JOIN item_barcodes ib ON ib.item_id=i.id AND ib.status='pending' WHERE i.company_id=? AND EXISTS(SELECT 1 FROM goods_receipt_lines grl WHERE grl.item_id=i.id) AND(i.status='draft' OR ib.id IS NOT NULL)",array($company))->row()->total;return$this->pendingBadge=(int)$this->CI->db->where(array('status'=>'draft','company_id'=>$company))->count_all_results('items');} diff --git a/application/libraries/PayrollService.php b/application/libraries/PayrollService.php index 93ea8f6..a242700 100644 --- a/application/libraries/PayrollService.php +++ b/application/libraries/PayrollService.php @@ -1,11 +1,122 @@ CI=&get_instance();$this->CI->load->library(array('FormulaService','PostingService','FiscalPeriodService','AccountMappingService'));} - private function period($id){$p=$this->CI->db->query('SELECT * FROM k_payroll_periods WHERE id=? FOR UPDATE',array($id))->row();if(!$p)throw new BusinessException('Periode payroll tidak ditemukan.');return$p;} - public function calculate($month,$uid){$start=$month.'-01';$end=date('Y-m-t',strtotime($start));$db=$this->CI->db;$db->trans_begin();try{$locked=$db->get_where('attendance_period_locks',array('period'=>$month,'status'=>'locked'))->row();if(!$locked)throw new BusinessException('Absensi bulan '.$month.' harus dikunci sebelum payroll dihitung.');$p=$db->get_where('k_payroll_periods',array('start_date'=>$start,'end_date'=>$end))->row();if($p&&$p->workflow_status!=='draft')throw new BusinessException('Periode payroll bukan draft.');if(!$p){$db->insert('k_payroll_periods',array('period_name'=>date('F Y',strtotime($start)),'start_date'=>$start,'end_date'=>$end,'status'=>'draft','workflow_status'=>'draft','generated_at'=>date('Y-m-d H:i:s')));$pid=$db->insert_id();}else$pid=$p->id;$components=$db->where('is_active',1)->get('payroll_components')->result();foreach($db->where('is_active',1)->get('k_employees')->result()as$e){$att=$db->query("SELECT COUNT(*) work_days,SUM(attendance_status IN('present','late')) present_days,SUM(attendance_status='alpha') unpaid_days,COALESCE(SUM(overtime_hours),0) overtime_hours FROM k_attendances WHERE employee_id=? AND attendance_date BETWEEN ? AND ?",array($e->id,$start,$end))->row();$vars=array('basic_salary'=>(float)$e->basic_salary,'calendar_days'=>(int)date('t',strtotime($start)),'work_days'=>(float)$att->work_days,'present_days'=>(float)$att->present_days,'unpaid_days'=>(float)$att->unpaid_days,'overtime_hours'=>(float)$att->overtime_hours,'overtime_rate'=>(float)(app_env('PAYROLL_OVERTIME_RATE',25000)?:25000),'daily_rate'=>(float)$e->basic_salary/max(1,(int)date('t',strtotime($start))));$pay=$db->get_where('k_payrolls',array('payroll_period_id'=>$pid,'employee_id'=>$e->id))->row();$data=array('payroll_period_id'=>$pid,'employee_id'=>$e->id,'basic_salary'=>$e->basic_salary,'total_work_days'=>$att->work_days,'total_present'=>$att->present_days,'total_alpha'=>$att->unpaid_days,'total_overtime_hours'=>$att->overtime_hours);if($pay){$db->where('id',$pay->id)->update('k_payrolls',$data);$payid=$pay->id;$db->where('payroll_id',$payid)->delete('payroll_calculation_lines');}else{$db->insert('k_payrolls',$data);$payid=$db->insert_id();}$earn=$deduct=$employer=0;foreach($components as$c){$override=$db->where('employee_id',$e->id)->where('component_id',$c->id)->where('is_active',1)->where('effective_from <=',$end)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$start)->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->row();$amount=$override&&$override->amount_override!==null?(float)$override->amount_override:($c->fixed_amount!==null?(float)$c->fixed_amount:$this->CI->formulaservice->calculate($c->formula_expression,$vars));if($c->code==='BASIC'&&$c->prorated&&$e->join_date>$start&&$e->join_date<=$end)$amount=round($amount*(date('t',strtotime($start))-date('d',strtotime($e->join_date))+1)/date('t',strtotime($start)),2);if(abs($amount)<.01)continue;$db->insert('payroll_calculation_lines',array('payroll_id'=>$payid,'component_id'=>$c->id,'component_code'=>$c->code,'component_name'=>$c->name,'component_type'=>$c->component_type,'base_amount'=>$e->basic_salary,'amount'=>$amount,'formula_snapshot'=>$c->formula_expression,'expense_account_id'=>$c->expense_account_id,'liability_account_id'=>$c->liability_account_id));if($c->component_type==='earning')$earn+=$amount;elseif($c->component_type==='deduction')$deduct+=$amount;else$employer+=$amount;}$net=$earn-$deduct;$snap=json_encode(array('variables'=>$vars,'gross'=>$earn,'deductions'=>$deduct,'net'=>$net));$db->where('id',$payid)->update('k_payrolls',array('gross_salary'=>$earn,'total_deductions'=>$deduct,'total_salary'=>$net,'calculation_snapshot'=>$snap,'version'=>isset($pay->version)?$pay->version+1:1));}$hash=hash('sha256',json_encode($db->select('p.id,p.total_salary,p.version')->from('k_payrolls p')->where('payroll_period_id',$pid)->order_by('p.id')->get()->result_array()));$db->where('id',$pid)->update('k_payroll_periods',array('calculation_hash'=>$hash));$db->trans_commit();return$pid;}catch(Throwable$e){$db->trans_rollback();throw$e;}} - public function workflow($id,$action,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);$map=array('review'=>array('draft','reviewed'), 'approve'=>array('reviewed','approved'), 'finalize'=>array('approved','final'));if(!isset($map[$action])||$p->workflow_status!==$map[$action][0])throw new BusinessException('Transisi payroll tidak valid.');if($action!=='review'&&(int)($action==='approve'?$p->reviewed_by:$p->approved_by)===(int)$uid)throw new BusinessException('Reviewer/approver berikutnya harus user berbeda.');$up=array('workflow_status'=>$map[$action][1],$action==='review'?'reviewed_by':($action==='approve'?'approved_by':'finalized_by')=>$uid,$action==='review'?'reviewed_at':($action==='approve'?'approved_at':'finalized_at')=>date('Y-m-d H:i:s'));$db->where('id',$id)->update('k_payroll_periods',$up);$db->trans_commit();}catch(Throwable$e){$db->trans_rollback();throw$e;}} - public function post($id,$date,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);if($p->workflow_status!=='final'||$p->journal_id)throw new BusinessException('Payroll belum final atau sudah diposting.');$this->CI->fiscalperiodservice->assertOpen($date);$gross=(float)$db->select('COALESCE(SUM(gross_salary),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$ded=(float)$db->select('COALESCE(SUM(total_deductions),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$net=$gross-$ded;$lines=array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_expense'),'debit'=>$gross,'kredit'=>0));if($ded>0)$lines[]=array('account_id'=>$this->CI->accountmappingservice->get('payroll_tax_payable'),'debit'=>0,'kredit'=>$ded);$lines[]=array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>0,'kredit'=>$net);$jid=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'PAYROLL-'.str_replace('-','',$p->start_date),'keterangan'=>'Payroll register '.$p->period_name.'; karyawan '.$db->where('payroll_period_id',$id)->count_all_results('k_payrolls').'; bruto Rp '.number_format($gross,2,'.','').' neto Rp '.number_format($net,2,'.',''),'ref_type'=>'payroll','ref_id'=>$id,'created_by'=>$uid),$lines,false,true);$db->where('id',$id)->update('k_payroll_periods',array('workflow_status'=>'posted','journal_id'=>$jid));$db->trans_commit();return$jid;}catch(Throwable$e){$db->trans_rollback();throw$e;}} - public function pay($id,$date,$cashAccountId,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);if($p->workflow_status!=='posted')throw new BusinessException('Payroll belum diposting atau sudah dibayar.');if($db->get_where('payroll_payments',array('payroll_period_id'=>$id))->row())throw new BusinessException('Pembayaran payroll sudah tersedia.');$cash=$db->query('SELECT * FROM cash_accounts WHERE id=? FOR UPDATE',array($cashAccountId))->row();if(!$cash)throw new BusinessException('Rekening pembayaran tidak ditemukan.');$this->CI->fiscalperiodservice->assertOpen($date);$net=(float)$db->select('COALESCE(SUM(total_salary),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$no='PRP-'.date('YmdHis');$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Pembayaran payroll '.$p->period_name.' melalui '.$cash->name.'; total neto Rp '.number_format($net,2,'.',''),'ref_type'=>'payroll_payment','ref_id'=>$id,'created_by'=>$uid),array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>$net,'kredit'=>0),array('account_id'=>$cash->gl_account_id,'debit'=>0,'kredit'=>$net)),false,true);$db->insert('payroll_payments',array('payroll_period_id'=>$id,'payment_no'=>$no,'payment_date'=>$date,'cash_account_id'=>$cashAccountId,'total_amount'=>$net,'status'=>'posted','journal_id'=>$journal,'created_by'=>$uid,'approved_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s')));$paymentId=$db->insert_id();$db->where('id',$id)->update('k_payroll_periods',array('workflow_status'=>'paid','payment_transaction_id'=>$paymentId));$db->trans_commit();return$journal;}catch(Throwable$e){$db->trans_rollback();throw$e;}} + private $CI; + public function __construct() + { + $this->CI =& get_instance(); + $this->CI->load->library(array('FormulaService','PostingService','FiscalPeriodService','AccountMappingService','AuditService')); + } + + private function period($id,$companyId,$lock=true) + { + $period=$this->CI->db->query('SELECT * FROM k_payroll_periods WHERE id=? AND company_id=?'.($lock?' FOR UPDATE':''),array((int)$id,(int)$companyId))->row(); + if(!$period)throw new BusinessException('Periode payroll tidak ditemukan pada perusahaan aktif.'); + return $period; + } + + public function calculate($periodId,$uid,$canManageAll,$companyId) + { + $db=$this->CI->db;$db->trans_begin(); + try{ + $period=$this->period($periodId,$companyId); + if($period->workflow_status!=='draft')throw new BusinessException('Hanya payroll Draft yang dapat dihitung ulang.'); + $start=$period->attendance_start_date?:$period->start_date;$end=$period->attendance_end_date?:$period->end_date; + if(!$db->get_where('payroll_attendance_locks',array('company_id'=>$companyId,'payroll_period_id'=>$period->id,'status'=>'locked'))->row())throw new BusinessException('Kehadiran periode ini harus dikunci sebelum payroll dihitung.'); + $employees=$db->where(array('company_id'=>$companyId,'is_active'=>1))->order_by('full_name')->get('k_employees')->result(); + if(!$canManageAll)$employees=array_values(array_filter($employees,function($employee){return empty($employee->salary_restricted);})); + if(!$employees)throw new BusinessException('Tidak ada karyawan yang dapat dihitung dengan permission Anda.'); + foreach($employees as$employee)$this->calculateEmployee($period,$employee,$start,$end,$companyId,$uid); + $hash=hash('sha256',json_encode($db->select('id,total_salary,version')->where(array('payroll_period_id'=>$period->id,'company_id'=>$companyId))->order_by('id')->get('k_payrolls')->result_array())); + $db->where(array('id'=>$period->id,'company_id'=>$companyId))->update('k_payroll_periods',array('calculation_hash'=>$hash,'calculated_by'=>$uid,'calculated_at'=>date('Y-m-d H:i:s'),'calculation_version'=>(int)$period->calculation_version+1,'generated_at'=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s'))); + $this->CI->auditservice->record('payroll','payroll_period',$period->id,'calculate',null,array('start'=>$start,'end'=>$end,'employees'=>count($employees),'hash'=>$hash),$uid); + if($db->trans_status()===false)throw new RuntimeException('Perhitungan payroll gagal disimpan.');$db->trans_commit();return(int)$period->id; + }catch(Throwable$e){$db->trans_rollback();throw$e;} + } + + private function calculateEmployee($period,$employee,$start,$end,$companyId,$uid) + { + $db=$this->CI->db; + $attendance=$db->query("SELECT COUNT(*) work_days,COALESCE(SUM(attendance_status IN('present','late')),0) present_days,COALESCE(SUM(attendance_status='alpha'),0) alpha_days,COALESCE(SUM(attendance_status='unpaid'),0) unpaid_days,COALESCE(SUM(late_minutes),0) late_minutes,COALESCE(SUM(early_leave_minutes),0) early_leave_minutes,COALESCE(SUM(overtime_hours),0) overtime_hours,COALESCE(SUM(checkin_time IS NULL AND attendance_status NOT IN('annual','sick','permit','maternity','holiday')),0) missing_checkin,COALESCE(SUM(checkout_time IS NULL AND attendance_status NOT IN('annual','sick','permit','maternity','holiday')),0) missing_checkout FROM k_attendances WHERE employee_id=? AND attendance_date BETWEEN ? AND ?",array($employee->id,$start,$end))->row(); + $assignments=$this->resolvedAssignments($employee,$start,$end,$companyId);$basic=0; + foreach($assignments as$assignment)if($assignment->code==='BASIC'){$basic=$this->assignmentAmount($assignment,array('basic_salary'=>0));break;} + $calendarDays=max(1,(int)((strtotime($end)-strtotime($start))/86400)+1); + $vars=array('basic_salary'=>$basic,'calendar_days'=>$calendarDays,'work_days'=>(float)$attendance->work_days,'present_days'=>(float)$attendance->present_days,'alpha_days'=>(float)$attendance->alpha_days,'unpaid_days'=>(float)$attendance->unpaid_days,'unpaid_days_total'=>(float)$attendance->unpaid_days+(float)$attendance->alpha_days,'late_minutes'=>(float)$attendance->late_minutes,'early_leave_minutes'=>(float)$attendance->early_leave_minutes,'missing_checkin'=>(float)$attendance->missing_checkin,'missing_checkout'=>(float)$attendance->missing_checkout,'overtime_hours'=>(float)$attendance->overtime_hours,'overtime_rate'=>(float)(app_env('PAYROLL_OVERTIME_RATE',25000)?:25000),'daily_rate'=>$basic/$calendarDays); + $pay=$db->get_where('k_payrolls',array('company_id'=>$companyId,'payroll_period_id'=>$period->id,'employee_id'=>$employee->id))->row(); + $header=array('company_id'=>$companyId,'payroll_period_id'=>$period->id,'employee_id'=>$employee->id,'basic_salary'=>$basic,'total_work_days'=>$attendance->work_days,'total_present'=>$attendance->present_days,'total_alpha'=>$attendance->alpha_days,'total_overtime_hours'=>$attendance->overtime_hours,'period_start_date'=>$start,'period_end_date'=>$end,'structure_version'=>(int)$period->calculation_version+1); + if($pay){$db->where('id',$pay->id)->update('k_payrolls',$header);$payrollId=(int)$pay->id;$db->where('payroll_id',$payrollId)->delete('payroll_calculation_lines');}else{$db->insert('k_payrolls',$header);$payrollId=(int)$db->insert_id();} + $lines=array(); + foreach($assignments as$assignment)$this->mergeLine($lines,$assignment,$this->assignmentAmount($assignment,$vars),array('source_type'=>$assignment->source_type,'source_id'=>(int)$assignment->source_id,'method'=>$assignment->resolved_method,'value'=>$assignment->resolved_value)); + foreach($this->attendanceRuleLines($start,$end,$companyId,$vars)as$ruleLine)$this->mergeLine($lines,$ruleLine['component'],$ruleLine['amount'],$ruleLine['snapshot'],$ruleLine['snapshot']); + $adjustments=$db->select('a.*,c.code,c.name,c.expense_account_id,c.liability_account_id')->from('payroll_period_adjustments a')->join('payroll_components c','c.id=a.component_id')->where(array('a.company_id'=>$companyId,'a.payroll_period_id'=>$period->id,'a.employee_id'=>$employee->id,'a.is_active'=>1))->get()->result(); + foreach($adjustments as$adjustment){$component=(object)array('id'=>$adjustment->component_id,'code'=>$adjustment->code,'name'=>$adjustment->name,'component_type'=>$adjustment->adjustment_type,'expense_account_id'=>$adjustment->expense_account_id,'liability_account_id'=>$adjustment->liability_account_id,'resolved_method'=>'fixed','resolved_value'=>$adjustment->amount,'resolved_formula'=>null);$this->mergeLine($lines,$component,(float)$adjustment->amount,array('source_type'=>'period_adjustment','source_id'=>(int)$adjustment->id,'notes'=>$adjustment->notes));} + $earnings=0;$deductions=0;$employerCost=0; + foreach($lines as$line){if(abs($line['amount'])<.01)continue;$db->insert('payroll_calculation_lines',array('payroll_id'=>$payrollId,'component_id'=>$line['component_id'],'component_code'=>$line['component_code'],'component_name'=>$line['component_name'],'component_type'=>$line['component_type'],'calculation_method'=>$line['calculation_method'],'base_amount'=>$basic,'rate'=>$line['rate'],'quantity'=>$line['quantity'],'amount'=>round($line['amount'],2),'formula_snapshot'=>$line['formula_snapshot'],'source_snapshot'=>json_encode($line['sources'],JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),'attendance_rule_snapshot'=>$line['rule_snapshot']?json_encode($line['rule_snapshot'],JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES):null,'expense_account_id'=>$line['expense_account_id'],'liability_account_id'=>$line['liability_account_id']));if($line['component_type']==='earning')$earnings+=$line['amount'];elseif($line['component_type']==='deduction')$deductions+=$line['amount'];else$employerCost+=$line['amount'];} + $net=$earnings-$deductions;if($net<0)throw new BusinessException('Gaji neto '.$employee->full_name.' menjadi negatif. Periksa komponen dan potongannya.'); + $snapshot=array('employee'=>array('id'=>(int)$employee->id,'code'=>$employee->employee_code,'name'=>$employee->full_name,'department_id'=>$employee->department_id,'position_id'=>$employee->position_id,'salary_restricted'=>(int)$employee->salary_restricted),'attendance_period'=>array('start'=>$start,'end'=>$end),'variables'=>$vars,'lines'=>array_values($lines),'gross'=>$earnings,'deductions'=>$deductions,'employer_cost'=>$employerCost,'net'=>$net,'calculated_by'=>$uid,'calculated_at'=>date('c')); + $db->where('id',$payrollId)->update('k_payrolls',array('gross_salary'=>round($earnings,2),'total_deductions'=>round($deductions,2),'total_salary'=>round($net,2),'calculation_snapshot'=>json_encode($snapshot,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),'version'=>$pay?((int)$pay->version+1):1)); + } + + private function resolvedAssignments($employee,$start,$end,$companyId) + { + $db=$this->CI->db; + $position=$db->select("x.id source_id,'position' source_type,x.component_id,x.calculation_method resolved_method,x.amount_override resolved_value,x.formula_expression resolved_formula,c.code,c.name,c.component_type,c.calculation_method component_method,c.default_value,c.fixed_amount,c.formula_expression component_formula,c.expense_account_id,c.liability_account_id")->from('position_payroll_components x')->join('payroll_components c','c.id=x.component_id AND c.company_id=x.company_id')->where(array('x.company_id'=>$companyId,'x.position_id'=>$employee->position_id,'x.is_active'=>1,'c.is_active'=>1))->where('x.effective_from <=',$end)->group_start()->where('x.effective_to IS NULL',null,false)->or_where('x.effective_to >=',$start)->group_end()->order_by('x.effective_from','DESC')->get()->result(); + $employeeRows=$db->select("x.id source_id,'employee' source_type,x.component_id,x.calculation_method resolved_method,x.amount_override resolved_value,x.formula_expression resolved_formula,c.code,c.name,c.component_type,c.calculation_method component_method,c.default_value,c.fixed_amount,c.formula_expression component_formula,c.expense_account_id,c.liability_account_id")->from('employee_payroll_components x')->join('payroll_components c','c.id=x.component_id AND c.company_id=x.company_id')->where(array('x.company_id'=>$companyId,'x.employee_id'=>$employee->id,'x.is_active'=>1,'c.is_active'=>1))->where('x.effective_from <=',$end)->group_start()->where('x.effective_to IS NULL',null,false)->or_where('x.effective_to >=',$start)->group_end()->order_by('x.effective_from','DESC')->get()->result(); + $resolved=array();foreach($position as$row)if(!isset($resolved[$row->component_id]))$resolved[$row->component_id]=$row;foreach($employeeRows as$row)if(!isset($resolved[$row->component_id])||$resolved[$row->component_id]->source_type!=='employee')$resolved[$row->component_id]=$row; + foreach($resolved as$row){$row->resolved_method=$row->resolved_method?:$row->component_method?:'fixed';if($row->resolved_value===null)$row->resolved_value=$row->default_value!==null?$row->default_value:$row->fixed_amount;if(!$row->resolved_formula)$row->resolved_formula=$row->component_formula;}return array_values($resolved); + } + + private function assignmentAmount($assignment,$vars) + { + $method=$assignment->resolved_method?:'fixed';$value=max(0,(float)$assignment->resolved_value); + if($method==='per_present_day')return round($value*($vars['present_days']??0),2);if($method==='per_absent_day')return round($value*($vars['unpaid_days_total']??0),2);if($method==='per_minute')return round($value*($vars['late_minutes']??0),2);if($method==='per_hour')return round($value*($vars['overtime_hours']??0),2);if($method==='percentage_basic')return round(($vars['basic_salary']??0)*$value/100,2);if($method==='formula')return max(0,$this->CI->formulaservice->calculate($assignment->resolved_formula,$vars));return round($value,2); + } + + private function attendanceRuleLines($start,$end,$companyId,$vars) + { + $rows=$this->CI->db->select('r.*,c.code,c.name,c.component_type,c.expense_account_id,c.liability_account_id')->from('payroll_attendance_rules r')->join('payroll_components c','c.id=r.component_id AND c.company_id=r.company_id')->where(array('r.company_id'=>$companyId,'r.is_active'=>1,'c.is_active'=>1))->where('r.effective_from <=',$end)->group_start()->where('r.effective_to IS NULL',null,false)->or_where('r.effective_to >=',$start)->group_end()->order_by('r.effective_from','DESC')->get()->result();$seen=array();$out=array(); + foreach($rows as$rule){if(isset($seen[$rule->condition_type]))continue;$seen[$rule->condition_type]=true;if($rule->calculation_method==='none')continue;$quantity=$this->conditionQuantity($rule,$vars);if($quantity<=0)continue;$value=max(0,(float)$rule->rate_value);$method=$rule->calculation_method;if($method==='fixed')$amount=$value;elseif($method==='per_minute')$amount=$value*$quantity;elseif($method==='per_hour')$amount=$value*($quantity/60);elseif($method==='per_day')$amount=$value*$quantity;elseif($method==='percentage_daily')$amount=$vars['daily_rate']*($value/100)*$quantity;elseif($method==='formula')$amount=$this->CI->formulaservice->calculate($rule->formula_expression,array_merge($vars,array('condition_quantity'=>$quantity,'rule_rate'=>$value)));else$amount=0;if($rule->maximum_amount!==null)$amount=min($amount,(float)$rule->maximum_amount);$component=(object)array('id'=>$rule->component_id,'code'=>$rule->code,'name'=>$rule->name,'component_type'=>'deduction','expense_account_id'=>$rule->expense_account_id,'liability_account_id'=>$rule->liability_account_id,'resolved_method'=>$method,'resolved_value'=>$value,'resolved_formula'=>$rule->formula_expression);$out[]=array('component'=>$component,'amount'=>round(max(0,$amount),2),'snapshot'=>array('source_type'=>'attendance_rule','source_id'=>(int)$rule->id,'condition'=>$rule->condition_type,'quantity'=>$quantity,'tolerance'=>(int)$rule->tolerance_minutes,'method'=>$method,'rate'=>$value));}return$out; + } + + private function conditionQuantity($rule,$vars) + { + if($rule->condition_type==='late')return max(0,$vars['late_minutes']-(int)$rule->tolerance_minutes);if($rule->condition_type==='early_leave')return max(0,$vars['early_leave_minutes']-(int)$rule->tolerance_minutes);if($rule->condition_type==='alpha')return$vars['alpha_days'];if($rule->condition_type==='unpaid')return$vars['unpaid_days'];if($rule->condition_type==='missing_checkin')return$vars['missing_checkin'];if($rule->condition_type==='missing_checkout')return$vars['missing_checkout'];return 0; + } + + private function mergeLine(&$lines,$component,$amount,$source,$ruleSnapshot=null) + { + if($amount<0)throw new BusinessException('Nominal komponen payroll tidak boleh negatif.');$key=$component->code; + if(!isset($lines[$key]))$lines[$key]=array('component_id'=>(int)(isset($component->id)?$component->id:$component->component_id),'component_code'=>$component->code,'component_name'=>$component->name,'component_type'=>$component->component_type,'calculation_method'=>$component->resolved_method?:'fixed','amount'=>0,'rate'=>isset($component->resolved_value)?$component->resolved_value:null,'quantity'=>null,'formula_snapshot'=>isset($component->resolved_formula)?$component->resolved_formula:null,'expense_account_id'=>$component->expense_account_id,'liability_account_id'=>$component->liability_account_id,'sources'=>array(),'rule_snapshot'=>array());$lines[$key]['amount']+=(float)$amount;$lines[$key]['sources'][]=$source;if($ruleSnapshot)$lines[$key]['rule_snapshot'][]=$ruleSnapshot; + } + + public function workflow($id,$action,$uid,$companyId,$canManageAll) + { + $db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);$before=$period; + if($action==='revert'){if(!in_array($period->workflow_status,array('reviewed','approved'),true))throw new BusinessException('Hanya payroll Reviewed atau Approved yang dapat dikembalikan ke Draft.');$update=array('workflow_status'=>'draft','reviewed_by'=>null,'reviewed_at'=>null,'approved_by'=>null,'approved_at'=>null,'updated_at'=>date('Y-m-d H:i:s'));} + else{$map=array('review'=>array('draft','reviewed'),'approve'=>array('reviewed','approved'),'finalize'=>array('approved','final'));if(!isset($map[$action])||$period->workflow_status!==$map[$action][0])throw new BusinessException('Tahap payroll tidak sesuai. Muat ulang halaman dan periksa status terbaru.');if(in_array($action,array('approve','finalize'),true)&&$this->hasRestrictedPayroll($id)&&!$canManageAll)throw new BusinessException('Periode memuat gaji terbatas dan memerlukan permission Manage All Gaji.');$settings=$db->get_where('payroll_settings',array('company_id'=>$companyId))->row();$separation=!$settings||(int)$settings->separation_of_duties===1;if($separation&&$action==='approve'&&(int)$period->reviewed_by===(int)$uid)throw new BusinessException('Reviewer dan approver harus pengguna yang berbeda.');if($separation&&$action==='finalize'&&((int)$period->reviewed_by===(int)$uid||(int)$period->approved_by===(int)$uid))throw new BusinessException('Finalizer harus berbeda dari reviewer dan approver.');$who=$action==='review'?'reviewed_by':($action==='approve'?'approved_by':'finalized_by');$when=$action==='review'?'reviewed_at':($action==='approve'?'approved_at':'finalized_at');$update=array('workflow_status'=>$map[$action][1],$who=>$uid,$when=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s'));} + $db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',$update);$this->CI->auditservice->record('payroll','payroll_period',$id,$action,$before,$update,$uid);if($db->trans_status()===false)throw new RuntimeException('Workflow payroll gagal disimpan.');$db->trans_commit(); + }catch(Throwable$e){$db->trans_rollback();throw$e;} + } + + public function post($id,$date,$uid,$companyId,$canManageAll) + { + $db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);if($period->workflow_status!=='final'||$period->journal_id)throw new BusinessException('Payroll belum Final atau sudah diposting.');if($this->hasRestrictedPayroll($id)&&!$canManageAll)throw new BusinessException('Posting payroll dengan gaji terbatas memerlukan permission Manage All Gaji.');$this->CI->fiscalperiodservice->assertOpen($date); + $rows=$db->select('l.component_code,l.component_name,l.component_type,l.expense_account_id,l.liability_account_id,SUM(l.amount) amount',false)->from('payroll_calculation_lines l')->join('k_payrolls x','x.id=l.payroll_id')->where(array('x.payroll_period_id'=>$id,'x.company_id'=>$companyId))->group_by(array('l.component_code','l.component_name','l.component_type','l.expense_account_id','l.liability_account_id'))->get()->result();$net=(float)$db->select('COALESCE(SUM(total_salary),0) total',false)->get_where('k_payrolls',array('payroll_period_id'=>$id,'company_id'=>$companyId))->row()->total;$journal=array();$missing=array(); + foreach($rows as$row){try{$expense=$row->expense_account_id?(int)$row->expense_account_id:(int)$this->CI->accountmappingservice->get('payroll_expense');}catch(Throwable$e){$expense=0;}try{$liability=$row->liability_account_id?(int)$row->liability_account_id:(int)$this->CI->accountmappingservice->get($row->component_type==='deduction'?'payroll_tax_payable':'payroll_payable');}catch(Throwable$e){$liability=0;}if(in_array($row->component_type,array('earning','employer_cost'),true)&&!$expense)$missing[]=$row->component_name.' (akun beban)';if(in_array($row->component_type,array('deduction','employer_cost'),true)&&!$liability)$missing[]=$row->component_name.' (akun kewajiban)';if($row->component_type==='earning')$this->addJournal($journal,$expense,(float)$row->amount,0);elseif($row->component_type==='deduction')$this->addJournal($journal,$liability,0,(float)$row->amount);else{$this->addJournal($journal,$expense,(float)$row->amount,0);$this->addJournal($journal,$liability,0,(float)$row->amount);}} + try{$payable=(int)$this->CI->accountmappingservice->get('payroll_payable');}catch(Throwable$e){$payable=0;}if(!$payable)$missing[]='Hutang Gaji (payroll_payable)';else$this->addJournal($journal,$payable,0,$net);if($missing)throw new BusinessException('Mapping akun payroll belum lengkap: '.implode(', ',array_unique($missing)).'.');$debit=0;$credit=0;foreach($journal as$line){$debit+=$line['debit'];$credit+=$line['kredit'];}if(abs($debit-$credit)>.01)throw new BusinessException('Jurnal payroll tidak balance. Periksa mapping komponen.');$count=$db->where(array('payroll_period_id'=>$id,'company_id'=>$companyId))->count_all_results('k_payrolls');$journalId=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'PAYROLL-'.$period->id,'keterangan'=>'Payroll '.$period->period_name.' untuk '.$count.' karyawan','ref_type'=>'payroll','ref_id'=>$id,'created_by'=>$uid),array_values($journal),false,true);$db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',array('workflow_status'=>'posted','journal_id'=>$journalId,'updated_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('payroll','payroll_period',$id,'post',null,array('journal_id'=>$journalId),$uid);$db->trans_commit();return$journalId; + }catch(Throwable$e){$db->trans_rollback();throw$e;} + } + + private function addJournal(&$lines,$accountId,$debit,$credit){if(!$accountId)return;if(!isset($lines[$accountId]))$lines[$accountId]=array('account_id'=>$accountId,'debit'=>0,'kredit'=>0);$lines[$accountId]['debit']+=round($debit,2);$lines[$accountId]['kredit']+=round($credit,2);} + + public function pay($id,$date,$cashAccountId,$uid,$companyId,$canManageAll) + { + $db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);if($period->workflow_status!=='posted')throw new BusinessException('Payroll belum diposting atau sudah dibayar.');if($this->hasRestrictedPayroll($id)&&!$canManageAll)throw new BusinessException('Pembayaran payroll dengan gaji terbatas memerlukan permission Manage All Gaji.');if($db->get_where('payroll_payments',array('company_id'=>$companyId,'payroll_period_id'=>$id))->row())throw new BusinessException('Pembayaran payroll sudah tersedia.');$cashQuery=$db->where(array('id'=>$cashAccountId,'is_active'=>1));if($db->field_exists('company_id','cash_accounts'))$cashQuery->where('company_id',$companyId);$cash=$cashQuery->get('cash_accounts')->row();if(!$cash)throw new BusinessException('Rekening pembayaran aktif tidak ditemukan.');$this->CI->fiscalperiodservice->assertOpen($date);$net=(float)$db->select('COALESCE(SUM(total_salary),0) total',false)->get_where('k_payrolls',array('payroll_period_id'=>$id,'company_id'=>$companyId))->row()->total;if($net<=0)throw new BusinessException('Total pembayaran payroll tidak valid.');$number='PRP-'.$companyId.'-'.date('YmdHis');$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$number,'keterangan'=>'Pembayaran payroll '.$period->period_name.' melalui '.$cash->name,'ref_type'=>'payroll_payment','ref_id'=>$id,'created_by'=>$uid),array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>$net,'kredit'=>0),array('account_id'=>$cash->gl_account_id,'debit'=>0,'kredit'=>$net)),false,true);$db->insert('payroll_payments',array('company_id'=>$companyId,'payroll_period_id'=>$id,'payment_no'=>$number,'payment_date'=>$date,'cash_account_id'=>$cashAccountId,'total_amount'=>$net,'status'=>'posted','journal_id'=>$journal,'created_by'=>$uid,'approved_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s')));$paymentId=(int)$db->insert_id();$db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',array('workflow_status'=>'paid','payment_transaction_id'=>$paymentId,'updated_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('payroll','payroll_period',$id,'pay',null,array('payment_id'=>$paymentId,'journal_id'=>$journal),$uid);$db->trans_commit();return$journal; + }catch(Throwable$e){$db->trans_rollback();throw$e;} + } + + private function hasRestrictedPayroll($periodId){return$this->CI->db->from('k_payrolls p')->join('k_employees e','e.id=p.employee_id')->where('p.payroll_period_id',(int)$periodId)->where('e.salary_restricted',1)->count_all_results()>0;} } diff --git a/application/libraries/UserSessionService.php b/application/libraries/UserSessionService.php new file mode 100644 index 0000000..053d7eb --- /dev/null +++ b/application/libraries/UserSessionService.php @@ -0,0 +1,158 @@ +CI=&get_instance(); } + + public function begin($user) + { + $hours=$this->duration($user->session_duration_hours??24); + $limit=$this->deviceLimit($user->max_active_devices??1); + $loginAt=date('Y-m-d H:i:s'); + $expiresAt=date('Y-m-d H:i:s',time()+$hours*3600); + $result=array('allowed'=>true,'login_key'=>bin2hex(random_bytes(32)),'login_at'=>$loginAt,'expires_at'=>$expiresAt,'duration_hours'=>$hours,'device_limit'=>$limit); + if(!$this->ready())return$result; + + $db=$this->CI->db;$db->trans_begin(); + try{ + $db->query('SELECT id FROM users WHERE id=? FOR UPDATE',array((int)$user->id)); + $this->expireStale((int)$user->id); + $active=(int)$db->where(array('user_id'=>(int)$user->id,'status'=>'active'))->where('expires_at >',date('Y-m-d H:i:s'))->count_all_results('user_login_sessions'); + if($active>=$limit){ + $this->attempt((int)$user->id,(string)$user->username,'device_limit','Batas perangkat aktif telah tercapai.',$active,$limit,null); + if(!$db->trans_status())throw new RuntimeException('Log pembatasan perangkat gagal disimpan.'); + $db->trans_commit(); + return array('allowed'=>false,'active_count'=>$active,'device_limit'=>$limit,'message'=>'Akun ini sudah aktif pada '.$active.' perangkat (batas '.$limit.'). Akhiri salah satu sesi aktif melalui menu Profil sebelum login di perangkat lain.'); + } + $device=$this->device(); + $db->insert('user_login_sessions',array( + 'user_id'=>(int)$user->id,'login_key'=>$result['login_key'],'session_id_hash'=>$this->sessionHash(), + 'login_at'=>$loginAt,'expires_at'=>$expiresAt,'last_seen_at'=>$loginAt,'ip_address'=>$device['ip'], + 'user_agent'=>$device['agent'],'device_type'=>$device['type'],'platform'=>$device['platform'], + 'browser'=>$device['browser'],'status'=>'active' + )); + $sessionId=(int)$db->insert_id(); + $this->attempt((int)$user->id,(string)$user->username,'success','Login berhasil.',$active+1,$limit,$sessionId); + if(!$db->trans_status())throw new RuntimeException('Session login gagal dicatat.'); + $db->trans_commit();return$result; + }catch(Throwable$e){$db->trans_rollback();throw$e;} + } + + public function recordInvalid($user,$username) + { + if(!$this->attemptsReady())return; + $id=$user?(int)$user->id:null;$limit=$user?$this->deviceLimit($user->max_active_devices??1):1; + $this->attempt($id,substr((string)$username,0,100),'invalid_credentials','Username atau password tidak valid.',0,$limit,null); + } + + public function validateCurrent() + { + $userId=(int)$this->CI->session->userdata('user_id');if($userId<1)return array('valid'=>false,'reason'=>'invalid'); + $expires=(string)$this->CI->session->userdata('auth_expires_at'); + if($expires!==''&&strtotime($expires)<=time()){ $this->endCurrent('expired','Masa login 24 jam atau sesuai pengaturan telah berakhir.');return array('valid'=>false,'reason'=>'expired'); } + + if(!$this->ready()){ + if($expires===''){ + $loginAt=date('Y-m-d H:i:s');$expires=date('Y-m-d H:i:s',time()+86400); + $this->CI->session->set_userdata(array('auth_started_at'=>$loginAt,'auth_expires_at'=>$expires)); + } + return array('valid'=>true); + } + + $loginKey=(string)$this->CI->session->userdata('auth_login_key'); + if($loginKey==='')return$this->adoptExisting($userId); + $row=$this->CI->db->where(array('user_id'=>$userId,'login_key'=>$loginKey))->get('user_login_sessions')->row(); + if(!$row||$row->status!=='active')return array('valid'=>false,'reason'=>'revoked'); + if(strtotime($row->expires_at)<=time()){ + $this->CI->db->where('id',$row->id)->update('user_login_sessions',array('status'=>'expired','ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>'Batas waktu login tercapai.')); + return array('valid'=>false,'reason'=>'expired'); + } + $device=$this->device(); + $this->CI->db->where(array('id'=>$row->id,'status'=>'active'))->update('user_login_sessions',array('session_id_hash'=>$this->sessionHash(),'last_seen_at'=>date('Y-m-d H:i:s'),'ip_address'=>$device['ip'])); + $this->CI->session->set_userdata(array('auth_started_at'=>$row->login_at,'auth_expires_at'=>$row->expires_at)); + return array('valid'=>true,'row'=>$row); + } + + private function adoptExisting($userId) + { + $user=$this->CI->db->get_where('users',array('id'=>$userId))->row();if(!$user)return array('valid'=>false,'reason'=>'invalid'); + $created=$this->begin($user);if(!$created['allowed'])return array('valid'=>false,'reason'=>'device_limit'); + $this->CI->session->set_userdata(array('auth_login_key'=>$created['login_key'],'auth_started_at'=>$created['login_at'],'auth_expires_at'=>$created['expires_at'])); + return array('valid'=>true,'adopted'=>true); + } + + public function endCurrent($status='logged_out',$reason='Logout oleh pengguna.') + { + if(!$this->ready())return; + $key=(string)$this->CI->session->userdata('auth_login_key');$user=(int)$this->CI->session->userdata('user_id');if($key===''||$user<1)return; + $this->CI->db->where(array('user_id'=>$user,'login_key'=>$key,'status'=>'active'))->update('user_login_sessions',array('status'=>$status,'ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>substr($reason,0,255))); + } + + public function activeSessions($userId) + { + if(!$this->ready())return array();$this->expireStale((int)$userId);$current=(string)$this->CI->session->userdata('auth_login_key'); + $rows=$this->CI->db->where('user_id',(int)$userId)->where_in('status',array('active','logged_out','expired','revoked'))->order_by("status='active'",'DESC',false)->order_by('last_seen_at','DESC')->limit(100)->get('user_login_sessions')->result(); + foreach($rows as$row)$row->is_current=hash_equals((string)$row->login_key,$current); + return$rows; + } + + public function attempts($userId) + { + if(!$this->attemptsReady())return array();return$this->CI->db->where('user_id',(int)$userId)->order_by('attempted_at','DESC')->limit(100)->get('user_login_attempts')->result(); + } + + public function revoke($id,$userId) + { + if(!$this->ready())throw new RuntimeException('Migration keamanan session belum dijalankan.'); + $row=$this->CI->db->where(array('id'=>(int)$id,'user_id'=>(int)$userId))->get('user_login_sessions')->row(); + if(!$row)throw new RuntimeException('Session perangkat tidak ditemukan.'); + if($row->status==='active')$this->CI->db->where('id',$row->id)->update('user_login_sessions',array('status'=>'revoked','ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>'Diakhiri dari pengaturan perangkat.')); + $this->deleteNativeSession($row->session_id_hash); + return(array('current'=>hash_equals((string)$row->login_key,(string)$this->CI->session->userdata('auth_login_key')))); + } + + public function revokeOthers($userId) + { + if(!$this->ready())return;$current=(string)$this->CI->session->userdata('auth_login_key'); + $rows=$this->CI->db->where(array('user_id'=>(int)$userId,'status'=>'active'))->where('login_key !=',$current)->get('user_login_sessions')->result(); + foreach($rows as$row){$this->CI->db->where('id',$row->id)->update('user_login_sessions',array('status'=>'revoked','ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>'Password akun diubah.'));$this->deleteNativeSession($row->session_id_hash);} + } + + public function activeCount($userId) + { + if(!$this->ready())return 1;$this->expireStale((int)$userId);return(int)$this->CI->db->where(array('user_id'=>(int)$userId,'status'=>'active'))->where('expires_at >',date('Y-m-d H:i:s'))->count_all_results('user_login_sessions'); + } + + private function expireStale($userId) + { + if(!$this->ready())return;$this->CI->db->where(array('user_id'=>(int)$userId,'status'=>'active'))->where('expires_at <=',date('Y-m-d H:i:s'))->update('user_login_sessions',array('status'=>'expired','ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>'Batas waktu login tercapai.')); + } + + private function attempt($userId,$username,$result,$reason,$active,$limit,$sessionId) + { + if(!$this->attemptsReady())return;$device=$this->device();$this->CI->db->insert('user_login_attempts',array('user_id'=>$userId?:null,'username'=>substr($username,0,100),'attempted_at'=>date('Y-m-d H:i:s'),'ip_address'=>$device['ip'],'user_agent'=>$device['agent'],'device_type'=>$device['type'],'platform'=>$device['platform'],'browser'=>$device['browser'],'result'=>$result,'reason'=>substr($reason,0,255),'active_session_count'=>max(0,(int)$active),'device_limit'=>$this->deviceLimit($limit),'login_session_id'=>$sessionId)); + } + + private function deleteNativeSession($hash) + { + if($hash===''||!$this->CI->db->table_exists('ci_sessions'))return;$this->CI->db->query('DELETE FROM ci_sessions WHERE SHA2(id,256)=?',array($hash)); + } + + private function sessionHash(){return hash('sha256',(string)session_id());} + private function duration($hours){$hours=(int)$hours;return in_array($hours,$this->durations,true)?$hours:24;} + private function deviceLimit($limit){return max(1,min(10,(int)$limit));} + private function ready(){return$this->CI->db->table_exists('user_login_sessions')&&$this->CI->db->field_exists('session_duration_hours','users')&&$this->CI->db->field_exists('max_active_devices','users');} + private function attemptsReady(){return$this->CI->db->table_exists('user_login_attempts');} + private function device() + { + $agent=substr((string)$this->CI->input->user_agent(),0,512);$browser='Browser lain';$platform='Perangkat lain';$type='Desktop'; + foreach(array('Edg/'=>'Microsoft Edge','OPR/'=>'Opera','Chrome/'=>'Google Chrome','Firefox/'=>'Mozilla Firefox','Safari/'=>'Safari')as$needle=>$label)if(stripos($agent,$needle)!==false){$browser=$label;break;} + foreach(array('Windows'=>'Windows','Android'=>'Android','iPhone'=>'iOS','iPad'=>'iPadOS','Macintosh'=>'macOS','Linux'=>'Linux')as$needle=>$label)if(stripos($agent,$needle)!==false){$platform=$label;break;} + if(preg_match('/iPad|Tablet/i',$agent))$type='Tablet';elseif(preg_match('/Mobile|Android|iPhone/i',$agent))$type='Mobile'; + return array('ip'=>substr((string)$this->CI->input->ip_address(),0,45),'agent'=>$agent,'browser'=>$browser,'platform'=>$platform,'type'=>$type); + } +} diff --git a/application/migrations/20260912000100_add_user_profile_and_login_security.php b/application/migrations/20260912000100_add_user_profile_and_login_security.php new file mode 100644 index 0000000..bc4c1c9 --- /dev/null +++ b/application/migrations/20260912000100_add_user_profile_and_login_security.php @@ -0,0 +1,69 @@ +array('type'=>'VARCHAR','constraint'=>150,'null'=>true,'after'=>'nama'), + 'profile_photo'=>array('type'=>'VARCHAR','constraint'=>255,'null'=>true,'after'=>'email'), + 'session_duration_hours'=>array('type'=>'SMALLINT','constraint'=>5,'unsigned'=>true,'default'=>24,'after'=>'profile_photo'), + 'max_active_devices'=>array('type'=>'TINYINT','constraint'=>3,'unsigned'=>true,'default'=>1,'after'=>'session_duration_hours'), + 'profile_updated_at'=>array('type'=>'DATETIME','null'=>true,'after'=>'max_active_devices'), + ); + foreach($columns as$name=>$definition)if(!$this->db->field_exists($name,'users'))$this->dbforge->add_column('users',array($name=>$definition)); + + if(!$this->db->table_exists('user_login_sessions')){ + $this->db->query("CREATE TABLE `user_login_sessions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `login_key` CHAR(64) NOT NULL, + `session_id_hash` CHAR(64) NOT NULL, + `login_at` DATETIME NOT NULL, + `expires_at` DATETIME NOT NULL, + `last_seen_at` DATETIME NOT NULL, + `ip_address` VARCHAR(45) NULL, + `user_agent` VARCHAR(512) NULL, + `device_type` VARCHAR(30) NULL, + `platform` VARCHAR(80) NULL, + `browser` VARCHAR(80) NULL, + `status` ENUM('active','logged_out','expired','revoked') NOT NULL DEFAULT 'active', + `ended_at` DATETIME NULL, + `end_reason` VARCHAR(255) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_user_login_key` (`login_key`), + KEY `idx_user_login_active` (`user_id`,`status`,`expires_at`), + KEY `idx_user_session_hash` (`session_id_hash`), + KEY `idx_user_last_seen` (`user_id`,`last_seen_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + } + + if(!$this->db->table_exists('user_login_attempts')){ + $this->db->query("CREATE TABLE `user_login_attempts` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT NULL, + `username` VARCHAR(100) NULL, + `attempted_at` DATETIME NOT NULL, + `ip_address` VARCHAR(45) NULL, + `user_agent` VARCHAR(512) NULL, + `device_type` VARCHAR(30) NULL, + `platform` VARCHAR(80) NULL, + `browser` VARCHAR(80) NULL, + `result` ENUM('success','invalid_credentials','device_limit') NOT NULL, + `reason` VARCHAR(255) NULL, + `active_session_count` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `device_limit` SMALLINT UNSIGNED NOT NULL DEFAULT 1, + `login_session_id` BIGINT UNSIGNED NULL, + PRIMARY KEY (`id`), + KEY `idx_login_attempt_user_time` (`user_id`,`attempted_at`), + KEY `idx_login_attempt_result_time` (`result`,`attempted_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + } + } + + public function down() + { + throw new RuntimeException('Riwayat keamanan login dan data profil dipertahankan.'); + } +} diff --git a/application/migrations/20260912000200_finalize_leave_request_workflow.php b/application/migrations/20260912000200_finalize_leave_request_workflow.php new file mode 100644 index 0000000..d011838 --- /dev/null +++ b/application/migrations/20260912000200_finalize_leave_request_workflow.php @@ -0,0 +1,80 @@ +db->field_exists($name, 'k_leave_requests')) { + $this->load->dbforge(); + $this->dbforge->add_column('k_leave_requests', array($name => $definition)); + } + } + + private function hasIndex($name) + { + $row = $this->db->query( + "SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='k_leave_requests' AND index_name=? LIMIT 1", + array($name) + )->row(); + return (bool) $row; + } + + public function up() + { + if (!$this->db->table_exists('k_leave_requests')) { + return; + } + + $this->addColumn('company_id', array('type'=>'BIGINT','constraint'=>20,'unsigned'=>true,'null'=>true,'after'=>'id')); + $this->addColumn('request_no', array('type'=>'VARCHAR','constraint'=>40,'null'=>true,'after'=>'company_id')); + $this->addColumn('workflow_status', array('type'=>'ENUM','constraint'=>array('draft','submitted','approved','rejected','cancelled'),'default'=>'draft','after'=>'approval_status')); + $this->addColumn('approval_request_id', array('type'=>'BIGINT','constraint'=>20,'unsigned'=>true,'null'=>true,'after'=>'workflow_status')); + $this->addColumn('created_by', array('type'=>'INT','constraint'=>11,'null'=>true,'after'=>'approval_request_id')); + $this->addColumn('submitted_by', array('type'=>'INT','constraint'=>11,'null'=>true,'after'=>'created_by')); + $this->addColumn('submitted_at', array('type'=>'DATETIME','null'=>true,'after'=>'submitted_by')); + $this->addColumn('rejected_by', array('type'=>'INT','constraint'=>11,'null'=>true,'after'=>'approved_at')); + $this->addColumn('rejected_at', array('type'=>'DATETIME','null'=>true,'after'=>'rejected_by')); + $this->addColumn('rejection_reason', array('type'=>'TEXT','null'=>true,'after'=>'rejected_at')); + $this->addColumn('cancelled_by', array('type'=>'INT','constraint'=>11,'null'=>true,'after'=>'rejection_reason')); + $this->addColumn('cancelled_at', array('type'=>'DATETIME','null'=>true,'after'=>'cancelled_by')); + $this->addColumn('updated_by', array('type'=>'INT','constraint'=>11,'null'=>true,'after'=>'cancelled_at')); + $this->addColumn('updated_at', array('type'=>'DATETIME','null'=>true,'after'=>'updated_by')); + $this->addColumn('version', array('type'=>'INT','constraint'=>11,'default'=>1,'after'=>'updated_at')); + + $this->db->query("UPDATE k_leave_requests lr JOIN k_employees e ON e.id=lr.employee_id SET lr.company_id=e.company_id WHERE lr.company_id IS NULL"); + $this->db->query("UPDATE k_leave_requests SET request_no=CONCAT('LV-',DATE_FORMAT(COALESCE(created_at,NOW()),'%Y%m%d'),'-',LPAD(id,6,'0')) WHERE request_no IS NULL OR request_no='' "); + $this->db->query("UPDATE k_leave_requests SET workflow_status=CASE approval_status WHEN 'approved' THEN 'approved' WHEN 'rejected' THEN 'rejected' ELSE 'draft' END"); + $this->db->query("ALTER TABLE k_leave_requests MODIFY request_no VARCHAR(40) NOT NULL"); + + if (!$this->hasIndex('uq_leave_company_no')) { + $this->db->query('CREATE UNIQUE INDEX uq_leave_company_no ON k_leave_requests(company_id,request_no)'); + } + if (!$this->hasIndex('idx_leave_company_workflow')) { + $this->db->query('CREATE INDEX idx_leave_company_workflow ON k_leave_requests(company_id,workflow_status,start_date)'); + } + + if ($this->db->table_exists('approval_workflows') && !$this->db->where('code','LEAVE_REQUEST_DEFAULT')->count_all_results('approval_workflows')) { + $role = $this->db->where('nama_role','HR & Payroll')->get('roles')->row(); + if (!$role) { + $role = $this->db->where('nama_role','Admin')->get('roles')->row(); + } + $this->db->insert('approval_workflows', array( + 'code'=>'LEAVE_REQUEST_DEFAULT', 'module'=>'hr', 'entity_type'=>'leave_request', + 'name'=>'Persetujuan Cuti & Izin', 'require_separation'=>1, + 'is_active'=>1, 'created_at'=>date('Y-m-d H:i:s') + )); + $workflowId = (int) $this->db->insert_id(); + $this->db->insert('approval_workflow_steps', array( + 'workflow_id'=>$workflowId, 'step_order'=>1, 'name'=>'Persetujuan HR', + 'approver_role_id'=>$role ? (int)$role->id : null, 'approver_user_id'=>null + )); + } + } + + public function down() + { + throw new RuntimeException('Workflow cuti dipertahankan untuk menjaga histori persetujuan.'); + } +} diff --git a/application/migrations/20260912000300_grant_hr_leave_approval_access.php b/application/migrations/20260912000300_grant_hr_leave_approval_access.php new file mode 100644 index 0000000..a119373 --- /dev/null +++ b/application/migrations/20260912000300_grant_hr_leave_approval_access.php @@ -0,0 +1,24 @@ +db->table_exists('roles'))return; + $role=$this->db->where('nama_role','HR & Payroll')->get('roles')->row(); + if(!$role)return; + $permissions=json_decode((string)$role->permissions,true); + if(!is_array($permissions))$permissions=array(); + $permissions['approvals']=array_values(array_unique(array_merge((array)($permissions['approvals']??array()),array('can_view','can_approve')))); + $update=array('permissions'=>json_encode($permissions)); + if($this->db->field_exists('updated_at','roles'))$update['updated_at']=date('Y-m-d H:i:s'); + $this->db->where('id',$role->id)->update('roles',$update); + } + + public function down() + { + throw new RuntimeException('Hak akses approval HR dipertahankan agar workflow cuti tetap dapat diproses.'); + } +} diff --git a/application/migrations/20260912000400_finalize_hr_permissions_and_manual_attendance.php b/application/migrations/20260912000400_finalize_hr_permissions_and_manual_attendance.php new file mode 100644 index 0000000..4198cea --- /dev/null +++ b/application/migrations/20260912000400_finalize_hr_permissions_and_manual_attendance.php @@ -0,0 +1,87 @@ +db->field_exists('salary_restricted', 'k_employees')) { + $this->db->query("ALTER TABLE `k_employees` ADD `salary_restricted` TINYINT(1) NOT NULL DEFAULT 0 AFTER `basic_salary`"); + } + + $this->db->query("CREATE TABLE IF NOT EXISTS `manual_attendance_requests` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `company_id` BIGINT UNSIGNED NOT NULL, + `request_no` VARCHAR(50) NOT NULL, + `employee_id` BIGINT NOT NULL, + `attendance_date` DATE NOT NULL, + `attendance_time` TIME NOT NULL, + `attendance_action` ENUM('check_in','check_out') NOT NULL, + `reason` TEXT NOT NULL, + `status` ENUM('draft','submitted','approved','rejected','cancelled') NOT NULL DEFAULT 'draft', + `approval_request_id` BIGINT UNSIGNED NULL, + `created_by` INT NOT NULL, + `submitted_by` INT NULL, + `submitted_at` DATETIME NULL, + `approved_by` INT NULL, + `approved_at` DATETIME NULL, + `rejected_by` INT NULL, + `rejected_at` DATETIME NULL, + `rejection_reason` TEXT NULL, + `applied_attendance_id` BIGINT NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_manual_attendance_no` (`company_id`,`request_no`), + KEY `idx_manual_attendance_queue` (`company_id`,`status`,`attendance_date`), + KEY `idx_manual_attendance_employee` (`company_id`,`employee_id`,`attendance_date`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $admin = $this->db->where('nama_role', 'Admin')->get('roles')->row(); + if (!$this->db->where('code', 'MANUAL_ATTENDANCE')->count_all_results('approval_workflows')) { + $this->db->insert('approval_workflows', array( + 'code'=>'MANUAL_ATTENDANCE', 'module'=>'hr', 'entity_type'=>'manual_attendance', + 'name'=>'Persetujuan Absensi Manual', 'require_separation'=>1, 'is_active'=>1, + 'created_at'=>date('Y-m-d H:i:s') + )); + $workflowId = (int) $this->db->insert_id(); + $approver = $this->db->where('nama_role', 'HR & Payroll')->get('roles')->row(); + if (!$approver) $approver = $admin; + if ($approver) { + $this->db->insert('approval_workflow_steps', array( + 'workflow_id'=>$workflowId, 'step_order'=>1, 'name'=>'Persetujuan HR', + 'approver_role_id'=>$approver->id + )); + } + } + + $featureMap = array( + 'hr_organization'=>array('can_view','can_create','can_update','can_delete'), + 'hr_employees'=>array('can_view','can_create','can_update','can_delete','can_export','can_import'), + 'hr_schedules'=>array('can_view','can_create','can_update','can_delete','can_post','can_export'), + 'hr_attendance'=>array('can_view','can_create','can_update','can_submit','can_approve','can_post','can_export'), + 'hr_leave'=>array('can_view','can_create','can_update','can_submit','can_approve','can_export'), + 'payroll'=>array('can_view','can_create','can_update','can_approve','can_post','can_export') + ); + foreach ($this->db->get('roles')->result() as $role) { + if (!empty($role->is_super_admin)) continue; + $permissions = json_decode($role->permissions, true) ?: array(); + $legacy = isset($permissions['employees']) && is_array($permissions['employees']) ? $permissions['employees'] : array(); + if ($legacy) { + foreach ($featureMap as $feature=>$allowed) { + $copy = array_values(array_intersect($legacy, $allowed)); + if ($copy && !isset($permissions[$feature])) $permissions[$feature] = $copy; + } + } + if (in_array($role->nama_role, array('Finance & Treasury','Keuangan'), true) && !isset($permissions['payroll'])) { + $permissions['payroll'] = array('can_view','can_create','can_update','can_post','can_export'); + } + $this->db->where('id', $role->id)->update('roles', array('permissions'=>json_encode($permissions))); + } + } + + public function down() + { + throw new RuntimeException('Gunakan backup untuk rollback finalisasi HR & Payroll.'); + } +} diff --git a/application/migrations/20260913000100_finalize_flexible_payroll.php b/application/migrations/20260913000100_finalize_flexible_payroll.php new file mode 100644 index 0000000..3477eab --- /dev/null +++ b/application/migrations/20260913000100_finalize_flexible_payroll.php @@ -0,0 +1,207 @@ +db->table_exists($table) && !$this->db->field_exists($column, $table)) { + $this->db->query("ALTER TABLE `{$table}` ADD `{$column}` {$definition}"); + } + } + + private function indexExists($table, $index) + { + return (bool) $this->db->query("SHOW INDEX FROM `{$table}` WHERE Key_name=?", array($index))->row(); + } + + private function index($table, $name, $columns, $unique = false) + { + if ($this->db->table_exists($table) && !$this->indexExists($table, $name)) { + $this->db->query('ALTER TABLE `'.$table.'` ADD '.($unique ? 'UNIQUE ' : '').'KEY `'.$name.'` ('.$columns.')'); + } + } + + public function up() + { + foreach (array( + 'attendance_start_date'=>'DATE NULL AFTER `end_date`', + 'attendance_end_date'=>'DATE NULL AFTER `attendance_start_date`', + 'payment_date'=>'DATE NULL AFTER `attendance_end_date`', + 'notes'=>'TEXT NULL AFTER `payment_date`', + 'attendance_locked_by'=>'INT NULL AFTER `notes`', + 'attendance_locked_at'=>'DATETIME NULL AFTER `attendance_locked_by`', + 'calculated_by'=>'INT NULL AFTER `attendance_locked_at`', + 'calculated_at'=>'DATETIME NULL AFTER `calculated_by`', + 'calculation_version'=>'INT NOT NULL DEFAULT 0 AFTER `calculated_at`', + 'updated_at'=>'DATETIME NULL AFTER `created_at`' + ) as $column=>$definition) $this->column('k_payroll_periods', $column, $definition); + + $this->db->query("UPDATE k_payroll_periods SET attendance_start_date=COALESCE(attendance_start_date,start_date), attendance_end_date=COALESCE(attendance_end_date,end_date), payment_date=COALESCE(payment_date,end_date) WHERE attendance_start_date IS NULL OR attendance_end_date IS NULL OR payment_date IS NULL"); + $this->index('k_payroll_periods', 'idx_payroll_period_range', '`company_id`,`attendance_start_date`,`attendance_end_date`,`workflow_status`'); + + foreach (array( + 'period_start_date'=>'DATE NULL AFTER `calculation_snapshot`', + 'period_end_date'=>'DATE NULL AFTER `period_start_date`', + 'structure_version'=>'INT NOT NULL DEFAULT 1 AFTER `period_end_date`' + ) as $column=>$definition) $this->column('k_payrolls', $column, $definition); + $this->db->query("UPDATE k_payrolls x JOIN k_payroll_periods p ON p.id=x.payroll_period_id SET x.period_start_date=COALESCE(x.period_start_date,p.attendance_start_date,p.start_date),x.period_end_date=COALESCE(x.period_end_date,p.attendance_end_date,p.end_date)"); + $this->index('k_payrolls', 'idx_payroll_company_period_employee', '`company_id`,`payroll_period_id`,`employee_id`'); + + foreach (array( + 'company_id'=>'BIGINT UNSIGNED NULL FIRST', + 'calculation_method'=>"VARCHAR(40) NOT NULL DEFAULT 'fixed' AFTER `category`", + 'default_value'=>'DECIMAL(18,2) NULL AFTER `fixed_amount`', + 'effective_from'=>'DATE NULL AFTER `is_active`', + 'effective_to'=>'DATE NULL AFTER `effective_from`', + 'created_by'=>'INT NULL AFTER `effective_to`', + 'created_at'=>'DATETIME NULL AFTER `created_by`', + 'updated_at'=>'DATETIME NULL AFTER `created_at`' + ) as $column=>$definition) $this->column('payroll_components', $column, $definition); + $firstCompany = $this->db->select_min('id', 'id')->get('companies')->row(); + $firstCompanyId = $firstCompany ? (int) $firstCompany->id : 1; + $this->db->query("UPDATE payroll_components SET company_id=? WHERE company_id IS NULL", array($firstCompanyId)); + if ($this->indexExists('payroll_components', 'uq_payroll_component')) { + $this->db->query('ALTER TABLE payroll_components DROP INDEX uq_payroll_component'); + } + $this->index('payroll_components', 'uq_payroll_component_company', '`company_id`,`code`', true); + $this->index('payroll_components', 'idx_payroll_component_effective', '`company_id`,`is_active`,`effective_from`,`effective_to`'); + $this->db->query("UPDATE payroll_components SET default_value=COALESCE(default_value,fixed_amount),created_at=COALESCE(created_at,NOW())"); + + foreach (array( + 'company_id'=>'BIGINT UNSIGNED NULL FIRST', + 'calculation_method'=>"VARCHAR(40) NOT NULL DEFAULT 'fixed' AFTER `component_id`", + 'formula_expression'=>'VARCHAR(500) NULL AFTER `amount_override`', + 'notes'=>'TEXT NULL AFTER `is_active`', + 'created_by'=>'INT NULL AFTER `notes`', + 'created_at'=>'DATETIME NULL AFTER `created_by`', + 'updated_at'=>'DATETIME NULL AFTER `created_at`' + ) as $column=>$definition) $this->column('employee_payroll_components', $column, $definition); + $this->db->query("UPDATE employee_payroll_components x JOIN k_employees e ON e.id=x.employee_id SET x.company_id=e.company_id,x.created_at=COALESCE(x.created_at,NOW()) WHERE x.company_id IS NULL OR x.created_at IS NULL"); + $this->index('employee_payroll_components', 'idx_employee_component_scope', '`company_id`,`employee_id`,`component_id`,`is_active`'); + $this->index('employee_payroll_components', 'idx_employee_component_effective', '`company_id`,`effective_from`,`effective_to`'); + + $this->db->query("CREATE TABLE IF NOT EXISTS `position_payroll_components` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `company_id` BIGINT UNSIGNED NOT NULL, + `position_id` BIGINT NOT NULL, + `component_id` BIGINT UNSIGNED NOT NULL, + `calculation_method` VARCHAR(40) NOT NULL DEFAULT 'fixed', + `amount_override` DECIMAL(18,2) NULL, + `formula_expression` VARCHAR(500) NULL, + `effective_from` DATE NOT NULL, + `effective_to` DATE NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `notes` TEXT NULL, + `created_by` INT NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_position_component_version` (`company_id`,`position_id`,`component_id`,`effective_from`), + KEY `idx_position_component_effective` (`company_id`,`position_id`,`is_active`,`effective_from`,`effective_to`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $this->db->query("CREATE TABLE IF NOT EXISTS `payroll_attendance_rules` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `company_id` BIGINT UNSIGNED NOT NULL, + `component_id` BIGINT UNSIGNED NULL, + `rule_name` VARCHAR(120) NOT NULL, + `condition_type` VARCHAR(40) NOT NULL, + `calculation_method` VARCHAR(40) NOT NULL DEFAULT 'none', + `tolerance_minutes` INT NOT NULL DEFAULT 0, + `rate_value` DECIMAL(18,4) NOT NULL DEFAULT 0, + `maximum_amount` DECIMAL(18,2) NULL, + `formula_expression` VARCHAR(500) NULL, + `effective_from` DATE NOT NULL, + `effective_to` DATE NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `notes` TEXT NULL, + `created_by` INT NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_attendance_rule_scope` (`company_id`,`condition_type`,`is_active`), + KEY `idx_attendance_rule_effective` (`company_id`,`effective_from`,`effective_to`), + KEY `idx_attendance_rule_component` (`component_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $this->db->query("CREATE TABLE IF NOT EXISTS `payroll_attendance_locks` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `company_id` BIGINT UNSIGNED NOT NULL, + `payroll_period_id` BIGINT NOT NULL, + `start_date` DATE NOT NULL, + `end_date` DATE NOT NULL, + `status` ENUM('open','locked') NOT NULL DEFAULT 'open', + `locked_by` INT NULL, + `locked_at` DATETIME NULL, + `notes` TEXT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_payroll_attendance_lock_period` (`company_id`,`payroll_period_id`), + KEY `idx_payroll_attendance_lock_range` (`company_id`,`status`,`start_date`,`end_date`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $this->db->query("CREATE TABLE IF NOT EXISTS `payroll_period_adjustments` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `company_id` BIGINT UNSIGNED NOT NULL, + `payroll_period_id` BIGINT NOT NULL, + `employee_id` BIGINT NOT NULL, + `component_id` BIGINT UNSIGNED NOT NULL, + `adjustment_type` ENUM('earning','deduction') NOT NULL, + `amount` DECIMAL(18,2) NOT NULL, + `notes` TEXT NOT NULL, + `attachment_path` VARCHAR(255) NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_by` INT NULL, + `created_at` DATETIME NOT NULL, + `updated_by` INT NULL, + `updated_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_period_adjustment_scope` (`company_id`,`payroll_period_id`,`employee_id`,`is_active`), + KEY `idx_period_adjustment_component` (`component_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + foreach (array( + 'calculation_method'=>"VARCHAR(40) NOT NULL DEFAULT 'fixed' AFTER `component_type`", + 'source_snapshot'=>'LONGTEXT NULL AFTER `formula_snapshot`', + 'attendance_rule_snapshot'=>'LONGTEXT NULL AFTER `source_snapshot`' + ) as $column=>$definition) $this->column('payroll_calculation_lines', $column, $definition); + $this->index('payroll_calculation_lines', 'idx_payroll_line_component', '`component_id`,`component_type`'); + + $this->column('payroll_payments', 'company_id', 'BIGINT UNSIGNED NULL FIRST'); + $this->db->query("UPDATE payroll_payments x JOIN k_payroll_periods p ON p.id=x.payroll_period_id SET x.company_id=p.company_id WHERE x.company_id IS NULL"); + $this->index('payroll_payments', 'idx_payroll_payment_company_status', '`company_id`,`status`,`payment_date`'); + + // Migrasi satu kali: gaji pokok lama hanya menjadi assignment bila karyawan belum punya struktur gaji. + $this->db->query("INSERT INTO employee_payroll_components + (company_id,employee_id,component_id,calculation_method,amount_override,effective_from,effective_to,is_active,notes,created_at) + SELECT e.company_id,e.id,c.id,'fixed',GREATEST(COALESCE(e.basic_salary,0),0),COALESCE(e.join_date,'2000-01-01'),NULL,1,'Migrasi aman dari k_employees.basic_salary',NOW() + FROM k_employees e + JOIN payroll_components c ON c.company_id=e.company_id AND c.code='BASIC' + WHERE e.company_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM employee_payroll_components x WHERE x.company_id=e.company_id AND x.employee_id=e.id)"); + + // Keterlambatan aman secara default: dicatat tetapi tidak otomatis memotong gaji. + $this->db->query("INSERT INTO payroll_attendance_rules + (company_id,component_id,rule_name,condition_type,calculation_method,tolerance_minutes,rate_value,effective_from,is_active,notes,created_at) + SELECT c.id,NULL,'Keterlambatan tanpa potongan','late','none',0,0,'2000-01-01',1,'Default: keterlambatan tidak dipotong sampai aturan diubah.',NOW() + FROM companies c + WHERE NOT EXISTS (SELECT 1 FROM payroll_attendance_rules r WHERE r.company_id=c.id AND r.condition_type='late')"); + + // Trigger lama tetap dipertahankan sebagai kompatibilitas; rentang fleksibel ditambahkan. + $rangeConditionNew = "EXISTS(SELECT 1 FROM payroll_attendance_locks l JOIN k_employees e ON e.id=NEW.employee_id WHERE l.company_id=e.company_id AND l.status='locked' AND NEW.attendance_date BETWEEN l.start_date AND l.end_date)"; + $rangeConditionOld = "EXISTS(SELECT 1 FROM payroll_attendance_locks l JOIN k_employees e ON e.id=OLD.employee_id WHERE l.company_id=e.company_id AND l.status='locked' AND OLD.attendance_date BETWEEN l.start_date AND l.end_date)"; + $this->db->query('DROP TRIGGER IF EXISTS trg_attendance_locked_insert'); + $this->db->query("CREATE TRIGGER trg_attendance_locked_insert BEFORE INSERT ON k_attendances FOR EACH ROW BEGIN IF {$rangeConditionNew} OR EXISTS(SELECT 1 FROM attendance_period_locks WHERE period=DATE_FORMAT(NEW.attendance_date,'%Y-%m') AND status='locked' AND branch_id IS NULL) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Attendance period is locked'; END IF; END"); + $this->db->query('DROP TRIGGER IF EXISTS trg_attendance_locked_update'); + $this->db->query("CREATE TRIGGER trg_attendance_locked_update BEFORE UPDATE ON k_attendances FOR EACH ROW BEGIN IF {$rangeConditionOld} OR EXISTS(SELECT 1 FROM attendance_period_locks WHERE period=DATE_FORMAT(OLD.attendance_date,'%Y-%m') AND status='locked' AND branch_id IS NULL) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Attendance period is locked'; END IF; END"); + $this->db->query('DROP TRIGGER IF EXISTS trg_attendance_locked_delete'); + $this->db->query("CREATE TRIGGER trg_attendance_locked_delete BEFORE DELETE ON k_attendances FOR EACH ROW BEGIN IF {$rangeConditionOld} OR EXISTS(SELECT 1 FROM attendance_period_locks WHERE period=DATE_FORMAT(OLD.attendance_date,'%Y-%m') AND status='locked' AND branch_id IS NULL) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Attendance period is locked'; END IF; END"); + + // Kolom lama sengaja tidak dihapus. k_employees.basic_salary hanya compatibility mirror/read-only fallback. + } + + public function down() + { + throw new RuntimeException('Migration payroll bersifat data-safe; gunakan backup untuk rollback struktur.'); + } +} diff --git a/application/migrations/20260913000200_seed_payroll_for_all_companies.php b/application/migrations/20260913000200_seed_payroll_for_all_companies.php new file mode 100644 index 0000000..8646461 --- /dev/null +++ b/application/migrations/20260913000200_seed_payroll_for_all_companies.php @@ -0,0 +1,31 @@ +db->select_min('company_id','company_id')->get('payroll_components')->row(); + if($source&&$source->company_id){ + $this->db->query("INSERT INTO payroll_components (company_id,code,name,component_type,category,calculation_method,formula_expression,fixed_amount,default_value,taxable,prorated,expense_account_id,liability_account_id,is_active,effective_from,effective_to,created_at) + SELECT co.id,p.code,p.name,p.component_type,p.category,p.calculation_method,p.formula_expression,p.fixed_amount,p.default_value,p.taxable,p.prorated,NULL,NULL,p.is_active,p.effective_from,p.effective_to,NOW() + FROM companies co JOIN payroll_components p ON p.company_id=? + WHERE NOT EXISTS(SELECT 1 FROM payroll_components x WHERE x.company_id=co.id AND x.code=p.code)",array((int)$source->company_id)); + } + $this->db->query("INSERT INTO employee_payroll_components (company_id,employee_id,component_id,calculation_method,amount_override,effective_from,is_active,notes,created_at) + SELECT e.company_id,e.id,c.id,'fixed',GREATEST(COALESCE(e.basic_salary,0),0),COALESCE(e.join_date,'2000-01-01'),1,'Migrasi aman dari k_employees.basic_salary',NOW() + FROM k_employees e JOIN payroll_components c ON c.company_id=e.company_id AND c.code='BASIC' + WHERE e.company_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM employee_payroll_components x WHERE x.company_id=e.company_id AND x.employee_id=e.id)"); + $this->db->query("INSERT INTO payroll_attendance_rules (company_id,rule_name,condition_type,calculation_method,tolerance_minutes,rate_value,effective_from,is_active,notes,created_at) + SELECT c.id,'Keterlambatan tanpa potongan','late','none',0,0,'2000-01-01',1,'Default: keterlambatan tidak dipotong sampai aturan diubah.',NOW() FROM companies c + WHERE NOT EXISTS(SELECT 1 FROM payroll_attendance_rules r WHERE r.company_id=c.id AND r.condition_type='late')"); + $this->db->query("CREATE TABLE IF NOT EXISTS payroll_settings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,company_id BIGINT UNSIGNED NOT NULL, + separation_of_duties TINYINT(1) NOT NULL DEFAULT 1,default_payment_day TINYINT UNSIGNED NULL, + bank_export_note VARCHAR(255) NULL,created_by INT NULL,created_at DATETIME NOT NULL,updated_at DATETIME NULL, + PRIMARY KEY(id),UNIQUE KEY uq_payroll_settings_company(company_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("INSERT INTO payroll_settings(company_id,separation_of_duties,created_at) SELECT id,1,NOW() FROM companies c WHERE NOT EXISTS(SELECT 1 FROM payroll_settings s WHERE s.company_id=c.id)"); + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback payroll multi-company.');} +} diff --git a/application/migrations/20260913000300_scope_cash_accounts_for_payroll.php b/application/migrations/20260913000300_scope_cash_accounts_for_payroll.php new file mode 100644 index 0000000..114e3c6 --- /dev/null +++ b/application/migrations/20260913000300_scope_cash_accounts_for_payroll.php @@ -0,0 +1,22 @@ +db->field_exists('company_id','cash_accounts')){ + $this->db->query("ALTER TABLE cash_accounts ADD company_id BIGINT UNSIGNED NULL AFTER id"); + } + if($this->db->table_exists('accounts')&&$this->db->field_exists('company_id','accounts')){ + $this->db->query("UPDATE cash_accounts c JOIN accounts a ON a.id=c.gl_account_id SET c.company_id=a.company_id WHERE c.company_id IS NULL AND a.company_id IS NOT NULL"); + } + $company=$this->db->select_min('id','id')->get('companies')->row(); + if($company)$this->db->where('company_id IS NULL',null,false)->update('cash_accounts',array('company_id'=>(int)$company->id)); + if(!$this->db->query("SHOW INDEX FROM cash_accounts WHERE Key_name='idx_cash_accounts_company_active'")->row()){ + $this->db->query("ALTER TABLE cash_accounts ADD KEY idx_cash_accounts_company_active(company_id,is_active,name)"); + } + } + + public function down(){throw new RuntimeException('Gunakan backup untuk rollback company scope cash account.');} +} diff --git a/application/views/auth/login.php b/application/views/auth/login.php index 3c16b95..8ec2331 100644 --- a/application/views/auth/login.php +++ b/application/views/auth/login.php @@ -4,7 +4,7 @@ defined('BASEPATH') OR exit('No direct script access allowed'); $loginError = isset($error) && trim((string) $error) !== '' ? trim((string) $error) : trim((string) $this->session->flashdata('error')); -$authNotice = trim((string) $this->session->flashdata('auth_notice')); +$authNotice = isset($auth_notice)&&trim((string)$auth_notice)!==''?trim((string)$auth_notice):trim((string) $this->session->flashdata('auth_notice')); ?> diff --git a/application/views/employees/attendance_monitoring.php b/application/views/employees/attendance_monitoring.php index 1f6bd15..bfe2217 100644 --- a/application/views/employees/attendance_monitoring.php +++ b/application/views/employees/attendance_monitoring.php @@ -1,75 +1,40 @@ -
+load->view('hr_payroll/_header',array('active_menu'=>'attendance_monitoring','title'=>'Monitoring Absensi','subtitle'=>'Pantau karyawan yang dijadwalkan hadir dan kelola absensi manual melalui persetujuan.','action_html'=>$permissions['create']?'':'')); ?> -
+
+
+ +
+
+
Daftar Kehadiran Harian
Menampilkan semua karyawan berdasarkan jadwal yang telah dipublikasikan.
+
NoKodeKaryawanShiftMasukPulangStatus
+
+
+
Alur Absensi Manual
Draft dibuat dahulu, diajukan, kemudian baru masuk ke daftar kehadiran setelah disetujui.
+
1Buat Draft2Ajukan3Approve/Reject4Masuk Absensi
+
DokumenKaryawanTanggal/JamJenisAlasanStatusPembuatAksi
+
+
+
+
-
- -
- -
- Monitoring Absensi -
- - - -
- -
- - - - - - - - - - - - - - - -
NoKodeNamaShiftCheckinCheckoutStatus
- -
- -
- -
- -
+ \ No newline at end of file + diff --git a/application/views/employees/attendance_monthly.php b/application/views/employees/attendance_monthly.php index 1b53511..ca2f2bb 100644 --- a/application/views/employees/attendance_monthly.php +++ b/application/views/employees/attendance_monthly.php @@ -1,3 +1,4 @@ +load->view('hr_payroll/_header',array('active_menu'=>'attendance_monthly','title'=>'Absensi Bulanan','subtitle'=>'Lihat rekap kehadiran bulanan dan rincian absensi setiap karyawan.')); ?> +

name??$company->company_name??'Perusahaan')?>

Register Payroll

period_name)?>

Kehadiran: attendance_start_date?:$period->start_date)?> s.d. attendance_end_date?:$period->end_date)?>

Tanggal bayar: payment_date?:'-')?> · Status: workflow_status))?>

+$r):$basic+=(float)$r->basic_salary;$gross+=(float)$r->gross_salary;$deduction+=(float)$r->total_deductions;$net+=(float)$r->total_salary;?>
NoKodeKaryawanBank / RekeningGaji PokokBrutoPotonganNeto
employee_code)?>full_name)?>bank_name?:'-').' / '.($r->bank_account_number?:'-'),' /'))?>basic_salary,2,',','.')?>gross_salary,2,',','.')?>total_deductions,2,',','.')?>total_salary,2,',','.')?>
TOTAL
+

Dokumen payroll bersifat rahasia. Dibuat pada .

diff --git a/application/views/hr_payroll/slip.php b/application/views/hr_payroll/slip.php index cc5473c..c45e968 100644 --- a/application/views/hr_payroll/slip.php +++ b/application/views/hr_payroll/slip.php @@ -1 +1 @@ -Slip Gaji

SLIP GAJI

full_name)?> — employee_code)?>
Periode period_name)?>

KomponenJenisNilai
component_name)?>component_type?>amount,0,',','.')?>
Gaji Netototal_salary,0,',','.')?>
Dokumen rahasia. Status payroll: workflow_status?> +Slip Gaji

SLIP GAJI

full_name)?> — employee_code)?>
period_name)?>
attendance_start_date?:$payroll->period_start_date)?> s.d. attendance_end_date?:$payroll->period_end_date)?>
Tanggal bayar: payment_date?:'-')?>
KomponenJenisNilai
component_name)?>component_type)?>amount,2,',','.')?>
Gaji NetoRp total_salary,2,',','.')?>

Dokumen rahasia. Status payroll: workflow_status))?>

diff --git a/application/views/inventory_professional/operations.php b/application/views/inventory_professional/operations.php index afb7d5d..0a394e5 100644 --- a/application/views/inventory_professional/operations.php +++ b/application/views/inventory_professional/operations.php @@ -40,11 +40,12 @@ $(function(){ const permissions=,csrf=()=>({[APP_CSRF.name]:APP_CSRF.value}),esc=v=>$('
').text(v==null?'-':v).html(),fmt=v=>Number(v||0).toLocaleString('id-ID',{maximumFractionDigits:4}),money=v=>'Rp '+Number(v||0).toLocaleString('id-ID',{minimumFractionDigits:2,maximumFractionDigits:2}); const labels={opening:'Saldo Awal',in:'Masuk Manual',out:'Keluar Manual',transfer:'Transfer Gudang',adjustment:'Penyesuaian',opname:'Stock Opname',damaged:'Barang Rusak',lost:'Barang Hilang',draft:'Draft',submitted:'Diajukan',approved:'Disetujui',posted:'Diposting',rejected:'Ditolak',reversed:'Dibalik'}; - function api(url,data){return $.ajax({url:url,type:'POST',data:Object.assign({},data||{},csrf()),dataType:'json'})} - function fail(xhr){AppAlert.toast(xhr.responseJSON&&xhr.responseJSON.message||'Proses tidak berhasil.','error')} + function api(url,data){return $.ajax({url:url,type:'POST',data:Object.assign({},data||{},csrf()),dataType:'json',timeout:30000})} + function hideGlobalLoading(){let loading=document.getElementById('globalLoading');if(loading)loading.hidden=true} + function fail(xhr){hideGlobalLoading();let message=xhr.responseJSON&&xhr.responseJSON.message;if(!message&&xhr.statusText==='timeout')message='Waktu proses habis. Silakan coba kembali.';AppAlert.toast(message||'Proses tidak berhasil.','error')} const table=$('#documentTable').DataTable({processing:true,serverSide:true,responsive:true,searchDelay:350,pageLength:10,order:[],ajax:{url:'',data:d=>{d.type=$('#filterType').val();d.status=$('#filterStatus').val()}},columns:[{data:'document_no',render:(v,t,r)=>''},{data:'document_date',render:v=>v?v.split('-').reverse().join('/'):'-'},{data:'document_type',render:v=>labels[v]||v},{data:null,render:r=>esc((r.from_warehouse||'-')+' → '+(r.to_warehouse||'-'))},{data:'line_count',className:'text-center'},{data:'status',render:v=>''+esc(labels[v]||v)+''},{data:'creator_name',defaultContent:'-'},{data:null,orderable:false,searchable:false,render:r=>actions(r)}],language:{search:'Cari:',lengthMenu:'Tampilkan _MENU_',info:'_START_–_END_ dari _TOTAL_',processing:'Memuat data...',paginate:{previous:'Sebelumnya',next:'Berikutnya'}}}); const focusDocument=new URLSearchParams(window.location.search).get('focus');if(focusDocument)table.one('draw.dt',function(){setTimeout(function(){$('').appendTo(document.body).trigger('click').remove()},100)}); - function actions(r){let a='
';if(permissions.export)a+='';if(r.status==='draft'&&permissions.create)a+='';if(r.status==='submitted'&&permissions.approve)a+='';if(r.status==='approved'&&permissions.post)a+='';if(r.status==='posted'&&permissions.post)a+='';return a+'
'} + function actions(r){let a='
';if(permissions.export)a+='';if(r.status==='draft'&&permissions.create)a+='';if(r.status==='submitted'&&permissions.approve)a+='';if(r.status==='approved'&&permissions.post)a+='';if(r.status==='posted'&&permissions.post)a+='';return a+'
'} $('#filterType,#filterStatus').on('change',()=>table.ajax.reload());$('#refreshDocuments').on('click',()=>table.ajax.reload(null,false)); $('[data-inventory-tab]').on('click',function(){let tab=$(this).data('inventory-tab');$('[data-inventory-tab]').removeClass('active');$(this).addClass('active');if(tab==='reservation'){$('#documentSection').addClass('d-none');$('#reservationSection').removeClass('d-none');loadReservations();return}$('#reservationSection').addClass('d-none');$('#documentSection').removeClass('d-none');$('#filterType').val(tab);table.ajax.reload()}); function addLine(){let id=Date.now()+Math.random().toString(16).slice(2),tr=$('');tr.attr('data-key',id);$('#documentLines').append(tr);initLine(tr);toggleType()} @@ -53,7 +54,7 @@ $(function(){ $('#newDocument').on('click',function(){$('#documentForm')[0].reset();selectedFiles=[];renderAttachments();$('#documentDate').val('');$('#idempotencyKey').val('WEB-INV-'+Date.now()+'-'+Math.random().toString(36).slice(2));$('#documentLines').empty();addLine();toggleType();bootstrap.Modal.getOrCreateInstance('#documentModal').show()});$('#addLine').on('click',addLine);$('#documentType').on('change',toggleType);$(document).on('click','.remove-line',function(){if($('.document-line').length>1)$(this).closest('tr').remove()}); let selectedFiles=[];$(document).on('click','#chooseAttachments',()=>$('#documentAttachments').click());$('#documentAttachments').on('change',function(){selectedFiles=selectedFiles.concat(Array.from(this.files));this.value='';renderAttachments()});function renderAttachments(){let h=selectedFiles.map((f,i)=>{let preview=f.type.indexOf('image/')===0?'':'';return'
'+preview+''+esc(f.name)+'
'}).join('');$('#attachmentNames').html(h+'')}$(document).on('click','.remove-attachment',function(){selectedFiles.splice(Number($(this).data('index')),1);renderAttachments()}); $('#documentForm').on('submit',function(e){e.preventDefault();let lines=[];$('.document-line').each(function(){let r=$(this);lines.push({item_id:r.find('.line-item').val(),barcode_id:r.find('.line-barcode').val(),batch_id:r.find('.line-batch').val(),movement_direction:r.find('.line-direction').val(),qty:r.find('.line-qty').val().replace(/\./g,'').replace(',','.'),unit_cost:r.find('.line-cost').val().replace(/\./g,'').replace(',','.'),reason:r.find('.line-reason').val()})});if(lines.some(x=>!x.item_id||!x.qty))return AppAlert.toast('Barang dan qty setiap baris wajib diisi.','warning');let btn=$('#saveDocument').prop('disabled',true);api('',{type:$('#documentType').val(),date:$('#documentDate').val(),from_warehouse_id:$('#fromWarehouse').val(),to_warehouse_id:$('#toWarehouse').val(),source_type:$('#sourceType').val(),source_id:$('#sourceId').val(),reason:$('#documentReason').val(),idempotency_key:$('#idempotencyKey').val(),lines:JSON.stringify(lines)}).done(async function(r){let files=selectedFiles.slice();try{for(let f of files){let fd=new FormData();fd.append(APP_CSRF.name,APP_CSRF.value);fd.append('entity_type','stock_document');fd.append('entity_id',r.document_id);fd.append('attachment',f);await $.ajax({url:'',type:'POST',data:fd,processData:false,contentType:false})}bootstrap.Modal.getInstance(document.getElementById('documentModal')).hide();table.ajax.reload(null,false);AppAlert.toast(r.message,'success')}catch(x){AppAlert.toast('Draft tersimpan, tetapi ada lampiran yang gagal diunggah. Buka detail untuk memeriksa.','warning')}}).fail(fail).always(()=>btn.prop('disabled',false))}); - $(document).on('click','.btn-state',function(){let b=$(this),action=b.data('action'),word=action==='submit'?'mengajukan':action==='approve'?'menyetujui':'memposting';AppAlert.confirm({title:'Konfirmasi '+word+' dokumen',text:'Pastikan detail barang dan gudang sudah benar.'}).then(x=>{if(!x.isConfirmed)return;b.prop('disabled',true);api('/'+action+'/'+b.data('id')).done(r=>{table.ajax.reload(null,false);AppAlert.toast(r.message,'success')}).fail(fail).always(()=>b.prop('disabled',false))})}); + $(document).on('click','.btn-state',function(){let b=$(this),action=b.data('action'),word=action==='submit'?'mengajukan':action==='approve'?'menyetujui':'memposting',original=b.html();hideGlobalLoading();AppAlert.confirm({title:'Konfirmasi '+word+' dokumen',text:'Pastikan detail barang dan gudang sudah benar.'}).then(x=>{if(!x.isConfirmed){hideGlobalLoading();return}b.prop('disabled',true).html(' Memproses');api('/'+action+'/'+b.data('id')).done(r=>{table.ajax.reload(null,false);AppAlert.toast(r.message,'success')}).fail(fail).always(()=>{hideGlobalLoading();b.prop('disabled',false).html(original)})}).catch(()=>{hideGlobalLoading();b.prop('disabled',false).html(original)})}); $(document).on('click','.btn-reject',function(){let id=$(this).data('id');AppAlert.prompt({title:'Tolak dokumen stok',input:'textarea',inputLabel:'Alasan penolakan',inputValidator:v=>!v.trim()?'Alasan wajib diisi.':undefined,confirmButtonText:'Tolak Dokumen'}).then(x=>{if(x.isConfirmed)api('/'+id,{reason:x.value}).done(r=>{table.ajax.reload(null,false);AppAlert.toast(r.message,'success')}).fail(fail)})}); $(document).on('click','.btn-reverse',function(){let id=$(this).data('id');AppAlert.prompt({title:'Reversal dokumen stok',html:'',preConfirm:()=>{let d=$('#reverseDate').val(),r=$('#reverseReason').val().trim();if(!d||!r){Swal.showValidationMessage('Tanggal dan alasan wajib diisi.');return false}return{date:d,reason:r}},confirmButtonText:'Posting Reversal'}).then(x=>{if(x.isConfirmed)api('/'+id,x.value).done(r=>{table.ajax.reload(null,false);AppAlert.toast(r.message,'success')}).fail(fail)})}); $(document).on('click','.btn-detail',function(){let id=$(this).data('id');$('#detailBody').html('
Memuat detail…
');bootstrap.Modal.getOrCreateInstance('#detailModal').show();$.get('/'+id).done(function(r){let d=r.document,lines=d.lines.map(l=>''+esc(l.kode_detail+' - '+l.nama_barang)+''+esc(l.barcode)+(l.serial_number?'SN '+esc(l.serial_number)+'':'')+''+esc(l.batch_no)+''+fmt(d.document_type==='opname'?l.counted_qty:l.qty)+' '+esc(l.unit)+''+money(l.unit_cost)+''+esc(l.reason)+'').join(''),hist=d.history.map(h=>'
  • '+esc(labels[h.to_status]||h.to_status)+''+esc(h.user_name)+' · '+esc(h.created_at)+''+esc(h.notes)+'
  • ').join(''),atts=d.attachments.map(a=>'').join(' ');$('#detailTitle').text(d.document_no);$('#detailBody').html('
    Jenis'+esc(labels[d.document_type]||d.document_type)+'Tanggal'+esc(d.document_date)+'Status'+esc(labels[d.status]||d.status)+'Gudang Asal'+esc(d.from_warehouse)+'Gudang Tujuan'+esc(d.to_warehouse)+'Dokumen Sumber'+esc(d.source_type)+' #'+esc(d.source_id)+'Pembuat'+esc(d.creator_name)+'Pengaju'+esc(d.submitter_name)+'Approver / Poster'+esc(d.approver_name)+' / '+esc(d.poster_name)+'
    Alasan: '+esc(d.reason)+'
    '+lines+'
    BarangBarcode/SNBatchQtyBiayaCatatan
    Lampiran
    '+(atts||'Tidak ada lampiran.')+'
    Riwayat Status
    ');$('#detailActions').html('')}).fail(fail)}); diff --git a/application/views/partials/header.php b/application/views/partials/header.php index c573fab..71b4b64 100644 --- a/application/views/partials/header.php +++ b/application/views/partials/header.php @@ -12,6 +12,7 @@ $dataPages=isset($uiAssets['ui_data_pages'])?$uiAssets['ui_data_pages']:array(); $selectPages=isset($uiAssets['ui_select_pages'])?$uiAssets['ui_select_pages']:array(); $userName=$this->session->userdata('nama')?:$this->session->userdata('username')?:'Pengguna'; $userRole=$this->session->userdata('role')?:'User'; +$userPhoto=$this->session->userdata('profile_photo'); ?> @@ -20,7 +21,7 @@ $userRole=$this->session->userdata('role')?:'User'; - + @@ -31,12 +32,12 @@ $userRole=$this->session->userdata('role')?:'User'; AccountingBusiness Suite - +
    Accounting Workspace

    -
    +
    diff --git a/application/views/partials/navigation_items.php b/application/views/partials/navigation_items.php index 4fe7431..7b915c9 100644 --- a/application/views/partials/navigation_items.php +++ b/application/views/partials/navigation_items.php @@ -1,13 +1,13 @@
    diff --git a/application/views/profile/index.php b/application/views/profile/index.php new file mode 100644 index 0000000..8150594 --- /dev/null +++ b/application/views/profile/index.php @@ -0,0 +1,34 @@ +security->get_csrf_token_name();$csrfHash=$this->security->get_csrf_hash(); +$initial=strtoupper(substr(trim((string)($user->nama?:$user->username)),0,1)); +$activeSessions=array_values(array_filter($sessions,function($row){return$row->status==='active';})); +$historySessions=array_values(array_filter($sessions,function($row){return$row->status!=='active';})); +$resultLabels=array('success'=>'Login berhasil','invalid_credentials'=>'Kredensial salah','device_limit'=>'Ditolak: batas perangkat'); +$resultClasses=array('success'=>'success','invalid_credentials'=>'danger','device_limit'=>'warning'); +$statusLabels=array('active'=>'Aktif','logged_out'=>'Logout','expired'=>'Kedaluwarsa','revoked'=>'Diakhiri'); +$statusClasses=array('active'=>'success','logged_out'=>'secondary','expired'=>'warning','revoked'=>'danger'); +function profileDateTime($value){return$value?date('d/m/Y H:i',strtotime($value)):'-';} +?> +
    +
    profile_photo)&&is_file(FCPATH.$user->profile_photo)):?>Foto profil
    AKUN PENGGUNA

    nama?:$user->username)?>

    @username)?> • email?:'Email belum diatur')?>

    Keamanan Login / max_active_devices?:1)?> perangkat aktifDurasi maksimum session_duration_hours?:24)?> jam dari login pertama
    + + 'success','error'=>'danger')as$key=>$class)if($this->session->flashdata($key)):?>
    session->flashdata($key))?>
    + + + +
    +
    Informasi Profil

    Identitas yang tampil di dalam aplikasi.

    profile_photo)&&is_file(FCPATH.$user->profile_photo)):?>Foto profil
    JPG, PNG, atau WEBP; maksimal 2 MB.
    Username dikelola oleh administrator.
    +
    +
    Pengaturan Session Login

    Batas waktu tetap dihitung sejak login pertama.

    Pengaturan baru berlaku saat login berikutnya. Rotasi ID session setiap 5 menit tidak memperpanjang batas ini.
    +
    Ganti Password

    Perangkat lain otomatis dikeluarkan setelah password diubah.

    Minimal 8 karakter serta mengandung huruf dan angka.
    +
    +
    + +
    Perangkat Aktif

    Session aktif menggunakan satu slot perangkat sampai logout, diakhiri, atau mencapai batas waktunya.

    aktif
    browser.' di '.$session->platform)?>is_current):?>Perangkat ini
    device_type)?> • IP ip_address?:'-')?>
    Login login_at)?>Aktif terakhir last_seen_at)?>Berakhir expires_at)?>
    Detail perangkat
    user_agent?:'-')?>
    Tidak ada perangkat aktif.
    + +
    Aktivitas & Percobaan Login

    Termasuk login berhasil, kredensial salah, dan permintaan yang ditolak karena batas perangkat.

    WaktuPerangkatAlamat IPHasilKeterangan
    attempted_at)?>browser)?>device_type)?> • platform)?>ip_address?:'-')?>result]??$attempt->result)?>reason?:'-')?>result==='device_limit'):?>active_session_count?> dari device_limit?> slot sedang aktif
    Belum ada aktivitas login yang tercatat.
    + +
    Riwayat Session Terakhir

    Session yang telah logout, kedaluwarsa, atau diakhiri.

    PerangkatLoginTerakhir AktifStatusAlasan
    browser.' di '.$session->platform)?>device_type)?> • ip_address?:'-')?>login_at)?>last_seen_at)?>status]??$session->status)?>end_reason?:'-')?>
    + +
    + diff --git a/application/views/roles/index.php b/application/views/roles/index.php index 1e34492..fd4af24 100644 --- a/application/views/roles/index.php +++ b/application/views/roles/index.php @@ -6,7 +6,7 @@
    Pilih role untuk melihat permission
    -
    $label):?>$actionLabel):?>
    Modul
    +
    $label):?>$actionLabel):?>
    Modul
    diff --git a/assets/css/app-final.css b/assets/css/app-final.css index 5f93677..edcf3b8 100644 --- a/assets/css/app-final.css +++ b/assets/css/app-final.css @@ -263,6 +263,13 @@ body.app-v2 { font-size: 10px; color: #8d929b; } +.user-copy a, +.user-copy a:hover, +.user-copy a:focus, +.user-copy a:visited { + color: inherit; + text-decoration: none; +} .sidebar-user > a { width: 30px; height: 30px; diff --git a/assets/css/hr-payroll.css b/assets/css/hr-payroll.css new file mode 100644 index 0000000..5f3ea6b --- /dev/null +++ b/assets/css/hr-payroll.css @@ -0,0 +1,8 @@ +.hr-module-hero{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:1rem;padding:1.35rem 1.5rem;border-radius:18px;color:#fff;background:linear-gradient(120deg,#17284a 0%,#273e67 62%,#ed7900 100%);box-shadow:0 12px 28px rgba(21,38,70,.14)} +.hr-module-hero span{display:block;margin-bottom:.25rem;font-size:.68rem;font-weight:800;letter-spacing:.15em;color:#ffd29f}.hr-module-hero h2{margin:0;font-size:1.45rem;line-height:1.25;color:#fff}.hr-module-hero p{margin:.35rem 0 0;max-width:760px;font-size:.85rem;color:rgba(255,255,255,.82)} +.hr-module-nav{display:flex;gap:.35rem;margin-bottom:1rem;padding:.45rem;overflow-x:auto;border:1px solid #e2e8f0;border-radius:14px;background:#fff;box-shadow:0 5px 18px rgba(30,41,59,.05);scrollbar-width:thin}.hr-module-nav a{display:inline-flex;align-items:center;gap:.4rem;flex:0 0 auto;padding:.62rem .78rem;border-radius:10px;color:#34415a;font-size:.78rem;font-weight:700;text-decoration:none;white-space:nowrap}.hr-module-nav a:hover{background:#fff4e8;color:#b95c00}.hr-module-nav a.active{background:#1d345b;color:#fff;box-shadow:0 6px 14px rgba(29,52,91,.18)} +body[data-page="employees"] .container.mt-4,body[data-page="shift"] .container.mt-4,body[data-page="holidays"] .container.mt-4,body[data-page="attendance_monitoring"] .container.mt-4,body[data-page="attendance_monthly"] .container.mt-4{max-width:none;margin-top:0!important;padding:0} +.hr-panel{border:1px solid #e2e8f0!important;border-radius:16px!important;box-shadow:0 7px 20px rgba(30,41,59,.06)!important}.hr-workflow-strip{display:grid;grid-template-columns:repeat(7,minmax(115px,1fr));gap:.45rem;margin-bottom:1rem}.hr-workflow-strip span{padding:.65rem;border:1px solid #e2e8f0;border-radius:11px;background:#fff;text-align:center;font-size:.73rem;font-weight:700;color:#4b5870}.hr-workflow-strip span b{display:inline-grid;place-items:center;width:20px;height:20px;margin-right:.25rem;border-radius:50%;background:#fff0df;color:#c35d00} +.salary-restricted-note{padding:.7rem .8rem;border:1px solid #ffd7ad;border-radius:10px;background:#fff8ef;color:#874500;font-size:.78rem} +.payroll-table th,.payroll-table td{font-size:11px}.payroll-actions{display:flex;flex-wrap:wrap;align-items:center;gap:.35rem;min-width:230px}.payroll-actions form{margin:0}.payroll-actions .form-control,.payroll-actions .form-select{width:auto;min-width:112px}.payroll-setting-tabs{padding:.4rem;border:1px solid #e2e8f0;border-radius:14px;background:#fff;overflow-x:auto;flex-wrap:nowrap}.payroll-setting-tabs .nav-link{white-space:nowrap;color:#34415a;font-size:.78rem;font-weight:700}.payroll-setting-tabs .nav-link.active{background:#1d345b}.payroll-info-box{height:100%;padding:1rem;border:1px solid #e2e8f0;border-radius:12px;background:#f8fafc}.payroll-info-box b,.payroll-info-box span{display:block}.payroll-info-box span{margin-top:.3rem;color:#64748b;font-size:.78rem} +@media(max-width:767.98px){.hr-module-hero{align-items:flex-start;flex-direction:column;padding:1.1rem}.hr-module-hero h2{font-size:1.2rem}.hr-workflow-strip{display:flex;overflow-x:auto}.hr-workflow-strip span{min-width:130px}} diff --git a/assets/css/leave-requests.css b/assets/css/leave-requests.css new file mode 100644 index 0000000..6ac2a2d --- /dev/null +++ b/assets/css/leave-requests.css @@ -0,0 +1,9 @@ +.leave-hero{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:24px 28px;margin-bottom:16px;border-radius:20px;color:#fff;background:linear-gradient(120deg,#172b4d 0%,#263b5f 62%,#ed7600 100%);box-shadow:0 14px 30px rgba(23,43,77,.14)} +.leave-hero h2{margin:2px 0 5px;font-size:24px;font-weight:750}.leave-hero p{margin:0;color:rgba(255,255,255,.8)}.leave-kicker{font-size:10px;font-weight:750;letter-spacing:.16em;color:#ffbd7d}.leave-hero .btn{flex:none} +.leave-flow{display:flex;align-items:center;justify-content:center;gap:14px;padding:12px 18px;margin-bottom:16px;border:1px solid #e2e7ef;border-radius:14px;background:#fff}.leave-flow span{display:flex;align-items:center;gap:8px}.leave-flow b{display:grid;place-items:center;width:25px;height:25px;border-radius:50%;color:#a94d00;background:#fff0df}.leave-flow small{font-weight:650;color:#34445e}.leave-flow>i{color:#aab4c4} +.leave-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:16px}.leave-metrics article{padding:17px 19px;border:1px solid #e3e8f0;border-radius:15px;background:#fff;box-shadow:0 7px 20px rgba(23,43,77,.045)}.leave-metrics small,.leave-metrics span{display:block;color:#718096}.leave-metrics strong{display:block;margin:4px 0;font-size:22px;color:#172b4d}.leave-metrics .metric-warning{border-left:4px solid #ed7600}.leave-metrics .metric-success{border-left:4px solid #198754} +.leave-card{overflow:hidden;border-radius:16px!important;box-shadow:0 8px 24px rgba(23,43,77,.055)}.leave-tabs{display:flex;gap:5px;padding:10px 12px;border-bottom:1px solid #e5e9f0;overflow-x:auto}.leave-tabs button{display:flex;align-items:center;gap:7px;padding:9px 13px;border:0;border-radius:10px;color:#40516c;background:transparent;white-space:nowrap}.leave-tabs button.active{color:#fff;background:#172b4d}.leave-tabs b{min-width:20px;padding:2px 6px;border-radius:99px;font-size:10px;text-align:center;color:#4b5c76;background:#edf1f7}.leave-tabs button.active b{color:#172b4d;background:#fff} +.leave-toolbar{display:grid;grid-template-columns:minmax(220px,1fr) minmax(190px,.7fr) auto;gap:10px;padding:14px 16px;border-bottom:1px solid #e5e9f0}.leave-status{display:inline-flex;align-items:center;padding:4px 9px;border-radius:99px;font-size:10px;font-weight:750}.leave-status-draft{color:#40516c;background:#e9edf3}.leave-status-submitted{color:#984800;background:#fff0dd}.leave-status-approved{color:#087047;background:#dff5ea}.leave-status-rejected{color:#b42331;background:#fde5e8}.leave-status-cancelled{color:#687386;background:#eef0f3} +.leave-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.leave-detail-grid>span{padding:12px 14px;border:1px solid #e4e8ef;border-radius:11px;color:#718096}.leave-detail-grid strong,.leave-detail-grid small{display:block;margin-top:3px;color:#172b4d}.leave-detail-grid small{font-size:11px;color:#718096}.leave-reason{padding:14px;margin-top:12px;border-radius:11px;background:#f6f8fb}.leave-reason small{font-weight:750;letter-spacing:.08em;color:#718096}.leave-reason p{margin:6px 0 0;white-space:pre-wrap}.leave-history{list-style:none;padding:0;margin:0}.leave-history li{position:relative;display:flex;gap:11px;padding:0 0 16px 4px}.leave-history li:after{content:"";position:absolute;top:15px;bottom:0;left:9px;border-left:1px solid #d9e0e9}.leave-history li:last-child:after{display:none}.leave-history-dot{position:relative;z-index:1;width:11px;height:11px;margin-top:4px;border:3px solid #ffe0bd;border-radius:50%;background:#ed7600}.leave-history strong,.leave-history small{display:block}.leave-history small{color:#718096}.leave-history p{margin:4px 0 0;color:#44526a} +@media(max-width:767.98px){.leave-hero{align-items:flex-start;padding:20px;flex-direction:column}.leave-flow{justify-content:flex-start;overflow-x:auto}.leave-flow small{white-space:nowrap}.leave-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.leave-toolbar{grid-template-columns:1fr}.leave-detail-grid{grid-template-columns:1fr}} +@media(max-width:420px){.leave-metrics{grid-template-columns:1fr}} diff --git a/assets/css/profile.css b/assets/css/profile.css new file mode 100644 index 0000000..b4e0e80 --- /dev/null +++ b/assets/css/profile.css @@ -0,0 +1 @@ +.profile-page{max-width:1320px;margin:0 auto}.profile-hero{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:25px 28px;margin-bottom:16px;border-radius:16px;color:#fff;background:linear-gradient(115deg,#172b4d 0%,#223b63 72%,#d96c00 140%);box-shadow:0 12px 28px rgba(23,43,77,.14)}.profile-identity{display:flex;align-items:center;gap:16px}.profile-identity span{font-size:9px;font-weight:700;letter-spacing:.16em;color:#fbb96f}.profile-identity h2{margin:2px 0 3px;font-size:21px;color:#fff}.profile-identity p{margin:0;color:rgba(255,255,255,.7)}.profile-avatar-large,.profile-avatar-preview{display:grid;place-items:center;overflow:hidden;background:#ee7800;color:#fff;font-weight:700}.profile-avatar-large{width:64px;height:64px;border:3px solid rgba(255,255,255,.2);border-radius:16px;font-size:23px}.profile-avatar-large img,.profile-avatar-preview img,.user-avatar img{width:100%;height:100%;object-fit:cover}.profile-security-summary{min-width:245px;padding:12px 15px;border:1px solid rgba(255,255,255,.14);border-radius:12px;background:rgba(255,255,255,.08)}.profile-security-summary small,.profile-security-summary span{display:block;color:rgba(255,255,255,.68)}.profile-security-summary b{display:block;margin:2px 0;font-size:14px}.profile-tabs{display:flex;gap:7px;padding:6px;margin-bottom:16px;border:1px solid #e4e7ec;border-radius:13px;background:#fff}.profile-tabs a{display:flex;align-items:center;gap:8px;padding:9px 13px;border-radius:9px;color:#475467;text-decoration:none;font-weight:600}.profile-tabs a:hover{background:#f8fafc}.profile-tabs a.active{color:#fff;background:#172b4d}.profile-tabs a b{display:grid;place-items:center;min-width:19px;height:19px;padding:0 5px;border-radius:10px;background:#ee7800;color:#fff;font-size:9px}.profile-grid{display:grid;grid-template-columns:minmax(310px,.78fr) minmax(480px,1.22fr);gap:16px}.profile-stack{display:grid;gap:16px}.profile-card{overflow:hidden;padding:19px;border:1px solid #e4e7ec;border-radius:14px;background:#fff;box-shadow:0 5px 14px rgba(16,24,40,.035)}.profile-card-head{display:flex;align-items:flex-start;justify-content:space-between;gap:15px;margin-bottom:17px}.profile-card-head h5{margin:0;color:#172b4d;font-size:14px}.profile-card-head p{margin:3px 0 0;color:#667085;font-size:11px}.profile-card-head>i{font-size:20px;color:#ee7800}.profile-photo-field{display:grid;grid-template-columns:72px 1fr;align-items:center;gap:14px;margin-bottom:17px}.profile-avatar-preview{width:72px;height:72px;border-radius:14px;font-size:23px}.profile-card small{color:#667085;font-size:10px}.profile-notice{display:flex;gap:9px;padding:10px 12px;margin:14px 0;border:1px solid #fed7aa;border-radius:9px;background:#fff7ed;color:#9a4508;font-size:10.5px}.profile-count{padding:5px 9px;border-radius:20px;background:#ecfdf3;color:#087443;font-size:10px;font-weight:700}.device-list{display:grid;gap:10px}.device-row{display:grid;grid-template-columns:42px minmax(0,1fr) auto;gap:12px;align-items:start;padding:13px;border:1px solid #e4e7ec;border-radius:11px;background:#fcfcfd}.device-icon{display:grid;place-items:center;width:42px;height:42px;border-radius:10px;background:#eef4ff;color:#175cd3;font-size:19px}.device-main b{color:#172b4d}.device-main .badge{margin-left:6px}.device-main>small,.profile-table small{display:block;margin-top:2px}.device-times{display:flex;flex-wrap:wrap;gap:5px 18px;margin-top:8px;color:#667085;font-size:10px}.device-times b{color:#344054}.device-main details{margin-top:7px;color:#667085;font-size:10px}.device-main summary{cursor:pointer;color:#175cd3}.user-agent{padding:7px;margin-top:4px;border-radius:6px;background:#f2f4f7;word-break:break-word}.profile-empty{padding:28px;text-align:center;color:#667085}.profile-table{margin:0;font-size:11px}.profile-table th{background:#f8fafc;color:#475467;font-size:9px;text-transform:uppercase;letter-spacing:.04em}.profile-table td{vertical-align:middle}.profile-table .badge{font-size:9px}@media(max-width:900px){.profile-grid{grid-template-columns:1fr}.profile-hero{align-items:flex-start;flex-direction:column}.profile-security-summary{width:100%}.device-row{grid-template-columns:40px minmax(0,1fr)}.device-row form{grid-column:2}}@media(max-width:575px){.profile-hero{padding:19px}.profile-tabs a{flex:1;justify-content:center;padding:9px}.profile-tabs a span{font-size:10px}.profile-card{padding:15px}.device-times{display:grid}.profile-identity h2{font-size:18px}} diff --git a/vendor/dompdf/dompdf/lib/fonts/Times-Roman.afm.json b/vendor/dompdf/dompdf/lib/fonts/Times-Roman.afm.json new file mode 100644 index 0000000..c96b2fc --- /dev/null +++ b/vendor/dompdf/dompdf/lib/fonts/Times-Roman.afm.json @@ -0,0 +1,569 @@ +{ + "codeToName": { + "32": "space", + "160": "space", + "33": "exclam", + "34": "quotedbl", + "35": "numbersign", + "36": "dollar", + "37": "percent", + "38": "ampersand", + "146": "quoteright", + "40": "parenleft", + "41": "parenright", + "42": "asterisk", + "43": "plus", + "44": "comma", + "45": "hyphen", + "173": "hyphen", + "46": "period", + "47": "slash", + "48": "zero", + "49": "one", + "50": "two", + "51": "three", + "52": "four", + "53": "five", + "54": "six", + "55": "seven", + "56": "eight", + "57": "nine", + "58": "colon", + "59": "semicolon", + "60": "less", + "61": "equal", + "62": "greater", + "63": "question", + "64": "at", + "65": "A", + "66": "B", + "67": "C", + "68": "D", + "69": "E", + "70": "F", + "71": "G", + "72": "H", + "73": "I", + "74": "J", + "75": "K", + "76": "L", + "77": "M", + "78": "N", + "79": "O", + "80": "P", + "81": "Q", + "82": "R", + "83": "S", + "84": "T", + "85": "U", + "86": "V", + "87": "W", + "88": "X", + "89": "Y", + "90": "Z", + "91": "bracketleft", + "92": "backslash", + "93": "bracketright", + "94": "asciicircum", + "95": "underscore", + "145": "quoteleft", + "97": "a", + "98": "b", + "99": "c", + "100": "d", + "101": "e", + "102": "f", + "103": "g", + "104": "h", + "105": "i", + "106": "j", + "107": "k", + "108": "l", + "109": "m", + "110": "n", + "111": "o", + "112": "p", + "113": "q", + "114": "r", + "115": "s", + "116": "t", + "117": "u", + "118": "v", + "119": "w", + "120": "x", + "121": "y", + "122": "z", + "123": "braceleft", + "124": "bar", + "125": "braceright", + "126": "asciitilde", + "161": "exclamdown", + "162": "cent", + "163": "sterling", + "165": "yen", + "131": "florin", + "167": "section", + "164": "currency", + "39": "quotesingle", + "147": "quotedblleft", + "171": "guillemotleft", + "139": "guilsinglleft", + "155": "guilsinglright", + "150": "endash", + "134": "dagger", + "135": "daggerdbl", + "183": "periodcentered", + "182": "paragraph", + "149": "bullet", + "130": "quotesinglbase", + "132": "quotedblbase", + "148": "quotedblright", + "187": "guillemotright", + "133": "ellipsis", + "137": "perthousand", + "191": "questiondown", + "96": "grave", + "180": "acute", + "136": "circumflex", + "152": "tilde", + "175": "macron", + "168": "dieresis", + "184": "cedilla", + "151": "emdash", + "198": "AE", + "170": "ordfeminine", + "216": "Oslash", + "140": "OE", + "186": "ordmasculine", + "230": "ae", + "248": "oslash", + "156": "oe", + "223": "germandbls", + "207": "Idieresis", + "233": "eacute", + "159": "Ydieresis", + "247": "divide", + "221": "Yacute", + "194": "Acircumflex", + "225": "aacute", + "219": "Ucircumflex", + "253": "yacute", + "234": "ecircumflex", + "220": "Udieresis", + "218": "Uacute", + "203": "Edieresis", + "169": "copyright", + "229": "aring", + "224": "agrave", + "227": "atilde", + "154": "scaron", + "237": "iacute", + "251": "ucircumflex", + "226": "acircumflex", + "231": "ccedilla", + "222": "Thorn", + "179": "threesuperior", + "210": "Ograve", + "192": "Agrave", + "215": "multiply", + "250": "uacute", + "255": "ydieresis", + "238": "icircumflex", + "202": "Ecircumflex", + "228": "adieresis", + "235": "edieresis", + "205": "Iacute", + "177": "plusminus", + "166": "brokenbar", + "174": "registered", + "200": "Egrave", + "142": "Zcaron", + "208": "Eth", + "199": "Ccedilla", + "193": "Aacute", + "196": "Adieresis", + "232": "egrave", + "211": "Oacute", + "243": "oacute", + "239": "idieresis", + "212": "Ocircumflex", + "217": "Ugrave", + "254": "thorn", + "178": "twosuperior", + "214": "Odieresis", + "181": "mu", + "236": "igrave", + "190": "threequarters", + "153": "trademark", + "204": "Igrave", + "189": "onehalf", + "244": "ocircumflex", + "241": "ntilde", + "201": "Eacute", + "188": "onequarter", + "138": "Scaron", + "176": "degree", + "242": "ograve", + "249": "ugrave", + "209": "Ntilde", + "245": "otilde", + "195": "Atilde", + "197": "Aring", + "213": "Otilde", + "206": "Icircumflex", + "172": "logicalnot", + "246": "odieresis", + "252": "udieresis", + "240": "eth", + "158": "zcaron", + "185": "onesuperior", + "128": "Euro" + }, + "isUnicode": false, + "FontName": "Times-Roman", + "FullName": "Times Roman", + "FamilyName": "Times", + "Weight": "Roman", + "ItalicAngle": "0", + "IsFixedPitch": "false", + "CharacterSet": "ExtendedRoman", + "FontBBox": [ + "-168", + "-218", + "1000", + "898" + ], + "UnderlinePosition": "-100", + "UnderlineThickness": "50", + "Version": "002.00", + "EncodingScheme": "WinAnsiEncoding", + "CapHeight": "662", + "XHeight": "450", + "Ascender": "683", + "Descender": "-217", + "StdHW": "28", + "StdVW": "84", + "StartCharMetrics": "317", + "C": { + "32": 250, + "160": 250, + "33": 333, + "34": 408, + "35": 500, + "36": 500, + "37": 833, + "38": 778, + "146": 333, + "40": 333, + "41": 333, + "42": 500, + "43": 564, + "44": 250, + "45": 333, + "173": 333, + "46": 250, + "47": 278, + "48": 500, + "49": 500, + "50": 500, + "51": 500, + "52": 500, + "53": 500, + "54": 500, + "55": 500, + "56": 500, + "57": 500, + "58": 278, + "59": 278, + "60": 564, + "61": 564, + "62": 564, + "63": 444, + "64": 921, + "65": 722, + "66": 667, + "67": 667, + "68": 722, + "69": 611, + "70": 556, + "71": 722, + "72": 722, + "73": 333, + "74": 389, + "75": 722, + "76": 611, + "77": 889, + "78": 722, + "79": 722, + "80": 556, + "81": 722, + "82": 667, + "83": 556, + "84": 611, + "85": 722, + "86": 722, + "87": 944, + "88": 722, + "89": 722, + "90": 611, + "91": 333, + "92": 278, + "93": 333, + "94": 469, + "95": 500, + "145": 333, + "97": 444, + "98": 500, + "99": 444, + "100": 500, + "101": 444, + "102": 333, + "103": 500, + "104": 500, + "105": 278, + "106": 278, + "107": 500, + "108": 278, + "109": 778, + "110": 500, + "111": 500, + "112": 500, + "113": 500, + "114": 333, + "115": 389, + "116": 278, + "117": 500, + "118": 500, + "119": 722, + "120": 500, + "121": 500, + "122": 444, + "123": 480, + "124": 200, + "125": 480, + "126": 541, + "161": 333, + "162": 500, + "163": 500, + "fraction": 167, + "165": 500, + "131": 500, + "167": 500, + "164": 500, + "39": 180, + "147": 444, + "171": 500, + "139": 333, + "155": 333, + "fi": 556, + "fl": 556, + "150": 500, + "134": 500, + "135": 500, + "183": 250, + "182": 453, + "149": 350, + "130": 333, + "132": 444, + "148": 444, + "187": 500, + "133": 1000, + "137": 1000, + "191": 444, + "96": 333, + "180": 333, + "136": 333, + "152": 333, + "175": 333, + "breve": 333, + "dotaccent": 333, + "168": 333, + "ring": 333, + "184": 333, + "hungarumlaut": 333, + "ogonek": 333, + "caron": 333, + "151": 1000, + "198": 889, + "170": 276, + "Lslash": 611, + "216": 722, + "140": 889, + "186": 310, + "230": 667, + "dotlessi": 278, + "lslash": 278, + "248": 500, + "156": 722, + "223": 500, + "207": 333, + "233": 444, + "abreve": 444, + "uhungarumlaut": 500, + "ecaron": 444, + "159": 722, + "247": 564, + "221": 722, + "194": 722, + "225": 444, + "219": 722, + "253": 500, + "scommaaccent": 389, + "234": 444, + "Uring": 722, + "220": 722, + "aogonek": 444, + "218": 722, + "uogonek": 500, + "203": 611, + "Dcroat": 722, + "commaaccent": 250, + "169": 760, + "Emacron": 611, + "ccaron": 444, + "229": 444, + "Ncommaaccent": 722, + "lacute": 278, + "224": 444, + "Tcommaaccent": 611, + "Cacute": 667, + "227": 444, + "Edotaccent": 611, + "154": 389, + "scedilla": 389, + "237": 278, + "lozenge": 471, + "Rcaron": 667, + "Gcommaaccent": 722, + "251": 500, + "226": 444, + "Amacron": 722, + "rcaron": 333, + "231": 444, + "Zdotaccent": 611, + "222": 556, + "Omacron": 722, + "Racute": 667, + "Sacute": 556, + "dcaron": 588, + "Umacron": 722, + "uring": 500, + "179": 300, + "210": 722, + "192": 722, + "Abreve": 722, + "215": 564, + "250": 500, + "Tcaron": 611, + "partialdiff": 476, + "255": 500, + "Nacute": 722, + "238": 278, + "202": 611, + "228": 444, + "235": 444, + "cacute": 444, + "nacute": 500, + "umacron": 500, + "Ncaron": 722, + "205": 333, + "177": 564, + "166": 200, + "174": 760, + "Gbreve": 722, + "Idotaccent": 333, + "summation": 600, + "200": 611, + "racute": 333, + "omacron": 500, + "Zacute": 611, + "142": 611, + "greaterequal": 549, + "208": 722, + "199": 667, + "lcommaaccent": 278, + "tcaron": 326, + "eogonek": 444, + "Uogonek": 722, + "193": 722, + "196": 722, + "232": 444, + "zacute": 444, + "iogonek": 278, + "211": 722, + "243": 500, + "amacron": 444, + "sacute": 389, + "239": 278, + "212": 722, + "217": 722, + "Delta": 612, + "254": 500, + "178": 300, + "214": 722, + "181": 500, + "236": 278, + "ohungarumlaut": 500, + "Eogonek": 611, + "dcroat": 500, + "190": 750, + "Scedilla": 556, + "lcaron": 344, + "Kcommaaccent": 722, + "Lacute": 611, + "153": 980, + "edotaccent": 444, + "204": 333, + "Imacron": 333, + "Lcaron": 611, + "189": 750, + "lessequal": 549, + "244": 500, + "241": 500, + "Uhungarumlaut": 722, + "201": 611, + "emacron": 444, + "gbreve": 500, + "188": 750, + "138": 556, + "Scommaaccent": 556, + "Ohungarumlaut": 722, + "176": 400, + "242": 500, + "Ccaron": 667, + "249": 500, + "radical": 453, + "Dcaron": 722, + "rcommaaccent": 333, + "209": 722, + "245": 500, + "Rcommaaccent": 667, + "Lcommaaccent": 611, + "195": 722, + "Aogonek": 722, + "197": 722, + "213": 722, + "zdotaccent": 444, + "Ecaron": 611, + "Iogonek": 333, + "kcommaaccent": 500, + "minus": 564, + "206": 333, + "ncaron": 500, + "tcommaaccent": 278, + "172": 564, + "246": 500, + "252": 500, + "notequal": 549, + "gcommaaccent": 500, + "240": 500, + "158": 444, + "ncommaaccent": 500, + "185": 300, + "imacron": 278, + "128": 500 + }, + "CIDtoGID_Compressed": true, + "CIDtoGID": "eJwDAAAAAAE=", + "_version_": 6 +} \ No newline at end of file