update HR dan Payroll
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
{"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}}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,65 +1,112 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
require_once APPPATH.'exceptions/BusinessException.php';
|
||||
|
||||
class Attendancemonitoring extends MY_Controller {
|
||||
|
||||
class Attendancemonitoring extends MY_Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->load->database();
|
||||
$this->load->library('ApprovalService');
|
||||
}
|
||||
|
||||
private function companyId() { return (int)$this->session->userdata('company_id'); }
|
||||
private function userId() { return (int)$this->session->userdata('user_id'); }
|
||||
private function roleId() { return (int)$this->session->userdata('role_id'); }
|
||||
private function can($action) { return is_master_admin_user() || check_permission('hr_attendance',$action) || check_permission('employees',$action); }
|
||||
|
||||
public function index()
|
||||
{
|
||||
$data = [
|
||||
"active_menu" => "attendance_monitoring"
|
||||
];
|
||||
|
||||
$this->load->view('partials/header', $data);
|
||||
$this->load->view('employees/attendance_monitoring');
|
||||
$this->load->view('partials/footer');
|
||||
$employees=$this->db->select('id,employee_code,full_name')->where('is_active',1);
|
||||
if($this->db->field_exists('company_id','k_employees'))$employees->where('company_id',$this->companyId());
|
||||
$data=array('active_menu'=>'attendance_monitoring','employees'=>$employees->order_by('full_name')->get('k_employees')->result(),'permissions'=>array('create'=>$this->can('can_create'),'submit'=>$this->can('can_submit')||$this->can('can_create'),'approve'=>$this->can('can_approve'),'update'=>$this->can('can_update')));
|
||||
$this->load->view('partials/header',$data);$this->load->view('employees/attendance_monitoring',$data);$this->load->view('partials/footer');
|
||||
}
|
||||
|
||||
public function get_data()
|
||||
{
|
||||
$date = $this->input->get('date');
|
||||
|
||||
if(!$date){
|
||||
$date = date('Y-m-d');
|
||||
}
|
||||
|
||||
$data = $this->db->query("
|
||||
SELECT
|
||||
a.*,
|
||||
e.employee_code,
|
||||
e.full_name,
|
||||
s.shift_name
|
||||
FROM k_attendances a
|
||||
LEFT JOIN k_employees e ON e.id = a.employee_id
|
||||
LEFT JOIN k_shifts s ON s.id = a.shift_id
|
||||
WHERE a.attendance_date = '$date'
|
||||
ORDER BY a.checkin_time DESC
|
||||
")->result();
|
||||
|
||||
$rows = [];
|
||||
$no = 1;
|
||||
|
||||
foreach($data as $d){
|
||||
|
||||
$rows[] = [
|
||||
$no++,
|
||||
$d->employee_code,
|
||||
$d->full_name,
|
||||
$d->shift_name,
|
||||
$d->checkin_time,
|
||||
$d->checkout_time,
|
||||
$d->attendance_status
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'data' => $rows
|
||||
]);
|
||||
$date=$this->validDate($this->input->get('date'))?:date('Y-m-d');
|
||||
$companySql=$this->db->field_exists('company_id','k_employees')?' AND e.company_id='.(int)$this->companyId():'';
|
||||
$rows=$this->db->query("SELECT e.employee_code,e.full_name,sh.shift_name,a.id attendance_id,a.checkin_time,a.checkout_time,a.attendance_status
|
||||
FROM k_employee_shift_schedules s JOIN k_employees e ON e.id=s.employee_id LEFT JOIN k_shifts sh ON sh.id=s.shift_id
|
||||
LEFT JOIN k_attendances a ON a.employee_id=s.employee_id AND a.attendance_date=?
|
||||
WHERE DATE(s.start_datetime)=? AND s.status IN('published','done') AND e.is_active=1{$companySql}
|
||||
ORDER BY sh.checkin_time,e.full_name",array($date,$date))->result();
|
||||
$data=array();$no=1;$labels=array('present'=>'Hadir','late'=>'Terlambat','alpha'=>'Alpa','annual'=>'Cuti','sick'=>'Sakit','permit'=>'Izin','maternity'=>'Melahirkan','unpaid'=>'Cuti Tidak Dibayar','holiday'=>'Libur','not_recorded'=>'Belum Absen');
|
||||
foreach($rows as$r){$status=$r->attendance_id?$r->attendance_status:'not_recorded';$class=$status==='present'?'success':($status==='late'?'warning':($status==='alpha'?'danger':'secondary'));$data[]=array($no++,html_escape($r->employee_code),html_escape($r->full_name),html_escape($r->shift_name?:'-'),$r->checkin_time?date('H:i',strtotime($r->checkin_time)):'-',$r->checkout_time?date('H:i',strtotime($r->checkout_time)):'-','<span class="badge bg-'.$class.'">'.html_escape($labels[$status]??ucfirst($status)).'</span>');}
|
||||
return $this->json(true,'Jadwal kehadiran ditemukan.',array('data'=>$data));
|
||||
}
|
||||
|
||||
public function manual_requests()
|
||||
{
|
||||
$q=$this->db->select('r.*,e.employee_code,e.full_name,u.nama creator_name')->from('manual_attendance_requests r')->join('k_employees e','e.id=r.employee_id')->join('users u','u.id=r.created_by','left')->where('r.company_id',$this->companyId());
|
||||
if(trim((string)$this->input->get('status'))!=='')$q->where('r.status',trim((string)$this->input->get('status')));
|
||||
$data=array();foreach($q->order_by('r.id','DESC')->get()->result()as$r){$actions='<button class="btn btn-sm btn-outline-primary manual-detail" data-id="'.$r->id.'"><i class="bi bi-eye"></i></button> ';
|
||||
if(in_array($r->status,array('draft','rejected'),true)&&$this->can('can_create')&&((int)$r->created_by===$this->userId()||$this->can('can_update')))$actions.='<button class="btn btn-sm btn-warning manual-submit" data-id="'.$r->id.'">Ajukan</button> ';
|
||||
if($r->status==='submitted'&&$this->can('can_approve'))$actions.='<button class="btn btn-sm btn-success manual-approve" data-id="'.$r->id.'">Approve</button> <button class="btn btn-sm btn-outline-danger manual-reject" data-id="'.$r->id.'">Reject</button>';
|
||||
$data[]=array('request'=>$r->request_no,'employee'=>$r->employee_code.' - '.$r->full_name,'datetime'=>date('d/m/Y',strtotime($r->attendance_date)).' '.substr($r->attendance_time,0,5),'action'=>$r->attendance_action==='check_in'?'Masuk':'Pulang','reason'=>$r->reason,'status'=>$r->status,'creator'=>$r->creator_name?:'-','actions'=>$actions);}
|
||||
return$this->json(true,'Daftar pengajuan ditemukan.',array('data'=>$data));
|
||||
}
|
||||
|
||||
public function manual_detail($id)
|
||||
{
|
||||
$row=$this->request((int)$id,false);return$row?$this->json(true,'Detail pengajuan.',array('request'=>$row)):$this->json(false,'Pengajuan tidak ditemukan.',array(),404);
|
||||
}
|
||||
|
||||
public function save_manual()
|
||||
{
|
||||
try{$employeeId=(int)$this->input->post('employee_id');$date=$this->validDate($this->input->post('attendance_date'));$time=$this->validTime($this->input->post('attendance_time'));$action=$this->input->post('attendance_action');$reason=trim((string)$this->input->post('reason',true));
|
||||
if(!$employeeId||!$date||!$time||!in_array($action,array('check_in','check_out'),true)||$reason==='')throw new BusinessException('Karyawan, tanggal, jam, jenis absen, dan alasan wajib diisi.');
|
||||
$employee=$this->db->where('id',$employeeId)->where('is_active',1);if($this->db->field_exists('company_id','k_employees'))$employee->where('company_id',$this->companyId());if(!$employee->get('k_employees')->row())throw new BusinessException('Karyawan aktif tidak ditemukan pada perusahaan ini.');
|
||||
if($this->isLocked($date))throw new BusinessException('Periode absensi sudah dikunci untuk payroll.');
|
||||
$duplicate=$this->db->where(array('company_id'=>$this->companyId(),'employee_id'=>$employeeId,'attendance_date'=>$date,'attendance_action'=>$action))->where_in('status',array('draft','submitted','approved'))->count_all_results('manual_attendance_requests');if($duplicate)throw new BusinessException('Pengajuan aktif untuk karyawan, tanggal, dan jenis absen tersebut sudah ada.');
|
||||
$last=(int)$this->db->select_max('id','last_id')->get('manual_attendance_requests')->row()->last_id;$no='MAN-'.date('Ymd',strtotime($date)).'-'.str_pad((string)($last+1),5,'0',STR_PAD_LEFT);
|
||||
$this->db->insert('manual_attendance_requests',array('company_id'=>$this->companyId(),'request_no'=>$no,'employee_id'=>$employeeId,'attendance_date'=>$date,'attendance_time'=>$time,'attendance_action'=>$action,'reason'=>$reason,'status'=>'draft','created_by'=>$this->userId(),'created_at'=>date('Y-m-d H:i:s')));$id=(int)$this->db->insert_id();log_activity('manual_attendance','create','Membuat draft absensi manual '.$no,'success');return$this->json(true,'Draft absensi manual berhasil dibuat.',array('id'=>$id));
|
||||
}catch(Throwable$e){return$this->json(false,$e->getMessage(),array(),422);}
|
||||
}
|
||||
|
||||
public function submit_manual($id)
|
||||
{
|
||||
return$this->transaction(function()use($id){$r=$this->request((int)$id,true);if(!$r)throw new BusinessException('Pengajuan tidak ditemukan.');if(!in_array($r->status,array('draft','rejected'),true))throw new BusinessException('Hanya draft atau pengajuan ditolak yang dapat diajukan.');if((int)$r->created_by!==$this->userId()&&!$this->can('can_update'))throw new BusinessException('Anda tidak berhak mengajukan dokumen ini.');if($this->isLocked($r->attendance_date))throw new BusinessException('Periode absensi sudah dikunci untuk payroll.');$approval=$this->approvalservice->create('hr','manual_attendance',$r->id,0,$this->userId(),array('request_no'=>$r->request_no,'employee_id'=>$r->employee_id,'attendance_date'=>$r->attendance_date,'attendance_time'=>$r->attendance_time,'attendance_action'=>$r->attendance_action,'reason'=>$r->reason));$this->db->where('id',$r->id)->update('manual_attendance_requests',array('status'=>'submitted','approval_request_id'=>$approval->id,'submitted_by'=>$this->userId(),'submitted_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>null,'updated_at'=>date('Y-m-d H:i:s')));return$this->json(true,'Pengajuan dikirim untuk persetujuan.');});
|
||||
}
|
||||
|
||||
public function approve_manual($id)
|
||||
{
|
||||
return$this->transaction(function()use($id){$r=$this->request((int)$id,true);if(!$r||$r->status!=='submitted')throw new BusinessException('Pengajuan tidak sedang menunggu persetujuan.');if($this->isLocked($r->attendance_date))throw new BusinessException('Periode absensi sudah dikunci untuk payroll.');$approval=$this->approvalservice->act($r->approval_request_id,'approved',$this->userId(),$this->roleId(),trim((string)$this->input->post('notes',true))?:null);if($approval->status==='approved'){$attendanceId=$this->applyAttendance($r);$this->db->where('id',$r->id)->update('manual_attendance_requests',array('status'=>'approved','approved_by'=>$this->userId(),'approved_at'=>date('Y-m-d H:i:s'),'applied_attendance_id'=>$attendanceId,'updated_at'=>date('Y-m-d H:i:s')));}return$this->json(true,'Absensi manual disetujui dan masuk ke daftar kehadiran.');});
|
||||
}
|
||||
|
||||
public function reject_manual($id)
|
||||
{
|
||||
return$this->transaction(function()use($id){$reason=trim((string)$this->input->post('reason',true));if($reason==='')throw new BusinessException('Alasan penolakan wajib diisi.');$r=$this->request((int)$id,true);if(!$r||$r->status!=='submitted')throw new BusinessException('Pengajuan tidak sedang menunggu persetujuan.');$this->approvalservice->act($r->approval_request_id,'rejected',$this->userId(),$this->roleId(),$reason);$this->db->where('id',$r->id)->update('manual_attendance_requests',array('status'=>'rejected','rejected_by'=>$this->userId(),'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>$reason,'updated_at'=>date('Y-m-d H:i:s')));return$this->json(true,'Pengajuan absensi manual ditolak.');});
|
||||
}
|
||||
|
||||
private function applyAttendance($request)
|
||||
{
|
||||
$schedule=$this->db->select('s.*,sh.late_tolerance_minutes')->from('k_employee_shift_schedules s')->join('k_shifts sh','sh.id=s.shift_id','left')->where('s.employee_id',$request->employee_id)->where('DATE(s.start_datetime)',$request->attendance_date)->where_in('s.status',array('published','done'))->order_by('s.id')->get()->row();if(!$schedule)throw new BusinessException('Jadwal kerja karyawan pada tanggal tersebut tidak ditemukan atau belum dipublikasikan.');
|
||||
$attendance=$this->db->query('SELECT * FROM k_attendances WHERE employee_id=? AND attendance_date=? FOR UPDATE',array($request->employee_id,$request->attendance_date))->row();$dateTime=$request->attendance_date.' '.$request->attendance_time;$note='[MANUAL '.$request->request_no.'] '.$request->reason;
|
||||
if($request->attendance_action==='check_in'){
|
||||
if($attendance&&!empty($attendance->checkin_time)&&$attendance->attendance_status!=='alpha')throw new BusinessException('Jam masuk sudah tercatat. Gunakan alur koreksi jika waktunya perlu diubah.');
|
||||
$late=max(0,(int)floor((strtotime($dateTime)-(strtotime($schedule->start_datetime)+((int)$schedule->late_tolerance_minutes*60)))/60));$data=array('checkin_time'=>$dateTime,'late_minutes'=>$late,'attendance_status'=>$late>0?'late':'present','shift_id'=>$schedule->shift_id,'notes'=>trim(($attendance&&$attendance->notes?$attendance->notes."\n":'').$note));
|
||||
if($attendance){$this->db->where('id',$attendance->id)->update('k_attendances',$data);return(int)$attendance->id;}$data['employee_id']=$request->employee_id;$data['attendance_date']=$request->attendance_date;$data['created_at']=date('Y-m-d H:i:s');$this->db->insert('k_attendances',$data);return(int)$this->db->insert_id();
|
||||
}
|
||||
if(!$attendance||empty($attendance->checkin_time))throw new BusinessException('Jam masuk belum tercatat. Ajukan jam masuk terlebih dahulu.');if(!empty($attendance->checkout_time))throw new BusinessException('Jam pulang sudah tercatat. Gunakan alur koreksi jika waktunya perlu diubah.');if(strtotime($dateTime)<=strtotime($attendance->checkin_time))throw new BusinessException('Jam pulang harus setelah jam masuk.');
|
||||
$work=round((strtotime($dateTime)-strtotime($attendance->checkin_time))/3600,2);$early=max(0,(int)floor((strtotime($schedule->end_datetime)-strtotime($dateTime))/60));$overtime=max(0,round((strtotime($dateTime)-strtotime($schedule->end_datetime))/3600,2));$this->db->where('id',$attendance->id)->update('k_attendances',array('checkout_time'=>$dateTime,'work_hours'=>$work,'early_leave_minutes'=>$early,'overtime_hours'=>$overtime,'notes'=>trim(($attendance->notes?$attendance->notes."\n":'').$note)));return(int)$attendance->id;
|
||||
}
|
||||
|
||||
private function request($id,$lock=false){return$this->db->query('SELECT r.*,e.employee_code,e.full_name FROM manual_attendance_requests r JOIN k_employees e ON e.id=r.employee_id WHERE r.id=? AND r.company_id=?'.($lock?' FOR UPDATE':''),array($id,$this->companyId()))->row();}
|
||||
private function isLocked($date)
|
||||
{
|
||||
if ($this->db->table_exists('payroll_attendance_locks')) {
|
||||
$range = $this->db->where(array('company_id'=>$this->companyId(),'status'=>'locked'))
|
||||
->where('start_date <=',$date)->where('end_date >=',$date)
|
||||
->get('payroll_attendance_locks')->row();
|
||||
if ($range) return true;
|
||||
}
|
||||
return (bool)$this->db->where(array('period'=>substr($date,0,7),'status'=>'locked'))->get('attendance_period_locks')->row();
|
||||
}
|
||||
private function validDate($value){$d=DateTime::createFromFormat('Y-m-d',(string)$value);return$d&&$d->format('Y-m-d')===$value?$value:null;}
|
||||
private function validTime($value){foreach(array('H:i','H:i:s')as$f){$d=DateTime::createFromFormat($f,(string)$value);if($d&&$d->format($f)===$value)return$d->format('H:i:s');}return null;}
|
||||
private function transaction($callback){$this->db->trans_begin();try{$response=$callback();if($this->db->trans_status()===false)throw new RuntimeException('Transaksi database gagal.');$this->db->trans_commit();return$response;}catch(Throwable$e){$this->db->trans_rollback();return$this->json(false,$e->getMessage(),array(),$e instanceof BusinessException?422:500);}}
|
||||
private function json($status,$message,$data=array(),$code=200){return$this->output->set_status_header($code)->set_content_type('application/json')->set_output(json_encode(array_merge(array('status'=>$status,'message'=>$message),$data)));}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ class Auth extends CI_Controller {
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->load->library('UserSessionService');
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -13,8 +14,9 @@ class Auth extends CI_Controller {
|
||||
if ($this->session->userdata('logged_in')) {
|
||||
redirect('dashboard');
|
||||
}
|
||||
|
||||
$this->load->view('auth/login');
|
||||
$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()
|
||||
@@ -41,10 +43,24 @@ class Auth extends CI_Controller {
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
@@ -114,6 +137,7 @@ class Auth extends CI_Controller {
|
||||
'success'
|
||||
);
|
||||
|
||||
$this->usersessionservice->endCurrent('logged_out', 'Logout oleh pengguna.');
|
||||
$this->session->sess_destroy();
|
||||
redirect('auth');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
|
||||
@@ -1,15 +1,142 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
require_once APPPATH.'exceptions/BusinessException.php';
|
||||
|
||||
class Hrpayroll extends MY_Controller
|
||||
{
|
||||
public function __construct(){parent::__construct();$this->requirePermission('employees');$this->load->database();$this->load->library('PayrollService');}
|
||||
private function uid(){return(int)$this->session->userdata('user_id');}private function go($ok,$m){$this->session->set_flashdata($ok?'success':'error',$m);redirect('hrpayroll');}
|
||||
public function index(){$d=array('active_menu'=>'hr_payroll');$d['periods']=$this->db->select('p.*,COUNT(x.id) employee_count,COALESCE(SUM(x.gross_salary),0) gross,COALESCE(SUM(x.total_deductions),0) deductions,COALESCE(SUM(x.total_salary),0) net')->from('k_payroll_periods p')->join('k_payrolls x','x.payroll_period_id=p.id','left')->group_by('p.id')->order_by('p.start_date','DESC')->get()->result();$d['components']=$this->db->order_by('code')->get('payroll_components')->result();$d['locks']=$this->db->order_by('period','DESC')->get('attendance_period_locks')->result();$d['cash_accounts']=$this->db->where('is_active',1)->get('cash_accounts')->result();$this->load->view('partials/header',$d);$this->load->view('hr_payroll/index',$d);$this->load->view('partials/footer');}
|
||||
public function lock_attendance(){try{$period=$this->input->post('period');if($this->db->get_where('attendance_period_locks',array('period'=>$period,'branch_id'=>null))->row())$this->db->where('period',$period)->where('branch_id IS NULL',null,false)->update('attendance_period_locks',array('status'=>'locked','locked_by'=>$this->uid(),'locked_at'=>date('Y-m-d H:i:s'),'notes'=>$this->input->post('notes',true)));else$this->db->insert('attendance_period_locks',array('period'=>$period,'status'=>'locked','locked_by'=>$this->uid(),'locked_at'=>date('Y-m-d H:i:s'),'notes'=>$this->input->post('notes',true)));$this->go(true,'Absensi '.$period.' dikunci.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
|
||||
public function calculate(){try{$id=$this->payrollservice->calculate($this->input->post('month'),$this->uid());$this->go(true,'Draft payroll dihitung. Periode #'.$id);}catch(Throwable$e){$this->go(false,$e->getMessage());}}
|
||||
public function workflow($id,$action){try{$this->payrollservice->workflow($id,$action,$this->uid());$this->go(true,'Workflow payroll berhasil: '.$action);}catch(Throwable$e){$this->go(false,$e->getMessage());}}
|
||||
public function post($id){try{$this->payrollservice->post($id,$this->input->post('date'),$this->uid());$this->go(true,'Payroll berhasil diposting ke jurnal.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
|
||||
public function pay($id){try{$this->payrollservice->pay($id,$this->input->post('date'),(int)$this->input->post('cash_account_id'),$this->uid());$this->go(true,'Payroll berhasil dibayar dan jurnal pembayaran diposting.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
|
||||
public function component(){try{$id=(int)$this->input->post('id');$data=array('code'=>strtoupper(trim($this->input->post('code',true))),'name'=>$this->input->post('name',true),'component_type'=>$this->input->post('component_type'),'category'=>$this->input->post('category'),'formula_expression'=>$this->input->post('formula_expression',true)?:null,'fixed_amount'=>$this->input->post('fixed_amount')!==''?(float)$this->input->post('fixed_amount'):null,'taxable'=>$this->input->post('taxable')?1:0,'prorated'=>$this->input->post('prorated')?1:0,'expense_account_id'=>$this->input->post('expense_account_id')?:null,'liability_account_id'=>$this->input->post('liability_account_id')?:null,'is_active'=>1);if($id)$this->db->where('id',$id)->update('payroll_components',$data);else$this->db->insert('payroll_components',$data);$this->go(true,'Komponen payroll disimpan.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
|
||||
public function slip($payrollId){$p=$this->db->select('x.*,e.full_name,e.employee_code,r.period_name,r.workflow_status')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->join('k_payroll_periods r','r.id=x.payroll_period_id')->where('x.id',$payrollId)->get()->row();if(!$p||!in_array($p->workflow_status,array('final','posted','paid'),true))show_error('Slip belum tersedia.',422);$lines=$this->db->where('payroll_id',$payrollId)->get('payroll_calculation_lines')->result();$this->load->view('hr_payroll/slip',array('payroll'=>$p,'lines'=>$lines));}
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();$this->load->database();$this->load->library(array('PayrollService','AuditService','FormulaService'));
|
||||
if(is_file(FCPATH.'vendor/autoload.php'))require_once FCPATH.'vendor/autoload.php';
|
||||
}
|
||||
private function uid(){return(int)$this->session->userdata('user_id');}
|
||||
private function company(){return(int)$this->session->userdata('company_id');}
|
||||
private function allSalary(){return is_master_admin_user()||check_permission('payroll','can_manage_all_salary');}
|
||||
private function can($action){return is_master_admin_user()||check_permission('payroll',$action)||check_permission('employees',$action);}
|
||||
private function go($ok,$message,$section='periods'){$this->session->set_flashdata($ok?'success':'error',$message);redirect('hrpayroll?section='.$section);}
|
||||
private function validDate($value){$date=DateTime::createFromFormat('Y-m-d',(string)$value);return$date&&$date->format('Y-m-d')===$value?$value:null;}
|
||||
private function jsonError(Throwable$e){return json_response(false,$e->getMessage(),array(),$e instanceof BusinessException?422:500);}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$section=$this->input->get('section',true)?:'periods';if(!in_array($section,array('periods','salaries','settings','adjustments'),true))$section='periods';
|
||||
$company=$this->company();$visible=$this->allSalary()?'1=1':'COALESCE(e.salary_restricted,0)=0';
|
||||
$periods=$this->db->query("SELECT p.*,COUNT(CASE WHEN {$visible} THEN x.id END) employee_count,COALESCE(SUM(CASE WHEN {$visible} THEN x.gross_salary ELSE 0 END),0) gross,COALESCE(SUM(CASE WHEN {$visible} THEN x.total_deductions ELSE 0 END),0) deductions,COALESCE(SUM(CASE WHEN {$visible} THEN x.total_salary ELSE 0 END),0) net,l.status attendance_lock_status FROM k_payroll_periods p LEFT JOIN k_payrolls x ON x.payroll_period_id=p.id LEFT JOIN k_employees e ON e.id=x.employee_id LEFT JOIN payroll_attendance_locks l ON l.payroll_period_id=p.id AND l.company_id=p.company_id WHERE p.company_id=? GROUP BY p.id,l.status ORDER BY COALESCE(p.attendance_start_date,p.start_date) DESC",array($company))->result();
|
||||
$components=$this->db->where('company_id',$company)->order_by('component_type')->order_by('code')->get('payroll_components')->result();
|
||||
$employees=$this->db->select('e.id,e.employee_code,e.full_name,e.department_id,e.position_id,e.is_active,e.salary_restricted,d.department_name,p.position_name')->from('k_employees e')->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')->where('e.company_id',$company)->order_by('e.full_name')->get()->result();
|
||||
if(!$this->allSalary())$employees=array_values(array_filter($employees,function($e){return empty($e->salary_restricted);}));
|
||||
$salaryRows=$this->salarySummaries($employees,$components);
|
||||
$positions=$this->db->select('p.id,p.code,p.position_name,p.department_id,d.department_name')->from('k_positions p')->join('k_departments d','d.id=p.department_id','left')->where('p.is_active',1)->order_by('p.position_name')->get()->result();
|
||||
$departments=$this->db->where('is_active',1)->order_by('department_name')->get('k_departments')->result();
|
||||
$positionAssignments=$this->db->select('x.*,p.position_name,c.code component_code,c.name component_name,c.component_type')->from('position_payroll_components x')->join('k_positions p','p.id=x.position_id')->join('payroll_components c','c.id=x.component_id')->where('x.company_id',$company)->order_by('p.position_name')->order_by('c.name')->get()->result();
|
||||
$rules=$this->db->select('r.*,c.code component_code,c.name component_name')->from('payroll_attendance_rules r')->join('payroll_components c','c.id=r.component_id','left')->where('r.company_id',$company)->order_by('r.condition_type')->order_by('r.effective_from','DESC')->get()->result();
|
||||
$adjustments=$this->db->select('a.*,p.period_name,p.workflow_status,e.employee_code,e.full_name,c.code component_code,c.name component_name')->from('payroll_period_adjustments a')->join('k_payroll_periods p','p.id=a.payroll_period_id')->join('k_employees e','e.id=a.employee_id')->join('payroll_components c','c.id=a.component_id')->where('a.company_id',$company)->where('a.is_active',1)->order_by('a.id','DESC')->get()->result();
|
||||
if(!$this->allSalary())$adjustments=array_values(array_filter($adjustments,function($row)use($employees){foreach($employees as$employee)if($employee->id==$row->employee_id)return true;return false;}));
|
||||
$cashQuery=$this->db->where('is_active',1);if($this->db->field_exists('company_id','cash_accounts'))$cashQuery->where('company_id',$company);$cash=$cashQuery->order_by('name')->get('cash_accounts')->result();
|
||||
$accounts=$this->db->where(array('company_id'=>$company,'is_active'=>1))->order_by('kode_akun')->get('accounts')->result();
|
||||
$settings=$this->db->get_where('payroll_settings',array('company_id'=>$company))->row();
|
||||
$data=array('active_menu'=>'hr_payroll','payroll_section'=>$section,'periods'=>$periods,'components'=>$components,'employees'=>$employees,'salary_rows'=>$salaryRows,'positions'=>$positions,'departments'=>$departments,'position_assignments'=>$positionAssignments,'attendance_rules'=>$rules,'adjustments'=>$adjustments,'cash_accounts'=>$cash,'accounts'=>$accounts,'payroll_settings'=>$settings,'can_manage_all_salary'=>$this->allSalary(),'permissions'=>array('create'=>$this->can('can_create'),'update'=>$this->can('can_update'),'approve'=>$this->can('can_approve'),'post'=>$this->can('can_post'),'export'=>$this->can('can_export')));
|
||||
$this->load->view('partials/header',$data);$this->load->view('hr_payroll/index',$data);$this->load->view('partials/footer');
|
||||
}
|
||||
|
||||
private function salarySummaries($employees,$components)
|
||||
{
|
||||
$componentMap=array();foreach($components as$c)$componentMap[$c->id]=$c;$today=date('Y-m-d');$rows=array();
|
||||
foreach($employees as$employee){$direct=$this->db->where(array('company_id'=>$this->company(),'employee_id'=>$employee->id,'is_active'=>1))->where('effective_from <=',$today)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$today)->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->result();$position=$this->db->where(array('company_id'=>$this->company(),'position_id'=>$employee->position_id,'is_active'=>1))->where('effective_from <=',$today)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$today)->group_end()->order_by('effective_from','DESC')->get('position_payroll_components')->result();$resolved=array();foreach($position as$x)if(!isset($resolved[$x->component_id]))$resolved[$x->component_id]=$x;foreach($direct as$x)$resolved[$x->component_id]=$x;$earning=0;$deduction=0;$effective=null;foreach($resolved as$x){if(!isset($componentMap[$x->component_id]))continue;$value=(float)($x->amount_override!==null?$x->amount_override:($componentMap[$x->component_id]->default_value!==null?$componentMap[$x->component_id]->default_value:$componentMap[$x->component_id]->fixed_amount));if($x->calculation_method==='fixed'){if($componentMap[$x->component_id]->component_type==='earning')$earning+=$value;elseif($componentMap[$x->component_id]->component_type==='deduction')$deduction+=$value;}if(!$effective||$x->effective_from>$effective)$effective=$x->effective_from;}$employee->fixed_earnings=$earning;$employee->fixed_deductions=$deduction;$employee->estimated_net=$earning-$deduction;$employee->effective_from=$effective;$rows[]=$employee;}return$rows;
|
||||
}
|
||||
|
||||
public function create_period()
|
||||
{
|
||||
try{if(!$this->can('can_create'))throw new BusinessException('Anda tidak mempunyai permission membuat periode payroll.');$name=trim((string)$this->input->post('period_name',true));$start=$this->validDate($this->input->post('attendance_start_date'));$end=$this->validDate($this->input->post('attendance_end_date'));$payment=$this->validDate($this->input->post('payment_date'));if($name===''||!$start||!$end||!$payment)throw new BusinessException('Nama periode, rentang kehadiran, dan tanggal pembayaran wajib diisi.');if($end<$start)throw new BusinessException('Tanggal akhir kehadiran tidak boleh sebelum tanggal awal.');$overlap=(int)$this->db->query("SELECT COUNT(*) total FROM k_payroll_periods WHERE company_id=? AND workflow_status<>'cancelled' AND COALESCE(attendance_start_date,start_date)<=? AND COALESCE(attendance_end_date,end_date)>=?",array($this->company(),$end,$start))->row()->total;if($overlap)throw new BusinessException('Rentang kehadiran tumpang tindih dengan periode payroll lain.');$row=array('company_id'=>$this->company(),'period_name'=>$name,'start_date'=>$start,'end_date'=>$end,'attendance_start_date'=>$start,'attendance_end_date'=>$end,'payment_date'=>$payment,'notes'=>trim((string)$this->input->post('notes',true))?:null,'status'=>'draft','workflow_status'=>'draft','generated_at'=>date('Y-m-d H:i:s'));$this->db->insert('k_payroll_periods',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','payroll_period',$id,'create',null,$row,$this->uid());$this->go(true,'Periode payroll berhasil dibuat.');}catch(Throwable$e){$this->go(false,$e->getMessage());}
|
||||
}
|
||||
|
||||
public function lock_attendance()
|
||||
{
|
||||
try{$id=(int)$this->input->post('period_id');$period=$this->period($id);if($period->workflow_status!=='draft')throw new BusinessException('Kehadiran hanya dapat dikunci ketika payroll masih Draft.');$start=$period->attendance_start_date?:$period->start_date;$end=$period->attendance_end_date?:$period->end_date;$pending=array();if($this->db->table_exists('manual_attendance_requests')){$n=$this->db->where('company_id',$this->company())->where('status','submitted')->where('attendance_date >=',$start)->where('attendance_date <=',$end)->count_all_results('manual_attendance_requests');if($n)$pending[]=$n.' absensi manual';}if($this->db->table_exists('k_leave_requests')){$n=$this->db->where('company_id',$this->company())->where('workflow_status','submitted')->where('start_date <=',$end)->where('end_date >=',$start)->count_all_results('k_leave_requests');if($n)$pending[]=$n.' cuti/izin';}if($this->db->table_exists('attendance_corrections')){$n=$this->db->from('attendance_corrections c')->join('k_attendances a','a.id=c.attendance_id')->join('k_employees e','e.id=a.employee_id')->where(array('e.company_id'=>$this->company(),'c.status'=>'submitted'))->where('a.attendance_date >=',$start)->where('a.attendance_date <=',$end)->count_all_results();if($n)$pending[]=$n.' koreksi absensi';}if($pending)throw new BusinessException('Periode belum dapat dikunci karena masih ada '.implode(', ',$pending).' menunggu approval.');$existing=$this->db->get_where('payroll_attendance_locks',array('company_id'=>$this->company(),'payroll_period_id'=>$id))->row();$row=array('company_id'=>$this->company(),'payroll_period_id'=>$id,'start_date'=>$start,'end_date'=>$end,'status'=>'locked','locked_by'=>$this->uid(),'locked_at'=>date('Y-m-d H:i:s'),'notes'=>trim((string)$this->input->post('notes',true))?:null);if($existing)$this->db->where('id',$existing->id)->update('payroll_attendance_locks',$row);else$this->db->insert('payroll_attendance_locks',$row);$this->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('k_payroll_periods',array('attendance_locked_by'=>$this->uid(),'attendance_locked_at'=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s')));$this->auditservice->record('payroll','attendance_lock',$id,'lock',null,$row,$this->uid());$this->go(true,'Kehadiran '.$start.' sampai '.$end.' berhasil dikunci.');}catch(Throwable$e){$this->go(false,$e->getMessage());}
|
||||
}
|
||||
|
||||
public function calculate(){try{$id=$this->payrollservice->calculate((int)$this->input->post('period_id'),$this->uid(),$this->allSalary(),$this->company());$this->go(true,'Draft payroll periode #'.$id.' berhasil dihitung.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
|
||||
public function workflow($id,$action){try{$this->payrollservice->workflow($id,$action,$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Tahap payroll berhasil diperbarui.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
|
||||
public function post($id){try{$this->payrollservice->post($id,$this->input->post('date'),$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Payroll berhasil diposting ke jurnal.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
|
||||
public function pay($id){try{$this->payrollservice->pay($id,$this->input->post('date'),(int)$this->input->post('cash_account_id'),$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Payroll berhasil dibayar.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
|
||||
|
||||
public function period_detail($id)
|
||||
{
|
||||
$period=$this->period($id,false);if(!$period)return json_response(false,'Periode payroll tidak ditemukan.',array(),404);$q=$this->db->select('p.id,p.basic_salary,p.gross_salary,p.total_deductions,p.total_salary,p.total_present,p.total_alpha,p.total_overtime_hours,e.employee_code,e.full_name,e.salary_restricted')->from('k_payrolls p')->join('k_employees e','e.id=p.employee_id')->where(array('p.payroll_period_id'=>$period->id,'p.company_id'=>$this->company()));if(!$this->allSalary())$q->where('e.salary_restricted',0);return json_response(true,'Rincian payroll.',array('period'=>$period,'rows'=>$q->order_by('e.full_name')->get()->result()));
|
||||
}
|
||||
|
||||
public function salary_detail($employeeId)
|
||||
{
|
||||
try{$employee=$this->employee((int)$employeeId);if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$lines=$this->db->select('x.*,c.code component_code,c.name component_name,c.component_type')->from('employee_payroll_components x')->join('payroll_components c','c.id=x.component_id')->where(array('x.company_id'=>$this->company(),'x.employee_id'=>$employee->id))->order_by('x.effective_from','DESC')->get()->result();return json_response(true,'Struktur gaji.',array('employee'=>$employee,'lines'=>$lines));}catch(Throwable$e){return$this->jsonError($e);}
|
||||
}
|
||||
|
||||
public function save_salary_line()
|
||||
{
|
||||
try{if(!$this->can('can_update'))throw new BusinessException('Anda tidak mempunyai permission mengubah struktur gaji.');$employee=$this->employee((int)$this->input->post('employee_id'));if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$row=$this->assignmentInput('employee',$employee->id);$duplicate=$this->db->get_where('employee_payroll_components',array('company_id'=>$this->company(),'employee_id'=>$employee->id,'component_id'=>$row['component_id'],'effective_from'=>$row['effective_from']))->row();if($duplicate)throw new BusinessException('Komponen dengan tanggal mulai yang sama sudah ada. Gunakan tanggal efektif baru agar histori tidak tertimpa.');$this->db->trans_begin();$previous=$this->db->where(array('company_id'=>$this->company(),'employee_id'=>$employee->id,'component_id'=>$row['component_id'],'is_active'=>1))->where('effective_from <',$row['effective_from'])->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$row['effective_from'])->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->row();if($previous)$this->db->where('id',$previous->id)->update('employee_payroll_components',array('effective_to'=>date('Y-m-d',strtotime($row['effective_from'].' -1 day')),'updated_at'=>date('Y-m-d H:i:s')));$this->db->insert('employee_payroll_components',$row);$id=(int)$this->db->insert_id();if($row['component_id']===$this->basicComponentId())$this->db->where(array('id'=>$employee->id,'company_id'=>$this->company()))->update('k_employees',array('basic_salary'=>$row['amount_override']));$this->auditservice->record('payroll','employee_salary_component',$id,'create_version',$previous,$row,$this->uid());$this->db->trans_commit();return json_response(true,'Versi struktur gaji berhasil disimpan.');}catch(Throwable$e){$this->db->trans_rollback();return$this->jsonError($e);}
|
||||
}
|
||||
|
||||
public function deactivate_salary_line($id)
|
||||
{
|
||||
try{$line=$this->db->get_where('employee_payroll_components',array('id'=>(int)$id,'company_id'=>$this->company()))->row();if(!$line)throw new BusinessException('Komponen gaji tidak ditemukan.');$employee=$this->employee($line->employee_id);if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$update=array('is_active'=>0,'effective_to'=>date('Y-m-d'),'updated_at'=>date('Y-m-d H:i:s'));$this->db->where('id',$line->id)->update('employee_payroll_components',$update);$this->auditservice->record('payroll','employee_salary_component',$line->id,'deactivate',$line,$update,$this->uid());return json_response(true,'Komponen dinonaktifkan tanpa menghapus histori.');}catch(Throwable$e){return$this->jsonError($e);}
|
||||
}
|
||||
|
||||
public function component()
|
||||
{
|
||||
try{if(!$this->can('can_update'))throw new BusinessException('Anda tidak mempunyai permission mengelola komponen.');$id=(int)$this->input->post('id');$code=strtoupper(trim((string)$this->input->post('code',true)));$name=trim((string)$this->input->post('name',true));$type=$this->input->post('component_type');$method=$this->method($this->input->post('calculation_method'));$value=$this->decimal($this->input->post('default_value'));if($code===''||$name===''||!in_array($type,array('earning','deduction','employer_cost'),true))throw new BusinessException('Kode, nama, dan jenis komponen wajib diisi.');$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),'code'=>$code,'name'=>$name,'component_type'=>$type,'category'=>$this->input->post('category')?:'other','calculation_method'=>$method,'default_value'=>$value,'fixed_amount'=>$value,'formula_expression'=>$formula,'taxable'=>$this->input->post('taxable')?1:0,'prorated'=>$this->input->post('prorated')?1:0,'expense_account_id'=>$this->input->post('expense_account_id')?:null,'liability_account_id'=>$this->input->post('liability_account_id')?:null,'is_active'=>$this->input->post('is_active')?1:0,'effective_from'=>$this->validDate($this->input->post('effective_from'))?:date('Y-m-d'),'effective_to'=>$this->validDate($this->input->post('effective_to'))?:null,'updated_at'=>date('Y-m-d H:i:s'));$duplicate=$this->db->where(array('company_id'=>$this->company(),'code'=>$code))->where('id !=',$id)->count_all_results('payroll_components');if($duplicate)throw new BusinessException('Kode komponen sudah digunakan.');if($id){$before=$this->db->get_where('payroll_components',array('id'=>$id,'company_id'=>$this->company()))->row();if(!$before)throw new BusinessException('Komponen tidak ditemukan.');$this->db->where('id',$id)->update('payroll_components',$row);}else{$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('payroll_components',$row);$id=(int)$this->db->insert_id();$before=null;}$this->auditservice->record('payroll','payroll_component',$id,$before?'update':'create',$before,$row,$this->uid());$this->go(true,'Komponen payroll berhasil disimpan.','settings');}catch(Throwable$e){$this->go(false,$e->getMessage(),'settings');}
|
||||
}
|
||||
|
||||
public function save_position_line()
|
||||
{
|
||||
try{$positionId=(int)$this->input->post('position_id');if(!$positionId||!$this->db->where('id',$positionId)->count_all_results('k_positions'))throw new BusinessException('Posisi tidak ditemukan.');$row=$this->assignmentInput('position',$positionId);if($this->db->get_where('position_payroll_components',array('company_id'=>$this->company(),'position_id'=>$positionId,'component_id'=>$row['component_id'],'effective_from'=>$row['effective_from']))->row())throw new BusinessException('Template komponen dengan tanggal efektif yang sama sudah ada.');$this->db->insert('position_payroll_components',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','position_salary_component',$id,'create',null,$row,$this->uid());$this->go(true,'Template posisi berhasil ditambahkan.','settings');}catch(Throwable$e){$this->go(false,$e->getMessage(),'settings');}
|
||||
}
|
||||
|
||||
public function save_attendance_rule()
|
||||
{
|
||||
try{$condition=$this->input->post('condition_type');$method=$this->attendanceMethod($this->input->post('calculation_method'));$start=$this->validDate($this->input->post('effective_from'));if(!in_array($condition,array('alpha','late','early_leave','unpaid','missing_checkin','missing_checkout'),true)||!$start)throw new BusinessException('Kondisi dan tanggal efektif aturan tidak valid.');$componentId=$this->input->post('component_id')?(int)$this->input->post('component_id'):null;if($method!=='none'&&!$componentId)throw new BusinessException('Komponen potongan wajib dipilih bila aturan menghasilkan potongan.');$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),'component_id'=>$componentId,'rule_name'=>trim((string)$this->input->post('rule_name',true)),'condition_type'=>$condition,'calculation_method'=>$method,'tolerance_minutes'=>max(0,(int)$this->input->post('tolerance_minutes')),'rate_value'=>$this->decimal($this->input->post('rate_value')),'maximum_amount'=>$this->input->post('maximum_amount')!==''?$this->decimal($this->input->post('maximum_amount')):null,'formula_expression'=>$formula,'effective_from'=>$start,'effective_to'=>$this->validDate($this->input->post('effective_to'))?:null,'is_active'=>$this->input->post('is_active')?1:0,'notes'=>trim((string)$this->input->post('notes',true))?:null,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));if($row['rule_name']==='')throw new BusinessException('Nama aturan wajib diisi.');$this->db->insert('payroll_attendance_rules',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','attendance_rule',$id,'create_version',null,$row,$this->uid());$this->go(true,'Versi aturan kehadiran berhasil disimpan.','settings');}catch(Throwable$e){$this->go(false,$e->getMessage(),'settings');}
|
||||
}
|
||||
|
||||
public function save_adjustment()
|
||||
{
|
||||
try{$period=$this->period((int)$this->input->post('payroll_period_id'),false);if($period->workflow_status!=='draft')throw new BusinessException('Penyesuaian hanya dapat dibuat pada payroll Draft.');$employee=$this->employee((int)$this->input->post('employee_id'));if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$component=$this->componentRow((int)$this->input->post('component_id'));$type=$this->input->post('adjustment_type');if(!in_array($type,array('earning','deduction'),true))throw new BusinessException('Jenis penyesuaian tidak valid.');$amount=$this->decimal($this->input->post('amount'));$notes=trim((string)$this->input->post('notes',true));if($amount<=0||$notes==='')throw new BusinessException('Nominal harus lebih dari nol dan keterangan wajib diisi.');$row=array('company_id'=>$this->company(),'payroll_period_id'=>$period->id,'employee_id'=>$employee->id,'component_id'=>$component->id,'adjustment_type'=>$type,'amount'=>$amount,'notes'=>$notes,'is_active'=>1,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));$this->db->insert('payroll_period_adjustments',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','period_adjustment',$id,'create',null,$row,$this->uid());$this->go(true,'Penyesuaian periode berhasil ditambahkan.','adjustments');}catch(Throwable$e){$this->go(false,$e->getMessage(),'adjustments');}
|
||||
}
|
||||
|
||||
public function deactivate_adjustment($id)
|
||||
{
|
||||
try{$row=$this->db->select('a.*,p.workflow_status,e.salary_restricted')->from('payroll_period_adjustments a')->join('k_payroll_periods p','p.id=a.payroll_period_id')->join('k_employees e','e.id=a.employee_id')->where(array('a.id'=>(int)$id,'a.company_id'=>$this->company()))->get()->row();if(!$row)throw new BusinessException('Penyesuaian tidak ditemukan.');if($row->workflow_status!=='draft')throw new BusinessException('Penyesuaian payroll yang bukan Draft tidak dapat diubah.');if($row->salary_restricted&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$update=array('is_active'=>0,'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'));$this->db->where('id',$row->id)->update('payroll_period_adjustments',$update);$this->auditservice->record('payroll','period_adjustment',$row->id,'deactivate',$row,$update,$this->uid());$this->go(true,'Penyesuaian dinonaktifkan tanpa menghapus histori.','adjustments');}catch(Throwable$e){$this->go(false,$e->getMessage(),'adjustments');}
|
||||
}
|
||||
|
||||
public function save_settings()
|
||||
{
|
||||
try{if(!$this->can('can_update'))throw new BusinessException('Anda tidak mempunyai permission mengubah pengaturan payroll.');$day=$this->input->post('default_payment_day');$day=$day===''?null:(int)$day;if($day!==null&&($day<1||$day>31))throw new BusinessException('Tanggal pembayaran default harus 1 sampai 31.');$before=$this->db->get_where('payroll_settings',array('company_id'=>$this->company()))->row();$row=array('separation_of_duties'=>$this->input->post('separation_of_duties')?1:0,'default_payment_day'=>$day,'bank_export_note'=>trim((string)$this->input->post('bank_export_note',true))?:null,'updated_at'=>date('Y-m-d H:i:s'));if($before)$this->db->where('id',$before->id)->update('payroll_settings',$row);else{$row['company_id']=$this->company();$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('payroll_settings',$row);}$this->auditservice->record('payroll','payroll_settings',$this->company(),'update',$before,$row,$this->uid());$this->go(true,'Pengaturan umum payroll berhasil disimpan.','settings');}catch(Throwable$e){$this->go(false,$e->getMessage(),'settings');}
|
||||
}
|
||||
|
||||
private function assignmentInput($subject,$subjectId)
|
||||
{
|
||||
$component=$this->componentRow((int)$this->input->post('component_id'));$method=$this->method($this->input->post('calculation_method'));$amount=$this->decimal($this->input->post('amount_override'));$start=$this->validDate($this->input->post('effective_from'));$end=$this->validDate($this->input->post('effective_to'));if(!$start)throw new BusinessException('Tanggal mulai berlaku wajib diisi.');if($end&&$end<$start)throw new BusinessException('Tanggal selesai tidak boleh sebelum tanggal mulai.');$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),$subject.'_id'=>$subjectId,'component_id'=>$component->id,'calculation_method'=>$method,'amount_override'=>$amount,'formula_expression'=>$formula,'effective_from'=>$start,'effective_to'=>$end?:null,'is_active'=>1,'notes'=>trim((string)$this->input->post('notes',true))?:null,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));return$row;
|
||||
}
|
||||
private function method($method){$allowed=array('fixed','per_present_day','per_absent_day','per_minute','per_hour','percentage_basic','formula');if(!in_array($method,$allowed,true))throw new BusinessException('Metode perhitungan tidak valid.');return$method;}
|
||||
private function attendanceMethod($method){$allowed=array('none','fixed','per_minute','per_hour','per_day','percentage_daily','formula');if(!in_array($method,$allowed,true))throw new BusinessException('Metode potongan tidak valid.');return$method;}
|
||||
private function validateFormula($formula){if(!$formula)throw new BusinessException('Formula wajib diisi.');$variables=array('basic_salary'=>1,'calendar_days'=>1,'work_days'=>1,'present_days'=>1,'alpha_days'=>1,'unpaid_days'=>1,'unpaid_days_total'=>1,'late_minutes'=>1,'early_leave_minutes'=>1,'missing_checkin'=>1,'missing_checkout'=>1,'overtime_hours'=>1,'overtime_rate'=>1,'daily_rate'=>1,'condition_quantity'=>1,'rule_rate'=>1);$this->formulaservice->calculate($formula,$variables);}
|
||||
private function decimal($value){$number=(float)str_replace(array('. ',','),array('','.'),(string)$value);if($number<0)throw new BusinessException('Nominal atau tarif tidak boleh negatif.');return round($number,2);}
|
||||
private function employee($id){$row=$this->db->select('e.*,d.department_name,p.position_name')->from('k_employees e')->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')->where(array('e.id'=>$id,'e.company_id'=>$this->company()))->get()->row();if(!$row)throw new BusinessException('Karyawan tidak ditemukan pada perusahaan aktif.');return$row;}
|
||||
private function componentRow($id){$row=$this->db->get_where('payroll_components',array('id'=>$id,'company_id'=>$this->company()))->row();if(!$row)throw new BusinessException('Komponen payroll tidak ditemukan.');return$row;}
|
||||
private function basicComponentId(){$row=$this->db->get_where('payroll_components',array('company_id'=>$this->company(),'code'=>'BASIC'))->row();return$row?(int)$row->id:0;}
|
||||
private function period($id,$throw=true){$row=$this->db->get_where('k_payroll_periods',array('id'=>(int)$id,'company_id'=>$this->company()))->row();if(!$row&&$throw)throw new BusinessException('Periode payroll tidak ditemukan.');return$row;}
|
||||
|
||||
public function slip($payrollId)
|
||||
{
|
||||
$payroll=$this->db->select('x.*,e.full_name,e.employee_code,e.salary_restricted,r.period_name,r.workflow_status,r.attendance_start_date,r.attendance_end_date,r.payment_date')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->join('k_payroll_periods r','r.id=x.payroll_period_id')->where(array('x.id'=>(int)$payrollId,'x.company_id'=>$this->company(),'r.company_id'=>$this->company()))->get()->row();if(!$payroll||!in_array($payroll->workflow_status,array('final','posted','paid'),true))show_error('Slip belum tersedia.',422);if($payroll->salary_restricted&&!$this->allSalary())show_error('Gaji ini bersifat terbatas.',403);$lines=$this->db->where('payroll_id',$payrollId)->get('payroll_calculation_lines')->result();$this->load->view('hr_payroll/slip',array('payroll'=>$payroll,'lines'=>$lines));
|
||||
}
|
||||
|
||||
public function export_pdf($id)
|
||||
{
|
||||
try{$period=$this->exportablePeriod($id);$rows=$this->payrollRows($id);$company=$this->db->get_where('companies',array('id'=>$this->company()))->row();$html=$this->load->view('hr_payroll/register_pdf',array('period'=>$period,'rows'=>$rows,'company'=>$company),true);$dompdf=new Dompdf\Dompdf();$dompdf->loadHtml($html);$dompdf->setPaper('A4','landscape');$dompdf->render();$dompdf->stream('payroll-'.$period->id.'.pdf',array('Attachment'=>true));}catch(Throwable$e){show_error($e->getMessage(),422);}
|
||||
}
|
||||
public function export_excel($id,$bank='general')
|
||||
{
|
||||
try{$period=$this->exportablePeriod($id);$bank=strtolower($bank);if(!in_array($bank,array('general','bri','bca'),true))throw new BusinessException('Format bank tidak tersedia.');$rows=$this->payrollRows($id,$bank==='general'?null:$bank);if(!$rows)throw new BusinessException('Tidak ada data payroll untuk format yang dipilih.');$spreadsheet=new PhpOffice\PhpSpreadsheet\Spreadsheet();$sheet=$spreadsheet->getActiveSheet();$sheet->setTitle($bank==='general'?'Payroll Register':strtoupper($bank).' Source');$headers=$bank==='general'?array('No','Kode Karyawan','Nama Karyawan','Bank','Nomor Rekening','Gaji Pokok','Bruto','Potongan','Neto','Tanggal Efektif'):array('No','Nomor Rekening','Nama Penerima','Nominal Neto','Berita Pembayaran','Kode Karyawan','Tanggal Efektif');$sheet->fromArray($headers,null,'A1');$line=2;foreach($rows as$i=>$row){$values=$bank==='general'?array($i+1,$row->employee_code,$row->full_name,$row->bank_name,$row->bank_account_number,(float)$row->basic_salary,(float)$row->gross_salary,(float)$row->total_deductions,(float)$row->total_salary,$period->payment_date):array($i+1,$row->bank_account_number,$row->bank_account_name?:$row->full_name,(float)$row->total_salary,'Gaji '.$period->period_name,$row->employee_code,$period->payment_date);$sheet->fromArray($values,null,'A'.$line++);$accountColumn=$bank==='general'?'E':'B';$sheet->setCellValueExplicit($accountColumn.($line-1),(string)$row->bank_account_number,PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);}$sheet->getStyle('A1:'.$sheet->getHighestColumn().'1')->getFont()->setBold(true);foreach(range('A',$sheet->getHighestColumn())as$column)$sheet->getColumnDimension($column)->setAutoSize(true);if($bank!=='general'){$guide=$spreadsheet->createSheet();$guide->setTitle('PETUNJUK');$guide->fromArray(array(array('File sumber '.strtoupper($bank)),array('Gunakan converter/checksum resmi portal bank perusahaan bila diwajibkan.'),array('Periksa nama, rekening, nominal, dan tanggal efektif sebelum otorisasi.')),null,'A1');$guide->getColumnDimension('A')->setWidth(100);}header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');header('Content-Disposition: attachment; filename="payroll-'.$bank.'-'.$period->id.'.xlsx"');header('Cache-Control: max-age=0');(new PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet))->save('php://output');exit;}catch(Throwable$e){show_error($e->getMessage(),422);}
|
||||
}
|
||||
private function exportablePeriod($id){$period=$this->period($id);if(!in_array($period->workflow_status,array('final','posted','paid'),true))throw new BusinessException('Export tersedia setelah payroll berstatus Final.');if(!$this->allSalary()&&$this->hasRestrictedPeriod($id))throw new BusinessException('Export periode ini memuat gaji terbatas.');return$period;}
|
||||
private function hasRestrictedPeriod($id){return$this->db->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->where(array('x.payroll_period_id'=>(int)$id,'x.company_id'=>$this->company(),'e.salary_restricted'=>1))->count_all_results()>0;}
|
||||
private function payrollRows($id,$bank=null){$q=$this->db->select('x.*,e.employee_code,e.full_name,e.bank_name,e.bank_account_number,e.bank_account_name,e.salary_restricted')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->where(array('x.payroll_period_id'=>(int)$id,'x.company_id'=>$this->company()));if(!$this->allSalary())$q->where('e.salary_restricted',0);if($bank){$q->group_start()->like('e.bank_name',$bank);if($bank==='bri')$q->or_like('e.bank_name','Rakyat Indonesia');if($bank==='bca')$q->or_like('e.bank_name','Central Asia');$q->group_end();}$rows=$q->order_by('e.full_name')->get()->result();if($bank){$missing=array();foreach($rows as$row)if(trim((string)$row->bank_account_number)===''||trim((string)$row->bank_account_name)==='')$missing[]=$row->full_name;if($missing)throw new BusinessException('Data rekening belum lengkap: '.implode(', ',$missing).'.');}return$rows;}
|
||||
}
|
||||
|
||||
@@ -1,284 +1,198 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class Leaverequests extends MY_Controller {
|
||||
class Leaverequests extends MY_Controller
|
||||
{
|
||||
private $leaveTypes = array('annual','sick','permit','maternity','unpaid');
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->load->database();
|
||||
$this->load->library(array('CompanyContext','ApprovalService','AuditService','TransactionService','NumberingService'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PAGE
|
||||
// ==========================================
|
||||
private function company(){return (int)$this->companycontext->id();}
|
||||
private function uid(){return (int)$this->session->userdata('user_id');}
|
||||
private function roleId()
|
||||
{
|
||||
$role=(int)$this->session->userdata('role_id');
|
||||
if($role)return $role;
|
||||
$user=$this->db->select('role_id')->get_where('users',array('id'=>$this->uid()))->row();
|
||||
return $user?(int)$user->role_id:0;
|
||||
}
|
||||
private function can($action){return is_master_admin_user()||check_permission('employees',$action);}
|
||||
private function permissions(){return array('create'=>$this->can('can_create'),'update'=>$this->can('can_update'),'approve'=>$this->can('can_approve'),'export'=>$this->can('can_export'));}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$data = [
|
||||
"active_menu" => "leave_requests"
|
||||
];
|
||||
|
||||
$data['employees'] = $this->db
|
||||
->where('is_active',1)
|
||||
->order_by('full_name','ASC')
|
||||
->get('k_employees')
|
||||
->result();
|
||||
|
||||
$this->load->view('partials/header', $data);
|
||||
$this->load->view('employees/leave_requests', $data);
|
||||
$company=$this->company();
|
||||
$employees=$this->db->select('id,employee_code,fingerprint_user_id,full_name')->where(array('company_id'=>$company,'is_active'=>1))->order_by('full_name')->get('k_employees')->result();
|
||||
$counts=$this->workflowCounts();
|
||||
$data=array('active_menu'=>'leave_requests','employees'=>$employees,'permissions'=>$this->permissions(),'counts'=>$counts);
|
||||
$this->load->view('partials/header',$data);
|
||||
$this->load->view('employees/leave_requests',$data);
|
||||
$this->load->view('partials/footer');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DATATABLE
|
||||
// ==========================================
|
||||
public function get_data()
|
||||
{
|
||||
$this->db->select('
|
||||
lr.*,
|
||||
e.full_name,
|
||||
e.fingerprint_user_id
|
||||
');
|
||||
|
||||
$this->db->from('k_leave_requests lr');
|
||||
|
||||
$this->db->join(
|
||||
'k_employees e',
|
||||
'e.id=lr.employee_id',
|
||||
'left'
|
||||
);
|
||||
|
||||
$this->db->order_by('lr.id','DESC');
|
||||
|
||||
$list = $this->db->get()->result();
|
||||
|
||||
$data = [];
|
||||
$no = 1;
|
||||
|
||||
foreach($list as $r){
|
||||
|
||||
// =====================================
|
||||
// LEAVE TYPE
|
||||
// =====================================
|
||||
$leave_type = '-';
|
||||
|
||||
if($r->leave_type == 'annual'){
|
||||
$leave_type = 'Cuti Tahunan';
|
||||
}
|
||||
|
||||
if($r->leave_type == 'sick'){
|
||||
$leave_type = 'Sakit';
|
||||
}
|
||||
|
||||
if($r->leave_type == 'permit'){
|
||||
$leave_type = 'Izin';
|
||||
}
|
||||
|
||||
if($r->leave_type == 'maternity'){
|
||||
$leave_type = 'Cuti Melahirkan';
|
||||
}
|
||||
|
||||
if($r->leave_type == 'unpaid'){
|
||||
$leave_type = 'Cuti Tidak Dibayar';
|
||||
}
|
||||
|
||||
// =====================================
|
||||
// STATUS
|
||||
// =====================================
|
||||
if($r->approval_status == 'approved'){
|
||||
|
||||
$status = '
|
||||
<span class="badge bg-success">
|
||||
Disetujui
|
||||
</span>
|
||||
';
|
||||
|
||||
} elseif($r->approval_status == 'rejected'){
|
||||
|
||||
$status = '
|
||||
<span class="badge bg-danger">
|
||||
Ditolak
|
||||
</span>
|
||||
';
|
||||
|
||||
} else {
|
||||
|
||||
$status = '
|
||||
<span class="badge bg-warning text-dark">
|
||||
Pending
|
||||
</span>
|
||||
';
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
$no++,
|
||||
$r->fingerprint_user_id,
|
||||
$r->full_name,
|
||||
$leave_type,
|
||||
$r->start_date,
|
||||
$r->end_date,
|
||||
$r->total_days . ' Hari',
|
||||
$status,
|
||||
'
|
||||
<button class="btn btn-warning btn-sm btn-edit"
|
||||
data-id="'.$r->id.'">
|
||||
|
||||
Edit
|
||||
|
||||
</button>
|
||||
|
||||
<button class="btn btn-danger btn-sm btn-delete"
|
||||
data-id="'.$r->id.'">
|
||||
|
||||
Delete
|
||||
|
||||
</button>
|
||||
'
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
"data" => $data
|
||||
]);
|
||||
$status=trim((string)$this->input->get('status'));
|
||||
$type=trim((string)$this->input->get('type'));
|
||||
$employee=(int)$this->input->get('employee_id');
|
||||
$this->db->select('lr.id,lr.request_no,lr.employee_id,lr.leave_type,lr.start_date,lr.end_date,lr.total_days,lr.reason,lr.approval_status,lr.workflow_status,lr.submitted_at,lr.approved_at,lr.rejected_at,lr.rejection_reason,lr.created_by,lr.submitted_by,e.full_name,e.employee_code,e.fingerprint_user_id,d.department_name,p.position_name,creator.nama creator_name,approver.nama approver_name,rejector.nama rejector_name')
|
||||
->from('k_leave_requests lr')->join('k_employees e','e.id=lr.employee_id','left')
|
||||
->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')
|
||||
->join('users creator','creator.id=lr.created_by','left')->join('users approver','approver.id=lr.approved_by','left')->join('users rejector','rejector.id=lr.rejected_by','left')
|
||||
->where('lr.company_id',$this->company());
|
||||
if(in_array($status,array('draft','submitted','approved','rejected','cancelled'),true))$this->db->where('lr.workflow_status',$status);
|
||||
if(in_array($type,$this->leaveTypes,true))$this->db->where('lr.leave_type',$type);
|
||||
if($employee>0)$this->db->where('lr.employee_id',$employee);
|
||||
$rows=$this->db->order_by('lr.id','DESC')->get()->result();
|
||||
return json_response(true,'Daftar pengajuan cuti.',array('data'=>$rows,'summary'=>$this->workflowCounts()));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DETAIL
|
||||
// ==========================================
|
||||
public function detail($id)
|
||||
{
|
||||
$data = $this->db
|
||||
->get_where(
|
||||
'k_leave_requests',
|
||||
['id'=>$id]
|
||||
)
|
||||
->row();
|
||||
|
||||
echo json_encode($data);
|
||||
$leave=$this->find((int)$id);
|
||||
if(!$leave)return json_response(false,'Pengajuan cuti tidak ditemukan.',array(),404);
|
||||
$history=array();
|
||||
$history=$this->db->select('aa.action,aa.notes,aa.created_at,aa.step_order,u.nama user_name,aws.name step_name,ar.request_no approval_no')
|
||||
->from('approval_actions aa')->join('approval_requests ar','ar.id=aa.request_id')
|
||||
->join('users u','u.id=aa.user_id','left')->join('approval_workflow_steps aws','aws.workflow_id=ar.workflow_id AND aws.step_order=aa.step_order','left')
|
||||
->where(array('ar.entity_type'=>'leave_request','ar.entity_id'=>$leave->id))->order_by('aa.id')->get()->result();
|
||||
return json_response(true,'Detail pengajuan cuti.',array('leave'=>$leave,'history'=>$history,'permissions'=>$this->permissions()));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SAVE
|
||||
// ==========================================
|
||||
public function save()
|
||||
{
|
||||
if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);
|
||||
$id=(int)$this->input->post('id');
|
||||
$this->requirePermission('employees',$id?'can_update':'can_create');
|
||||
try{
|
||||
|
||||
$start_date = $this->input->post('start_date');
|
||||
$end_date = $this->input->post('end_date');
|
||||
|
||||
$total_days = (
|
||||
strtotime($end_date)
|
||||
-
|
||||
strtotime($start_date)
|
||||
) / 86400 + 1;
|
||||
|
||||
$data = [
|
||||
|
||||
'employee_id' => $this->input->post('employee_id'),
|
||||
|
||||
'leave_type' => $this->input->post('leave_type'),
|
||||
|
||||
'start_date' => $start_date,
|
||||
|
||||
'end_date' => $end_date,
|
||||
|
||||
'total_days' => $total_days,
|
||||
|
||||
'reason' => $this->input->post('reason'),
|
||||
|
||||
'approval_status' => $this->input->post('approval_status')
|
||||
];
|
||||
|
||||
$this->db->insert(
|
||||
'k_leave_requests',
|
||||
$data
|
||||
);
|
||||
|
||||
echo json_encode([
|
||||
'status' => true,
|
||||
'message'=> 'Leave request berhasil ditambahkan'
|
||||
]);
|
||||
|
||||
} catch(Exception $e){
|
||||
|
||||
echo json_encode([
|
||||
'status' => false,
|
||||
'message'=> $e->getMessage()
|
||||
]);
|
||||
}
|
||||
$saved=$this->transactionservice->run(function()use($id){
|
||||
$before=$id?$this->lock($id):null;
|
||||
if($id&&!in_array($before->workflow_status,array('draft','rejected'),true))throw new BusinessException('Hanya Draft atau pengajuan yang ditolak yang dapat diperbaiki.');
|
||||
$employeeId=(int)$this->input->post('employee_id');
|
||||
$employee=$this->db->get_where('k_employees',array('id'=>$employeeId,'company_id'=>$this->company(),'is_active'=>1))->row();
|
||||
if(!$employee)throw new BusinessException('Karyawan aktif tidak ditemukan.');
|
||||
$type=trim((string)$this->input->post('leave_type'));
|
||||
if(!in_array($type,$this->leaveTypes,true))throw new BusinessException('Jenis cuti atau izin tidak valid.');
|
||||
$start=$this->validDate($this->input->post('start_date'),'Tanggal mulai');
|
||||
$end=$this->validDate($this->input->post('end_date'),'Tanggal selesai');
|
||||
if($end<$start)throw new BusinessException('Tanggal selesai tidak boleh sebelum tanggal mulai.');
|
||||
$days=(int)((strtotime($end)-strtotime($start))/86400)+1;
|
||||
if($days<1||$days>366)throw new BusinessException('Rentang cuti harus antara 1 sampai 366 hari.');
|
||||
$reason=trim((string)$this->input->post('reason'));
|
||||
if($reason==='')throw new BusinessException('Alasan pengajuan wajib diisi.');
|
||||
if(mb_strlen($reason)>2000)throw new BusinessException('Alasan pengajuan maksimal 2.000 karakter.');
|
||||
$overlap=$this->db->where(array('company_id'=>$this->company(),'employee_id'=>$employeeId))->where_in('workflow_status',array('submitted','approved'))->where('start_date <=',$end)->where('end_date >=',$start);
|
||||
if($id)$overlap->where('id !=',$id);
|
||||
if($overlap->count_all_results('k_leave_requests'))throw new BusinessException('Karyawan sudah mempunyai pengajuan aktif pada rentang tanggal tersebut.');
|
||||
$row=array('employee_id'=>$employeeId,'leave_type'=>$type,'start_date'=>$start,'end_date'=>$end,'total_days'=>$days,'reason'=>$reason,'approval_status'=>'pending','workflow_status'=>'draft','rejection_reason'=>null,'rejected_by'=>null,'rejected_at'=>null,'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'));
|
||||
if($id){$row['version']=(int)$before->version+1;$this->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('k_leave_requests',$row);$leaveId=$id;$auditAction='update';}
|
||||
else{$row['company_id']=$this->company();$row['request_no']=$this->numberingservice->next('leave_request',$start);$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$row['version']=1;$this->db->insert('k_leave_requests',$row);$leaveId=(int)$this->db->insert_id();$auditAction='create';}
|
||||
$after=$this->find($leaveId);
|
||||
$this->auditservice->record('hr','leave_request',$leaveId,$auditAction,$before,$after,$this->uid());
|
||||
return $leaveId;
|
||||
});
|
||||
return json_response(true,$id?'Draft pengajuan berhasil diperbarui.':'Draft pengajuan berhasil dibuat.',array('id'=>$saved));
|
||||
}catch(Throwable$e){return business_exception_response($e);}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// UPDATE
|
||||
// ==========================================
|
||||
public function update()
|
||||
public function submit($id)
|
||||
{
|
||||
try{
|
||||
|
||||
$id = $this->input->post('id');
|
||||
|
||||
$start_date = $this->input->post('start_date');
|
||||
$end_date = $this->input->post('end_date');
|
||||
|
||||
$total_days = (
|
||||
strtotime($end_date)
|
||||
-
|
||||
strtotime($start_date)
|
||||
) / 86400 + 1;
|
||||
|
||||
$data = [
|
||||
|
||||
'employee_id' => $this->input->post('employee_id'),
|
||||
|
||||
'leave_type' => $this->input->post('leave_type'),
|
||||
|
||||
'start_date' => $start_date,
|
||||
|
||||
'end_date' => $end_date,
|
||||
|
||||
'total_days' => $total_days,
|
||||
|
||||
'reason' => $this->input->post('reason'),
|
||||
|
||||
'approval_status' => $this->input->post('approval_status')
|
||||
];
|
||||
|
||||
$this->db->where('id',$id);
|
||||
|
||||
$this->db->update(
|
||||
'k_leave_requests',
|
||||
$data
|
||||
);
|
||||
|
||||
echo json_encode([
|
||||
'status' => true,
|
||||
'message'=> 'Leave request berhasil diupdate'
|
||||
]);
|
||||
|
||||
} catch(Exception $e){
|
||||
|
||||
echo json_encode([
|
||||
'status' => false,
|
||||
'message'=> $e->getMessage()
|
||||
]);
|
||||
}
|
||||
$this->requirePermission('employees','can_create');
|
||||
return $this->action(function()use($id){
|
||||
$before=$this->lock((int)$id);
|
||||
if($before->workflow_status!=='draft')throw new BusinessException('Hanya Draft yang dapat diajukan.');
|
||||
$request=$this->approvalservice->create('hr','leave_request',$before->id,0,$this->uid(),array('company_id'=>$this->company(),'request_no'=>$before->request_no,'employee_id'=>$before->employee_id,'employee_name'=>$before->full_name,'leave_type'=>$before->leave_type,'start_date'=>$before->start_date,'end_date'=>$before->end_date,'total_days'=>$before->total_days));
|
||||
$update=array('workflow_status'=>'submitted','approval_status'=>'pending','approval_request_id'=>(int)$request->id,'submitted_by'=>$this->uid(),'submitted_at'=>date('Y-m-d H:i:s'),'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$before->version+1);
|
||||
$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('k_leave_requests',$update);
|
||||
$this->auditservice->record('hr','leave_request',$before->id,'submit',$before,$update,$this->uid());
|
||||
return $before->id;
|
||||
},'Pengajuan cuti berhasil dikirim untuk persetujuan.');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DELETE
|
||||
// ==========================================
|
||||
public function delete($id)
|
||||
public function approve($id)
|
||||
{
|
||||
$this->db->delete(
|
||||
'k_leave_requests',
|
||||
['id'=>$id]
|
||||
);
|
||||
$this->requirePermission('employees','can_approve');
|
||||
return $this->action(function()use($id){
|
||||
$before=$this->lock((int)$id);
|
||||
if($before->workflow_status!=='submitted')throw new BusinessException('Pengajuan tidak sedang menunggu persetujuan.');
|
||||
$request=$this->approvalservice->pendingForEntity('leave_request',$before->id);
|
||||
if(!$request)throw new BusinessException('Permintaan persetujuan tidak ditemukan.');
|
||||
$result=$this->approvalservice->act($request->id,'approved',$this->uid(),$this->roleId(),trim((string)$this->input->post('notes'))?:null);
|
||||
$update=array('updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$before->version+1);
|
||||
if($result->status==='approved'){$update['workflow_status']='approved';$update['approval_status']='approved';$update['approved_by']=$this->uid();$update['approved_at']=date('Y-m-d H:i:s');}
|
||||
$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('k_leave_requests',$update);
|
||||
$this->auditservice->record('hr','leave_request',$before->id,'approve',$before,$update,$this->uid());
|
||||
return $before->id;
|
||||
},'Pengajuan cuti berhasil disetujui.');
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => true,
|
||||
'message'=> 'Leave request berhasil dihapus'
|
||||
]);
|
||||
public function reject($id)
|
||||
{
|
||||
$this->requirePermission('employees','can_approve');
|
||||
return $this->action(function()use($id){
|
||||
$reason=trim((string)$this->input->post('reason'));
|
||||
if($reason==='')throw new BusinessException('Alasan penolakan wajib diisi.');
|
||||
$before=$this->lock((int)$id);
|
||||
if($before->workflow_status!=='submitted')throw new BusinessException('Pengajuan tidak sedang menunggu persetujuan.');
|
||||
$request=$this->approvalservice->pendingForEntity('leave_request',$before->id);
|
||||
if(!$request)throw new BusinessException('Permintaan persetujuan tidak ditemukan.');
|
||||
$this->approvalservice->act($request->id,'rejected',$this->uid(),$this->roleId(),$reason);
|
||||
$update=array('workflow_status'=>'rejected','approval_status'=>'rejected','rejected_by'=>$this->uid(),'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>$reason,'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$before->version+1);
|
||||
$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('k_leave_requests',$update);
|
||||
$this->auditservice->record('hr','leave_request',$before->id,'reject',$before,$update,$this->uid());
|
||||
return $before->id;
|
||||
},'Pengajuan cuti ditolak dan dapat diperbaiki kembali.');
|
||||
}
|
||||
|
||||
public function cancel($id)
|
||||
{
|
||||
if(!$this->can('can_update')&&!$this->can('can_create'))return json_response(false,'Anda tidak memiliki izin untuk membatalkan Draft.',array(),403);
|
||||
return $this->action(function()use($id){
|
||||
$before=$this->lock((int)$id);
|
||||
if(!in_array($before->workflow_status,array('draft','rejected'),true))throw new BusinessException('Hanya Draft atau pengajuan ditolak yang dapat dibatalkan.');
|
||||
$update=array('workflow_status'=>'cancelled','cancelled_by'=>$this->uid(),'cancelled_at'=>date('Y-m-d H:i:s'),'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$before->version+1);
|
||||
$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('k_leave_requests',$update);
|
||||
$this->auditservice->record('hr','leave_request',$before->id,'cancel',$before,$update,$this->uid());
|
||||
return $before->id;
|
||||
},'Draft pengajuan berhasil dibatalkan.');
|
||||
}
|
||||
|
||||
private function action($callback,$message)
|
||||
{
|
||||
if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);
|
||||
try{$result=$this->transactionservice->run($callback);return json_response(true,$message,array('result'=>$result));}catch(Throwable$e){return business_exception_response($e);}
|
||||
}
|
||||
|
||||
private function lock($id)
|
||||
{
|
||||
$row=$this->db->query('SELECT lr.*,e.full_name FROM k_leave_requests lr LEFT JOIN k_employees e ON e.id=lr.employee_id WHERE lr.id=? AND lr.company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();
|
||||
if(!$row)throw new BusinessException('Pengajuan cuti tidak ditemukan.');
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function find($id)
|
||||
{
|
||||
return $this->db->select('lr.*,e.full_name,e.employee_code,e.fingerprint_user_id,d.department_name,p.position_name,ar.workflow_id,ar.request_no approval_no,ar.current_step,ar.status approval_request_status,creator.nama creator_name,submitter.nama submitter_name,approver.nama approver_name,rejector.nama rejector_name')
|
||||
->from('k_leave_requests lr')->join('k_employees e','e.id=lr.employee_id','left')->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')
|
||||
->join('approval_requests ar','ar.id=lr.approval_request_id','left')->join('users creator','creator.id=lr.created_by','left')->join('users submitter','submitter.id=lr.submitted_by','left')->join('users approver','approver.id=lr.approved_by','left')->join('users rejector','rejector.id=lr.rejected_by','left')
|
||||
->where(array('lr.id'=>(int)$id,'lr.company_id'=>$this->company()))->get()->row();
|
||||
}
|
||||
|
||||
private function validDate($value,$label)
|
||||
{
|
||||
$value=trim((string)$value);$date=DateTime::createFromFormat('Y-m-d',$value);
|
||||
if(!$date||$date->format('Y-m-d')!==$value)throw new BusinessException($label.' tidak valid.');
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function workflowCounts()
|
||||
{
|
||||
$counts=array('all'=>0,'draft'=>0,'submitted'=>0,'approved'=>0,'rejected'=>0,'cancelled'=>0);
|
||||
foreach($this->db->select('workflow_status,COUNT(*) total')->where('company_id',$this->company())->group_by('workflow_status')->get('k_leave_requests')->result() as $row){$counts[$row->workflow_status]=(int)$row->total;$counts['all']+=(int)$row->total;}
|
||||
return $counts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,606 +1,15 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class Payroll extends MY_Controller {
|
||||
|
||||
public function __construct()
|
||||
/**
|
||||
* Compatibility bridge untuk bookmark/URL Payroll lama.
|
||||
* Seluruh proses payroll sekarang hanya dijalankan oleh Hrpayroll + PayrollService.
|
||||
*/
|
||||
class Payroll extends MY_Controller
|
||||
{
|
||||
public function _remap($method, $parameters = array())
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->requireAdmin();
|
||||
|
||||
$this->load->database();
|
||||
|
||||
date_default_timezone_set('Asia/Jakarta');
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// INDEX
|
||||
// =====================================================
|
||||
public function index()
|
||||
{
|
||||
$data = [
|
||||
'active_menu' => 'payroll'
|
||||
];
|
||||
|
||||
$this->load->view('partials/header',$data);
|
||||
$this->load->view('employees/payroll');
|
||||
$this->load->view('partials/footer');
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// GET DATA
|
||||
// =====================================================
|
||||
public function get_data()
|
||||
{
|
||||
$month = $this->input->post('month');
|
||||
|
||||
$this->db->select("
|
||||
p.*,
|
||||
e.full_name,
|
||||
pp.period_name,
|
||||
pp.start_date,
|
||||
pp.end_date
|
||||
");
|
||||
|
||||
$this->db->from('k_payrolls p');
|
||||
|
||||
$this->db->join(
|
||||
'k_employees e',
|
||||
'e.id = p.employee_id'
|
||||
);
|
||||
|
||||
$this->db->join(
|
||||
'k_payroll_periods pp',
|
||||
'pp.id = p.payroll_period_id'
|
||||
);
|
||||
|
||||
if($month){
|
||||
|
||||
$this->db->where(
|
||||
'DATE_FORMAT(pp.start_date,"%Y-%m")',
|
||||
$month
|
||||
);
|
||||
}
|
||||
|
||||
$this->db->order_by(
|
||||
'e.full_name',
|
||||
'ASC'
|
||||
);
|
||||
|
||||
$rows = $this->db->get()->result();
|
||||
|
||||
$data = [];
|
||||
|
||||
$no = 1;
|
||||
|
||||
foreach($rows as $row){
|
||||
|
||||
$pendapatan =
|
||||
(
|
||||
$row->allowance_amount
|
||||
+
|
||||
$row->bonus_amount
|
||||
+
|
||||
$row->overtime_amount
|
||||
);
|
||||
|
||||
$potongan =
|
||||
(
|
||||
$row->deduction_amount
|
||||
+
|
||||
$row->bpjs_kesehatan_amount
|
||||
+
|
||||
$row->bpjs_ketenagakerjaan_amount
|
||||
+
|
||||
$row->tax_amount
|
||||
+
|
||||
$row->attendance_cut_amount
|
||||
);
|
||||
|
||||
$status = '';
|
||||
|
||||
if($row->payment_status == 'paid'){
|
||||
|
||||
$status = '
|
||||
<span class="badge bg-success">
|
||||
Paid
|
||||
</span>
|
||||
';
|
||||
|
||||
} else {
|
||||
|
||||
$status = '
|
||||
<span class="badge bg-warning text-dark">
|
||||
Draft
|
||||
</span>
|
||||
';
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
|
||||
$no++,
|
||||
|
||||
$row->full_name,
|
||||
|
||||
'Rp '.number_format(
|
||||
$row->basic_salary,
|
||||
0,
|
||||
',',
|
||||
'.'
|
||||
),
|
||||
|
||||
'Rp '.number_format(
|
||||
$pendapatan,
|
||||
0,
|
||||
',',
|
||||
'.'
|
||||
),
|
||||
|
||||
'Rp '.number_format(
|
||||
$potongan,
|
||||
0,
|
||||
',',
|
||||
'.'
|
||||
),
|
||||
|
||||
'Rp '.number_format(
|
||||
$row->total_salary,
|
||||
0,
|
||||
',',
|
||||
'.'
|
||||
),
|
||||
|
||||
$status,
|
||||
|
||||
'
|
||||
<button
|
||||
class="btn btn-sm btn-warning btn-edit"
|
||||
data-id="'.$row->id.'">
|
||||
|
||||
<i class="fa fa-edit"></i>
|
||||
Edit
|
||||
|
||||
</button>
|
||||
'
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'data' => $data
|
||||
]);
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// DETAIL
|
||||
// =====================================================
|
||||
public function detail($id)
|
||||
{
|
||||
$this->db->select("
|
||||
p.*,
|
||||
e.full_name
|
||||
");
|
||||
|
||||
$this->db->from('k_payrolls p');
|
||||
|
||||
$this->db->join(
|
||||
'k_employees e',
|
||||
'e.id = p.employee_id'
|
||||
);
|
||||
|
||||
$this->db->where(
|
||||
'p.id',
|
||||
$id
|
||||
);
|
||||
|
||||
$payroll = $this->db->get()->row();
|
||||
|
||||
if(!$payroll){
|
||||
|
||||
echo json_encode([
|
||||
'status' => false,
|
||||
'message' => 'Payroll tidak ditemukan'
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$periodControl=$this->db->get_where('k_payroll_periods',array('id'=>$payroll->payroll_period_id))->row();
|
||||
if($periodControl&&in_array($periodControl->workflow_status,array('final','posted','paid'),true)){echo json_encode(array('status'=>false,'message'=>'Payroll final terkunci. Gunakan pembatalan resmi.'));return;}
|
||||
|
||||
$items = $this->db
|
||||
->where('payroll_id',$id)
|
||||
->order_by('id','ASC')
|
||||
->get('k_payroll_items')
|
||||
->result();
|
||||
|
||||
echo json_encode([
|
||||
|
||||
'status' => true,
|
||||
|
||||
'payroll' => $payroll,
|
||||
|
||||
'employee' => [
|
||||
'full_name' => $payroll->full_name
|
||||
],
|
||||
|
||||
'items' => $items
|
||||
]);
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// SAVE ITEMS
|
||||
// =====================================================
|
||||
public function save_items()
|
||||
{
|
||||
$payrollId = $this->input->post('payroll_id');
|
||||
|
||||
$items = $this->input->post('items');
|
||||
|
||||
$payroll = $this->db
|
||||
->where('id',$payrollId)
|
||||
->get('k_payrolls')
|
||||
->row();
|
||||
|
||||
if(!$payroll){
|
||||
|
||||
echo json_encode([
|
||||
'status' => false,
|
||||
'message' => 'Payroll tidak ditemukan'
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$periodControl=$this->db->get_where('k_payroll_periods',array('id'=>$payroll->payroll_period_id))->row();
|
||||
if($periodControl&&in_array($periodControl->workflow_status,array('final','posted','paid'),true)){echo json_encode(array('status'=>false,'message'=>'Payroll final terkunci. Gunakan pembatalan resmi.'));return;}
|
||||
|
||||
// =========================================
|
||||
// DELETE OLD ITEMS
|
||||
// =========================================
|
||||
$this->db
|
||||
->where('payroll_id',$payrollId)
|
||||
->delete('k_payroll_items');
|
||||
|
||||
$allowance = 0;
|
||||
$bonus = 0;
|
||||
$overtime = 0;
|
||||
$deduction = 0;
|
||||
$bpjs = 0;
|
||||
$tax = 0;
|
||||
|
||||
if($items){
|
||||
|
||||
foreach($items as $item){
|
||||
|
||||
$amount = (float)$item['amount'];
|
||||
|
||||
$insert = [
|
||||
|
||||
'payroll_id' => $payrollId,
|
||||
|
||||
'item_type' => $item['item_type'],
|
||||
|
||||
'item_name' => $item['item_name'],
|
||||
|
||||
'amount' => $amount
|
||||
];
|
||||
|
||||
$this->db->insert(
|
||||
'k_payroll_items',
|
||||
$insert
|
||||
);
|
||||
|
||||
switch($item['item_type']){
|
||||
|
||||
case 'allowance':
|
||||
$allowance += $amount;
|
||||
break;
|
||||
|
||||
case 'bonus':
|
||||
$bonus += $amount;
|
||||
break;
|
||||
|
||||
case 'overtime':
|
||||
$overtime += $amount;
|
||||
break;
|
||||
|
||||
case 'deduction':
|
||||
$deduction += $amount;
|
||||
break;
|
||||
|
||||
case 'bpjs':
|
||||
$bpjs += $amount;
|
||||
break;
|
||||
|
||||
case 'tax':
|
||||
$tax += $amount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// TOTAL
|
||||
// =========================================
|
||||
$totalSalary =
|
||||
(
|
||||
$payroll->basic_salary
|
||||
+
|
||||
$allowance
|
||||
+
|
||||
$bonus
|
||||
+
|
||||
$overtime
|
||||
)
|
||||
-
|
||||
(
|
||||
$deduction
|
||||
+
|
||||
$bpjs
|
||||
+
|
||||
$tax
|
||||
+
|
||||
$payroll->attendance_cut_amount
|
||||
);
|
||||
|
||||
// =========================================
|
||||
// UPDATE PAYROLL
|
||||
// =========================================
|
||||
$this->db
|
||||
->where('id',$payrollId)
|
||||
->update(
|
||||
'k_payrolls',
|
||||
[
|
||||
|
||||
'allowance_amount' => $allowance,
|
||||
|
||||
'bonus_amount' => $bonus,
|
||||
|
||||
'overtime_amount' => $overtime,
|
||||
|
||||
'deduction_amount' => $deduction,
|
||||
|
||||
'bpjs_kesehatan_amount' => $bpjs,
|
||||
|
||||
'tax_amount' => $tax,
|
||||
|
||||
'total_salary' => $totalSalary
|
||||
]
|
||||
);
|
||||
|
||||
echo json_encode([
|
||||
'status' => true,
|
||||
'message' => 'Payroll berhasil disimpan'
|
||||
]);
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// GENERATE PAYROLL
|
||||
// =====================================================
|
||||
public function generate_payroll()
|
||||
{
|
||||
$month = $this->input->post('month');
|
||||
|
||||
if(!$month){
|
||||
|
||||
echo json_encode([
|
||||
'status' => false,
|
||||
'message' => 'Bulan wajib dipilih'
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$startDate = date(
|
||||
'Y-m-01',
|
||||
strtotime($month)
|
||||
);
|
||||
|
||||
$endDate = date(
|
||||
'Y-m-t',
|
||||
strtotime($month)
|
||||
);
|
||||
|
||||
// =========================================
|
||||
// PERIOD
|
||||
// =========================================
|
||||
$period = $this->db
|
||||
->where('start_date',$startDate)
|
||||
->where('end_date',$endDate)
|
||||
->get('k_payroll_periods')
|
||||
->row();
|
||||
|
||||
if(!$period){
|
||||
|
||||
$this->db->insert(
|
||||
'k_payroll_periods',
|
||||
[
|
||||
|
||||
'period_name' => date(
|
||||
'F Y',
|
||||
strtotime($startDate)
|
||||
),
|
||||
|
||||
'start_date' => $startDate,
|
||||
|
||||
'end_date' => $endDate,
|
||||
|
||||
'status' => 'draft',
|
||||
|
||||
'generated_at' => date('Y-m-d H:i:s')
|
||||
]
|
||||
);
|
||||
|
||||
$periodId = $this->db->insert_id();
|
||||
|
||||
} else {
|
||||
|
||||
$periodId = $period->id;
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// EMPLOYEES
|
||||
// =========================================
|
||||
$employees = $this->db
|
||||
->where('is_active',1)
|
||||
->get('k_employees')
|
||||
->result();
|
||||
|
||||
foreach($employees as $employee){
|
||||
|
||||
// =====================================
|
||||
// ATTENDANCE
|
||||
// =====================================
|
||||
$attendance = $this->db
|
||||
->select("
|
||||
COUNT(*) as total_work_days,
|
||||
|
||||
SUM(attendance_status='present')
|
||||
as total_present,
|
||||
|
||||
SUM(attendance_status='alpha')
|
||||
as total_alpha,
|
||||
|
||||
SUM(overtime_hours)
|
||||
as total_overtime_hours
|
||||
")
|
||||
->where('employee_id',$employee->id)
|
||||
->where('attendance_date >=',$startDate)
|
||||
->where('attendance_date <=',$endDate)
|
||||
->get('k_attendances')
|
||||
->row();
|
||||
|
||||
$dailySalary =
|
||||
$employee->basic_salary / 30;
|
||||
|
||||
$alphaCut =
|
||||
(
|
||||
$attendance->total_alpha
|
||||
* $dailySalary
|
||||
);
|
||||
|
||||
$overtimeRate = 25000;
|
||||
|
||||
$overtimeAmount =
|
||||
(
|
||||
$attendance->total_overtime_hours
|
||||
* $overtimeRate
|
||||
);
|
||||
|
||||
$totalSalary =
|
||||
(
|
||||
$employee->basic_salary
|
||||
+ $overtimeAmount
|
||||
)
|
||||
- $alphaCut;
|
||||
|
||||
$exist = $this->db
|
||||
->where(
|
||||
'payroll_period_id',
|
||||
$periodId
|
||||
)
|
||||
->where(
|
||||
'employee_id',
|
||||
$employee->id
|
||||
)
|
||||
->get('k_payrolls')
|
||||
->row();
|
||||
|
||||
$data = [
|
||||
|
||||
'payroll_period_id' => $periodId,
|
||||
|
||||
'employee_id' => $employee->id,
|
||||
|
||||
'basic_salary' => $employee->basic_salary,
|
||||
|
||||
'total_work_days' => (
|
||||
$attendance->total_work_days ?? 0
|
||||
),
|
||||
|
||||
'total_present' => (
|
||||
$attendance->total_present ?? 0
|
||||
),
|
||||
|
||||
'total_alpha' => (
|
||||
$attendance->total_alpha ?? 0
|
||||
),
|
||||
|
||||
'total_overtime_hours' => (
|
||||
$attendance->total_overtime_hours ?? 0
|
||||
),
|
||||
|
||||
'attendance_cut_amount' => $alphaCut,
|
||||
|
||||
'overtime_amount' => $overtimeAmount,
|
||||
|
||||
'total_salary' => $totalSalary
|
||||
];
|
||||
|
||||
if($exist){
|
||||
|
||||
$this->db
|
||||
->where('id',$exist->id)
|
||||
->update(
|
||||
'k_payrolls',
|
||||
$data
|
||||
);
|
||||
|
||||
} else {
|
||||
|
||||
$this->db->insert(
|
||||
'k_payrolls',
|
||||
$data
|
||||
);
|
||||
|
||||
$payrollId = $this->db->insert_id();
|
||||
|
||||
// =================================
|
||||
// AUTO ITEM OVERTIME
|
||||
// =================================
|
||||
if($overtimeAmount > 0){
|
||||
|
||||
$this->db->insert(
|
||||
'k_payroll_items',
|
||||
[
|
||||
|
||||
'payroll_id' => $payrollId,
|
||||
|
||||
'item_type' => 'overtime',
|
||||
|
||||
'item_name' => 'Lembur',
|
||||
|
||||
'amount' => $overtimeAmount
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// =================================
|
||||
// AUTO ITEM ALPHA
|
||||
// =================================
|
||||
if($alphaCut > 0){
|
||||
|
||||
$this->db->insert(
|
||||
'k_payroll_items',
|
||||
[
|
||||
|
||||
'payroll_id' => $payrollId,
|
||||
|
||||
'item_type' => 'deduction',
|
||||
|
||||
'item_name' => 'Potongan Alpha',
|
||||
|
||||
'amount' => $alphaCut
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => true,
|
||||
'message' => 'Payroll berhasil digenerate'
|
||||
]);
|
||||
$this->session->set_flashdata('success', 'Payroll lama telah disatukan ke Payroll profesional.');
|
||||
redirect('hrpayroll');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
/** Pemeriksaan CLI terarah. Seluruh fixture selalu di-rollback. */
|
||||
class Payrollcheck extends CI_Controller
|
||||
{
|
||||
public function exports()
|
||||
{
|
||||
if(!$this->input->is_cli_request())show_404();
|
||||
$this->load->database();if(is_file(FCPATH.'vendor/autoload.php'))require_once FCPATH.'vendor/autoload.php';
|
||||
$period=$this->db->order_by('id')->get('k_payroll_periods')->row();if(!$period){echo"SKIP: periode payroll belum tersedia.\n";return;}
|
||||
$company=$this->db->get_where('companies',array('id'=>$period->company_id))->row();$rows=$this->db->select('x.*,e.employee_code,e.full_name,e.bank_name,e.bank_account_number')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->where('x.payroll_period_id',$period->id)->limit(3)->get()->result();
|
||||
$html=$this->load->view('hr_payroll/register_pdf',array('period'=>$period,'rows'=>$rows,'company'=>$company),true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','landscape');$pdf->render();if(strlen($pdf->output())<500)throw new RuntimeException('Render PDF payroll gagal.');
|
||||
$book=new PhpOffice\PhpSpreadsheet\Spreadsheet();$book->getActiveSheet()->fromArray(array(array('Kode','Nama','Neto'),array($rows?$rows[0]->employee_code:'-',$rows?$rows[0]->full_name:'-', $rows?(float)$rows[0]->total_salary:0)),null,'A1');$file=tempnam(sys_get_temp_dir(),'payroll-check-').'.xlsx';(new PhpOffice\PhpSpreadsheet\Writer\Xlsx($book))->save($file);if(!is_file($file)||filesize($file)<1000)throw new RuntimeException('Render XLSX payroll gagal.');unlink($file);echo"PASS: satu PDF register dan satu XLSX payroll berhasil dirender; file uji dihapus.\n";
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
if(!$this->input->is_cli_request())show_404();
|
||||
$this->load->database();$this->load->library('PayrollService');
|
||||
$company=$this->db->order_by('id')->get('companies')->row();
|
||||
if(!$company){echo"SKIP: perusahaan tidak tersedia.\n";return;}
|
||||
$employee=$this->db->where(array('company_id'=>$company->id,'is_active'=>1,'salary_restricted'=>0))->order_by('id')->get('k_employees')->row();
|
||||
$user=$this->db->order_by('id')->get('users')->row();
|
||||
if(!$employee||!$user){echo"SKIP: karyawan biasa atau user tidak tersedia.\n";return;}
|
||||
$access=$this->db->get_where('user_companies',array('company_id'=>$company->id))->row();if(!$access){echo"SKIP: akses user perusahaan tidak tersedia.\n";return;}$user=$this->db->get_where('users',array('id'=>$access->user_id))->row();$this->session->set_userdata(array('company_id'=>(int)$company->id,'user_id'=>(int)$user->id));
|
||||
$this->db->trans_begin(true);
|
||||
try{
|
||||
$start='2098-08-14';$end='2098-09-13';$now=date('Y-m-d H:i:s');
|
||||
$this->db->insert('k_payroll_periods',array('company_id'=>$company->id,'period_name'=>'[TEST ROLLBACK] Flexible payroll','start_date'=>$start,'end_date'=>$end,'attendance_start_date'=>$start,'attendance_end_date'=>$end,'payment_date'=>'2098-09-25','status'=>'draft','workflow_status'=>'draft','generated_at'=>$now));$periodId=(int)$this->db->insert_id();
|
||||
$componentIds=array();foreach(array(array('SMOKE_EARN','[TEST] Pendapatan','earning'),array('SMOKE_DED','[TEST] Potongan Tetap','deduction'),array('SMOKE_ALPHA','[TEST] Potongan Alpha','deduction'),array('SMOKE_BONUS','[TEST] Bonus Periode','earning'))as$component){$this->db->insert('payroll_components',array('company_id'=>$company->id,'code'=>$component[0].'-'.bin2hex(random_bytes(2)),'name'=>$component[1],'component_type'=>$component[2],'category'=>'other','calculation_method'=>'fixed','default_value'=>0,'fixed_amount'=>0,'taxable'=>0,'prorated'=>0,'is_active'=>1,'effective_from'=>$start,'created_at'=>$now));$componentIds[]=(int)$this->db->insert_id();}
|
||||
foreach(array(array($componentIds[0],100),array($componentIds[1],10))as$assignment)$this->db->insert('employee_payroll_components',array('company_id'=>$company->id,'employee_id'=>$employee->id,'component_id'=>$assignment[0],'calculation_method'=>'fixed','amount_override'=>$assignment[1],'effective_from'=>$start,'is_active'=>1,'notes'=>'[TEST ROLLBACK]','created_at'=>$now));
|
||||
$this->db->insert('payroll_attendance_rules',array('company_id'=>$company->id,'component_id'=>$componentIds[2],'rule_name'=>'[TEST] Alpha per hari','condition_type'=>'alpha','calculation_method'=>'per_day','rate_value'=>25,'effective_from'=>$start,'is_active'=>1,'created_at'=>$now));
|
||||
$this->db->insert('payroll_period_adjustments',array('company_id'=>$company->id,'payroll_period_id'=>$periodId,'employee_id'=>$employee->id,'component_id'=>$componentIds[3],'adjustment_type'=>'earning','amount'=>50,'notes'=>'[TEST ROLLBACK] Bonus','is_active'=>1,'created_by'=>$user->id,'created_at'=>$now));
|
||||
$this->db->insert('k_attendances',array('employee_id'=>$employee->id,'attendance_date'=>'2098-09-01','attendance_status'=>'alpha','late_minutes'=>0,'early_leave_minutes'=>0));
|
||||
$this->db->insert('k_attendances',array('employee_id'=>$employee->id,'attendance_date'=>'2098-09-02','attendance_status'=>'late','late_minutes'=>30,'early_leave_minutes'=>0));
|
||||
$this->db->insert('payroll_attendance_locks',array('company_id'=>$company->id,'payroll_period_id'=>$periodId,'start_date'=>$start,'end_date'=>$end,'status'=>'locked','locked_by'=>$user->id,'locked_at'=>$now));
|
||||
$this->payrollservice->calculate($periodId,$user->id,true,$company->id);$first=$this->db->select('COUNT(*) count')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->where('p.payroll_period_id',$periodId)->get()->row();
|
||||
$this->payrollservice->calculate($periodId,$user->id,true,$company->id);$second=$this->db->select('COUNT(*) count')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->where('p.payroll_period_id',$periodId)->get()->row();
|
||||
$codes=$this->db->select('c.name,l.amount')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->join('payroll_components c','c.id=l.component_id')->where('p.payroll_period_id',$periodId)->where_in('c.id',$componentIds)->get()->result();$found=array();foreach($codes as$row)$found[$row->name]=(float)$row->amount;
|
||||
if((int)$first->count!==(int)$second->count)throw new RuntimeException('Recalculate menggandakan atau menghilangkan baris.');
|
||||
if(($found['[TEST] Pendapatan']??0)!==100.0||($found['[TEST] Potongan Tetap']??0)!==10.0||($found['[TEST] Potongan Alpha']??0)!==25.0||($found['[TEST] Bonus Periode']??0)!==50.0)throw new RuntimeException('Pendapatan, potongan, aturan alpha, atau bonus tidak sesuai.');
|
||||
$snapshot=$this->db->select('calculation_snapshot')->get_where('k_payrolls',array('payroll_period_id'=>$periodId,'employee_id'=>$employee->id))->row();if(!$snapshot||strpos($snapshot->calculation_snapshot,$start)===false||strpos($snapshot->calculation_snapshot,$end)===false)throw new RuntimeException('Snapshot rentang fleksibel tidak tersimpan.');
|
||||
$late=$this->db->where(array('company_id'=>$company->id,'condition_type'=>'late','calculation_method'=>'none'))->count_all_results('payroll_attendance_rules');if(!$late)throw new RuntimeException('Default terlambat tanpa potongan tidak tersedia.');
|
||||
$beforeJournal=(int)$this->db->count_all('journals');$this->db->where('id',$periodId)->update('k_payroll_periods',array('workflow_status'=>'final'));if((int)$this->db->count_all('journals')!==$beforeJournal)throw new RuntimeException('Finalisasi membuat jurnal sebelum Posting.');$journalId=$this->payrollservice->post($periodId,'2026-09-13',$user->id,$company->id,true);$balance=$this->db->select('SUM(debit-kredit) difference',false)->get_where('journal_details',array('journal_id'=>$journalId))->row();if(!$balance||abs((float)$balance->difference)>.01)throw new RuntimeException('Jurnal payroll tidak balance.');$doubleBlocked=false;try{$this->payrollservice->post($periodId,'2026-09-13',$user->id,$company->id,true);}catch(Throwable$expected){$doubleBlocked=true;}if(!$doubleBlocked)throw new RuntimeException('Double posting tidak diblokir.');
|
||||
echo"PASS: periode 14 Agustus–13 September, assignment, alpha, bonus, snapshot, recalculate, jurnal balance, dan idempotency posting.\n";
|
||||
}catch(Throwable$e){$this->db->trans_rollback();fwrite(STDERR,'FAIL: '.$e->getMessage().PHP_EOL);exit(1);}
|
||||
$this->db->trans_rollback();echo"ROLLBACK: seluruh fixture payroll dibatalkan.\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class Profile extends MY_Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function index($tab='settings')
|
||||
{
|
||||
$tab=$this->input->get('tab',true)?:$tab;if(!in_array($tab,array('settings','devices'),true))$tab='settings';
|
||||
$user=$this->db->get_where('users',array('id'=>$this->uid()))->row();if(!$user)show_404();
|
||||
$data=array('active_menu'=>'profile','profile_tab'=>$tab,'user'=>$user,'sessions'=>$this->usersessionservice->activeSessions($user->id),'attempts'=>$this->usersessionservice->attempts($user->id),'active_count'=>$this->usersessionservice->activeCount($user->id));
|
||||
$this->load->view('partials/header',$data);$this->load->view('profile/index',$data);$this->load->view('partials/footer');
|
||||
}
|
||||
|
||||
public function update_profile()
|
||||
{
|
||||
$this->postOnly();$user=$this->user();$newPhoto=null;
|
||||
try{
|
||||
$name=trim((string)$this->input->post('nama',true));$email=strtolower(trim((string)$this->input->post('email',true)));
|
||||
if($name===''||mb_strlen($name)>255)throw new RuntimeException('Nama wajib diisi dan maksimal 255 karakter.');
|
||||
if($email!==''&&!filter_var($email,FILTER_VALIDATE_EMAIL))throw new RuntimeException('Format email tidak valid.');
|
||||
if($email!==''&&$this->db->where('email',$email)->where('id !=',$user->id)->get('users')->row())throw new RuntimeException('Email sudah digunakan pengguna lain.');
|
||||
$newPhoto=$this->uploadPhoto();$update=array('nama'=>$name,'email'=>$email?:null,'profile_updated_at'=>date('Y-m-d H:i:s'));if($newPhoto)$update['profile_photo']=$newPhoto;
|
||||
$this->db->where('id',$user->id)->update('users',$update);if(!$this->db->trans_status())throw new RuntimeException('Profil gagal disimpan.');
|
||||
if($newPhoto&&!empty($user->profile_photo))$this->removeOldPhoto($user->profile_photo);
|
||||
$this->session->set_userdata(array('nama'=>$name,'email'=>$email?:null,'profile_photo'=>$newPhoto?:$user->profile_photo));
|
||||
log_activity('Profile','update','Memperbarui profil pengguna sendiri','success');$this->session->set_flashdata('success','Profil berhasil diperbarui.');
|
||||
}catch(Throwable$e){if($newPhoto&&is_file(FCPATH.$newPhoto))@unlink(FCPATH.$newPhoto);$this->session->set_flashdata('error',$e->getMessage());}
|
||||
redirect('profile');
|
||||
}
|
||||
|
||||
public function update_security()
|
||||
{
|
||||
$this->postOnly();$hours=(int)$this->input->post('session_duration_hours');$limit=(int)$this->input->post('max_active_devices');
|
||||
try{
|
||||
if(!in_array($hours,array(24,72,168),true))throw new RuntimeException('Durasi session tidak valid.');
|
||||
if($limit<1||$limit>10)throw new RuntimeException('Batas perangkat harus antara 1 sampai 10.');
|
||||
$this->db->where('id',$this->uid())->update('users',array('session_duration_hours'=>$hours,'max_active_devices'=>$limit,'profile_updated_at'=>date('Y-m-d H:i:s')));
|
||||
log_activity('Profile','security_setting','Mengubah durasi session menjadi '.$hours.' jam dan batas perangkat menjadi '.$limit,'success');
|
||||
$this->session->set_flashdata('success','Pengaturan login berhasil disimpan dan berlaku mulai login berikutnya. Session yang sedang aktif tetap memakai batas waktu awalnya.');
|
||||
}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}
|
||||
redirect('profile');
|
||||
}
|
||||
|
||||
public function update_password()
|
||||
{
|
||||
$this->postOnly();$user=$this->user();
|
||||
try{
|
||||
$current=(string)$this->input->post('current_password');$new=(string)$this->input->post('new_password');$confirm=(string)$this->input->post('confirm_password');
|
||||
if(!password_verify($current,$user->password))throw new RuntimeException('Password saat ini tidak sesuai.');
|
||||
if(strlen($new)<8||!preg_match('/[A-Za-z]/',$new)||!preg_match('/\d/',$new))throw new RuntimeException('Password baru minimal 8 karakter dan harus mengandung huruf serta angka.');
|
||||
if($new!==$confirm)throw new RuntimeException('Konfirmasi password baru tidak sama.');
|
||||
if(password_verify($new,$user->password))throw new RuntimeException('Password baru harus berbeda dari password saat ini.');
|
||||
$this->db->where('id',$user->id)->update('users',array('password'=>password_hash($new,PASSWORD_DEFAULT),'profile_updated_at'=>date('Y-m-d H:i:s')));
|
||||
$this->usersessionservice->revokeOthers($user->id);log_activity('Profile','password','Mengubah password dan mengakhiri session perangkat lain','success');
|
||||
$this->session->set_flashdata('success','Password berhasil diubah. Session pada perangkat lain telah diakhiri.');
|
||||
}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}
|
||||
redirect('profile');
|
||||
}
|
||||
|
||||
public function revoke_session($id)
|
||||
{
|
||||
$this->postOnly();
|
||||
try{
|
||||
$result=$this->usersessionservice->revoke((int)$id,$this->uid());log_activity('Profile','revoke_session','Mengakhiri session perangkat ID '.(int)$id,'success');
|
||||
if($result['current']){$this->session->sess_destroy();redirect('auth?notice=revoked');return;}
|
||||
$this->session->set_flashdata('success','Session perangkat berhasil diakhiri.');
|
||||
}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}
|
||||
redirect('profile?tab=devices');
|
||||
}
|
||||
|
||||
private function uploadPhoto()
|
||||
{
|
||||
if(empty($_FILES['profile_photo'])||$_FILES['profile_photo']['error']===UPLOAD_ERR_NO_FILE)return null;
|
||||
$file=$_FILES['profile_photo'];if($file['error']!==UPLOAD_ERR_OK)throw new RuntimeException('Foto profil gagal diunggah.');if((int)$file['size']>2*1024*1024)throw new RuntimeException('Ukuran foto profil maksimal 2 MB.');
|
||||
$mime=(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);$allowed=array('image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp');if(!isset($allowed[$mime]))throw new RuntimeException('Foto profil hanya menerima JPG, PNG, atau WEBP.');
|
||||
$dir=FCPATH.'uploads/accounting/profiles/';if(!is_dir($dir)&&!mkdir($dir,0755,true))throw new RuntimeException('Folder foto profil tidak dapat dibuat.');
|
||||
$relative='uploads/accounting/profiles/'.bin2hex(random_bytes(20)).'.'.$allowed[$mime];if(!move_uploaded_file($file['tmp_name'],FCPATH.$relative))throw new RuntimeException('Foto profil tidak dapat disimpan.');return$relative;
|
||||
}
|
||||
|
||||
private function removeOldPhoto($path){$prefix='uploads/accounting/profiles/';if(strpos((string)$path,$prefix)===0&&is_file(FCPATH.$path))@unlink(FCPATH.$path);}
|
||||
private function user(){$row=$this->db->get_where('users',array('id'=>$this->uid()))->row();if(!$row)throw new RuntimeException('Pengguna tidak ditemukan.');return$row;}
|
||||
private function uid(){return(int)$this->session->userdata('user_id');}
|
||||
private function postOnly(){if(strtoupper($this->input->method())!=='POST')show_404();}
|
||||
}
|
||||
@@ -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();}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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_tempo<CURDATE()) overdue_count FROM invoices WHERE deleted_at IS NULL".$where)->row();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');}
|
||||
|
||||
@@ -1,11 +1,122 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
require_once APPPATH.'exceptions/BusinessException.php';
|
||||
|
||||
class PayrollService
|
||||
{
|
||||
private $CI;public function __construct(){$this->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;}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class UserSessionService
|
||||
{
|
||||
private $CI;
|
||||
private $durations=array(24,72,168);
|
||||
|
||||
public function __construct(){ $this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class Migration_Add_user_profile_and_login_security extends CI_Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$columns=array(
|
||||
'email'=>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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
/** Menambahkan workflow pengajuan cuti tanpa menghapus data cuti lama. */
|
||||
class Migration_Finalize_leave_request_workflow extends CI_Migration
|
||||
{
|
||||
private function addColumn($name, array $definition)
|
||||
{
|
||||
if (!$this->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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
/** Memastikan approver HR dapat membuka Kotak Persetujuan. */
|
||||
class Migration_Grant_hr_leave_approval_access extends CI_Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if(!$this->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.');
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class Migration_Finalize_hr_permissions_and_manual_attendance extends CI_Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (!$this->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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class Migration_Finalize_flexible_payroll extends CI_Migration
|
||||
{
|
||||
private function column($table, $column, $definition)
|
||||
{
|
||||
if ($this->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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class Migration_Seed_payroll_for_all_companies extends CI_Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$source=$this->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.');}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||
|
||||
class Migration_Scope_cash_accounts_for_payroll extends CI_Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if(!$this->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.');}
|
||||
}
|
||||
@@ -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'));
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
|
||||
@@ -1,75 +1,40 @@
|
||||
<div class="container mt-4">
|
||||
<?php $this->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']?'<button class="btn btn-warning" id="addManual"><i class="bi bi-plus-lg"></i> Absen Manual</button>':'')); ?>
|
||||
|
||||
<div class="card border-0 shadow-sm rounded-4">
|
||||
<section class="card hr-panel border-0">
|
||||
<div class="card-body p-0">
|
||||
<ul class="nav nav-pills p-3 gap-2 border-bottom" role="tablist">
|
||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#dailyPane">Kehadiran Terjadwal</button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#manualPane">Pengajuan Absen Manual</button></li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active p-3" id="dailyPane">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-end gap-2 mb-3"><div><h5 class="mb-1">Daftar Kehadiran Harian</h5><small class="text-muted">Menampilkan semua karyawan berdasarkan jadwal yang telah dipublikasikan.</small></div><div><label class="form-label">Tanggal</label><input type="date" id="filterDate" class="form-control" value="<?=date('Y-m-d')?>"></div></div>
|
||||
<div class="table-responsive"><table id="tableAttendance" class="table modern-table align-middle w-100"><thead><tr><th>No</th><th>Kode</th><th>Karyawan</th><th>Shift</th><th>Masuk</th><th>Pulang</th><th>Status</th></tr></thead></table></div>
|
||||
</div>
|
||||
<div class="tab-pane fade p-3" id="manualPane">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-end gap-2 mb-3"><div><h5 class="mb-1">Alur Absensi Manual</h5><small class="text-muted">Draft dibuat dahulu, diajukan, kemudian baru masuk ke daftar kehadiran setelah disetujui.</small></div><select id="manualStatus" class="form-select" style="max-width:220px"><option value="">Semua status</option><option value="draft">Draft</option><option value="submitted">Menunggu persetujuan</option><option value="approved">Disetujui</option><option value="rejected">Ditolak</option></select></div>
|
||||
<div class="hr-workflow-strip"><span><b>1</b>Buat Draft</span><span><b>2</b>Ajukan</span><span><b>3</b>Approve/Reject</span><span><b>4</b>Masuk Absensi</span></div>
|
||||
<div class="table-responsive"><table id="tableManual" class="table modern-table align-middle w-100"><thead><tr><th>Dokumen</th><th>Karyawan</th><th>Tanggal/Jam</th><th>Jenis</th><th>Alasan</th><th>Status</th><th>Pembuat</th><th>Aksi</th></tr></thead></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="card-body p-4">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
|
||||
<h5 class="mb-0">
|
||||
Monitoring Absensi
|
||||
</h5>
|
||||
|
||||
<input type="date"
|
||||
id="filterDate"
|
||||
class="form-control"
|
||||
style="max-width:220px"
|
||||
value="<?= date('Y-m-d') ?>">
|
||||
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
|
||||
<table id="tableAttendance"
|
||||
class="table modern-table align-middle w-100">
|
||||
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>No</th>
|
||||
<th>Kode</th>
|
||||
<th>Nama</th>
|
||||
<th>Shift</th>
|
||||
<th>Checkin</th>
|
||||
<th>Checkout</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal fade" id="manualModal" tabindex="-1" data-bs-backdrop="static"><div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-header"><div><small class="text-muted">DRAFT ABSENSI</small><h5 class="modal-title">Tambah Absen Manual</h5></div><button class="btn-close" data-bs-dismiss="modal"></button></div><form id="manualForm"><div class="modal-body"><div class="mb-3"><label class="form-label">Karyawan <span class="text-danger">*</span></label><select class="form-select select-search" name="employee_id" required><option value="">Pilih karyawan</option><?php foreach($employees as$e):?><option value="<?=$e->id?>"><?=html_escape($e->employee_code.' - '.$e->full_name)?></option><?php endforeach?></select></div><div class="row g-3"><div class="col-6"><label class="form-label">Tanggal <span class="text-danger">*</span></label><input class="form-control" type="date" name="attendance_date" value="<?=date('Y-m-d')?>" required></div><div class="col-6"><label class="form-label">Jam <span class="text-danger">*</span></label><input class="form-control" type="time" name="attendance_time" value="<?=date('H:i')?>" required></div><div class="col-12"><label class="form-label">Jenis Absen <span class="text-danger">*</span></label><select class="form-select" name="attendance_action" required><option value="check_in">Absen Masuk</option><option value="check_out">Absen Pulang</option></select></div><div class="col-12"><label class="form-label">Alasan / Keterangan <span class="text-danger">*</span></label><textarea class="form-control" name="reason" rows="3" maxlength="1000" placeholder="Jelaskan alasan pencatatan manual agar approver dapat memeriksa." required></textarea></div></div><div class="alert alert-warning mt-3 mb-0 small"><i class="bi bi-shield-check"></i> Data belum mengubah absensi sampai pengajuan disetujui.</div></div><div class="modal-footer"><button type="button" class="btn btn-light" data-bs-dismiss="modal">Batal</button><button class="btn btn-warning"><i class="bi bi-save"></i> Simpan Draft</button></div></form></div></div></div>
|
||||
|
||||
<script>
|
||||
$(function(){
|
||||
|
||||
let table = $('#tableAttendance').DataTable({
|
||||
ajax:{
|
||||
url:`<?= base_url(); ?>attendancemonitoring/get_data`,
|
||||
data:function(d){
|
||||
d.date = $('#filterDate').val();
|
||||
},
|
||||
dataSrc:'data'
|
||||
},
|
||||
columns:[
|
||||
{data:0},
|
||||
{data:1},
|
||||
{data:2},
|
||||
{data:3},
|
||||
{data:4},
|
||||
{data:5},
|
||||
{data:6}
|
||||
]
|
||||
});
|
||||
|
||||
$('#filterDate').change(function(){
|
||||
table.ajax.reload();
|
||||
});
|
||||
|
||||
const base=window.APP_BASE_URL||'<?=base_url()?>',modal=new bootstrap.Modal('#manualModal');
|
||||
const daily=$('#tableAttendance').DataTable({processing:true,ajax:{url:base+'attendancemonitoring/get_data',data:d=>d.date=$('#filterDate').val(),dataSrc:'data'},order:[],columns:[0,1,2,3,4,5,6].map(i=>({data:i}))});
|
||||
const statusLabel={draft:'Draft',submitted:'Menunggu Persetujuan',approved:'Disetujui',rejected:'Ditolak',cancelled:'Dibatalkan'};
|
||||
const manual=$('#tableManual').DataTable({processing:true,ajax:{url:base+'attendancemonitoring/manual_requests',data:d=>d.status=$('#manualStatus').val(),dataSrc:'data'},order:[],columns:['request','employee','datetime','action','reason','status','creator','actions'].map(x=>({data:x})),columnDefs:[{targets:5,render:v=>`<span class="badge bg-${v==='approved'?'success':(v==='rejected'?'danger':(v==='submitted'?'warning':'secondary'))}">${statusLabel[v]||v}</span>`},{targets:7,orderable:false,searchable:false}]});
|
||||
$('#filterDate').on('change',()=>daily.ajax.reload());$('#manualStatus').on('change',()=>manual.ajax.reload());$('#addManual').on('click',()=>{document.getElementById('manualForm').reset();modal.show();});
|
||||
function refresh(){manual.ajax.reload(null,false);daily.ajax.reload(null,false)}
|
||||
function request(url,data){return $.ajax({url:base+url,type:'POST',data:data||{}}).done(r=>{if(r.status){AppAlert.toast(r.message,'success');refresh()}else AppAlert.toast(r.message,'error')}).fail(x=>AppAlert.toast(x.responseJSON?.message||'Proses gagal.','error'));}
|
||||
$('#manualForm').on('submit',function(e){e.preventDefault();request('attendancemonitoring/save_manual',$(this).serialize()).done(r=>{if(r.status)modal.hide()});});
|
||||
$(document).on('click','.manual-submit',function(){const id=this.dataset.id;AppAlert.confirm({title:'Ajukan absensi manual?',text:'Dokumen akan dikirim kepada approver HR.'}).then(x=>{if(x.isConfirmed)request('attendancemonitoring/submit_manual/'+id)});});
|
||||
$(document).on('click','.manual-approve',function(){const id=this.dataset.id;AppAlert.confirm({title:'Setujui absensi manual?',text:'Setelah disetujui, waktu akan langsung diterapkan ke daftar absensi.',confirmButtonText:'Approve'}).then(x=>{if(x.isConfirmed)request('attendancemonitoring/approve_manual/'+id)});});
|
||||
$(document).on('click','.manual-reject',function(){const id=this.dataset.id;AppAlert.prompt({title:'Tolak pengajuan',input:'textarea',inputLabel:'Alasan penolakan',inputValidator:v=>!v?.trim()?'Alasan wajib diisi.':undefined,confirmButtonText:'Reject'}).then(x=>{if(x.isConfirmed)request('attendancemonitoring/reject_manual/'+id,{reason:x.value})});});
|
||||
$(document).on('click','.manual-detail',function(){const id=this.dataset.id;$.getJSON(base+'attendancemonitoring/manual_detail/'+id,r=>{if(!r.status)return AppAlert.toast(r.message,'error');const x=r.request;Swal.fire({title:x.request_no,html:`<div class="text-start"><b>${$('<div>').text(x.employee_code+' - '+x.full_name).html()}</b><hr><div>Tanggal: ${x.attendance_date} ${x.attendance_time.slice(0,5)}</div><div>Jenis: ${x.attendance_action==='check_in'?'Masuk':'Pulang'}</div><div>Status: ${x.status}</div><div class="mt-2"><small>Alasan</small><br>${$('<div>').text(x.reason).html()}</div>${x.rejection_reason?`<div class="alert alert-danger mt-2 mb-0">${$('<div>').text(x.rejection_reason).html()}</div>`:''}</div>`,showConfirmButton:true});});});
|
||||
});
|
||||
</script>
|
||||
@@ -1,3 +1,4 @@
|
||||
<?php $this->load->view('hr_payroll/_header',array('active_menu'=>'attendance_monthly','title'=>'Absensi Bulanan','subtitle'=>'Lihat rekap kehadiran bulanan dan rincian absensi setiap karyawan.')); ?>
|
||||
<style>
|
||||
.calendar-grid{
|
||||
display:grid;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<?php $this->load->view('hr_payroll/_header',array('active_menu'=>'employees','title'=>'Data Karyawan','subtitle'=>'Kelola identitas, penempatan kerja, rekening, dan pengaturan kerahasiaan gaji.')); ?>
|
||||
<div class="container mt-4">
|
||||
|
||||
<div class="card border-0 shadow-lg rounded-4">
|
||||
@@ -16,10 +17,10 @@
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-warning rounded-pill px-4 btn-add">
|
||||
<?php if($employee_permissions['create']):?><button class="btn btn-warning rounded-pill px-4 btn-add">
|
||||
<i class="fa fa-plus me-2"></i>
|
||||
Tambah Karyawan
|
||||
</button>
|
||||
</button><?php endif?>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -400,6 +401,15 @@
|
||||
|
||||
</div>
|
||||
|
||||
<?php if($can_manage_all_salary):?>
|
||||
<div class="col-12 mb-3">
|
||||
<div class="form-check salary-restricted-note">
|
||||
<input class="form-check-input" type="checkbox" id="salary_restricted" value="1">
|
||||
<label class="form-check-label" for="salary_restricted"><b>Gaji terbatas</b> — hanya pengguna dengan permission <i>Manage All Gaji</i> yang dapat melihat dan memproses payroll karyawan ini.</label>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif?>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
|
||||
<label>Status Active</label>
|
||||
@@ -419,16 +429,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
|
||||
<label>Basic Salary</label>
|
||||
|
||||
<input type="number"
|
||||
id="basic_salary"
|
||||
class="form-control">
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
|
||||
<label>Bank Name</label>
|
||||
@@ -636,22 +636,22 @@ $(function(){
|
||||
data:null,
|
||||
render:function(data){
|
||||
|
||||
return `
|
||||
let actions = `
|
||||
<button class="btn btn-info btn-sm btn-detail"
|
||||
data-id="${data.id}">
|
||||
Detail
|
||||
</button>
|
||||
|
||||
<?php if($employee_permissions['update']):?>${data.salary_hidden ? '' : `
|
||||
<button class="btn btn-warning btn-sm btn-edit"
|
||||
data-id="${data.id}">
|
||||
Edit
|
||||
</button>
|
||||
|
||||
</button>`}<?php endif?>
|
||||
<?php if($employee_permissions['delete']):?>${data.salary_hidden ? '' : `
|
||||
<button class="btn btn-danger btn-sm btn-delete"
|
||||
data-id="${data.id}">
|
||||
Delete
|
||||
</button>
|
||||
`;
|
||||
</button>`}<?php endif?>
|
||||
`; return actions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,6 +676,7 @@ $(function(){
|
||||
$('#employment_status').val('permanent');
|
||||
$('#marital_status').val('single');
|
||||
$('#is_active').val('1');
|
||||
$('#salary_restricted').prop('checked',false);
|
||||
|
||||
$('#photoPreview').attr(
|
||||
'src',
|
||||
@@ -777,7 +778,7 @@ $(function(){
|
||||
$('#position_id').val(d.position_id);
|
||||
$('#branch_id').val(d.branch_id);
|
||||
|
||||
$('#basic_salary').val(d.basic_salary);
|
||||
$('#salary_restricted').prop('checked',Number(d.salary_restricted)===1);
|
||||
|
||||
$('#bank_name').val(d.bank_name);
|
||||
$('#bank_account_number').val(d.bank_account_number);
|
||||
@@ -840,7 +841,7 @@ $(function(){
|
||||
formData.append('position_id', $('#position_id').val());
|
||||
formData.append('branch_id', $('#branch_id').val());
|
||||
|
||||
formData.append('basic_salary', $('#basic_salary').val());
|
||||
<?php if($can_manage_all_salary):?>formData.append('salary_restricted', $('#salary_restricted').is(':checked') ? '1' : '0');<?php endif?>
|
||||
|
||||
formData.append('bank_name', $('#bank_name').val());
|
||||
formData.append('bank_account_number', $('#bank_account_number').val());
|
||||
@@ -1237,11 +1238,11 @@ $(function(){
|
||||
<div class="col-md-6 mb-3">
|
||||
|
||||
<label class="text-muted small">
|
||||
Basic Salary
|
||||
Struktur Gaji
|
||||
</label>
|
||||
|
||||
<div class="fw-semibold">
|
||||
Rp ${parseInt(d.basic_salary || 0).toLocaleString('id-ID')}
|
||||
${d.salary_hidden ? '<span class="text-danger"><i class="bi bi-lock"></i> Gaji terbatas</span>' : '<span class="text-muted">Dikelola melalui Master Gaji Karyawan</span>'}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<?php $this->load->view('hr_payroll/_header',array('active_menu'=>'generate_schedule','title'=>'Jadwal Kerja','subtitle'=>'Susun dan publikasikan jadwal karyawan tanpa mengubah aturan shift yang sudah berjalan.')); ?>
|
||||
<!-- application/views/employees/generate_schedule.php -->
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<?php $this->load->view('hr_payroll/_header',array('active_menu'=>'holidays','title'=>'Hari Libur','subtitle'=>'Kelola kalender hari libur nasional dan hari libur perusahaan.')); ?>
|
||||
<div class="container mt-4">
|
||||
|
||||
<div class="card modern-card shadow-sm border-0 rounded-4">
|
||||
|
||||
@@ -1,418 +1,95 @@
|
||||
<div class="container mt-4">
|
||||
|
||||
<div class="card modern-card shadow-sm border-0 rounded-4">
|
||||
|
||||
<div class="card-body p-4">
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
|
||||
<h5 class="mb-0">
|
||||
Leave Requests
|
||||
</h5>
|
||||
|
||||
<button class="btn btn-warning btn-add">
|
||||
Tambah Leave
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
|
||||
<table id="tableLeave"
|
||||
class="table modern-table align-middle w-100">
|
||||
|
||||
<thead class="table-light">
|
||||
|
||||
<tr>
|
||||
|
||||
<th width="5%">No</th>
|
||||
<th>Finger ID</th>
|
||||
<th>Employee</th>
|
||||
<th>Jenis Leave</th>
|
||||
<th>Tanggal Mulai</th>
|
||||
<th>Tanggal Selesai</th>
|
||||
<th>Total</th>
|
||||
<th>Status</th>
|
||||
<th width="15%">Aksi</th>
|
||||
|
||||
</tr>
|
||||
|
||||
</thead>
|
||||
|
||||
<tbody></tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<?php $this->load->view('hr_payroll/_header',array('active_menu'=>'leave_requests','title'=>'Cuti & Izin','subtitle'=>'Kelola pengajuan secara tertib dari draft, persetujuan HR, hingga masuk ke perhitungan kehadiran.','action_html'=>$permissions['create']?'<button type="button" class="btn btn-warning btn-add-leave"><i class="bi bi-plus-lg"></i> Buat Pengajuan</button>':'')); ?>
|
||||
|
||||
<div class="leave-flow" aria-label="Alur pengajuan cuti">
|
||||
<span><b>1</b><small>Buat Draft</small></span><i class="bi bi-chevron-right"></i>
|
||||
<span><b>2</b><small>Ajukan</small></span><i class="bi bi-chevron-right"></i>
|
||||
<span><b>3</b><small>Review HR</small></span><i class="bi bi-chevron-right"></i>
|
||||
<span><b>4</b><small>Disetujui / Ditolak</small></span>
|
||||
</div>
|
||||
|
||||
<!-- MODAL -->
|
||||
<div class="modal fade" id="modalLeave">
|
||||
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
|
||||
<div class="modal-content rounded-4">
|
||||
|
||||
<div class="modal-header bg-warning text-white">
|
||||
|
||||
<h5 class="modal-title">
|
||||
Tambah Leave
|
||||
</h5>
|
||||
|
||||
<button class="btn-close btn-close-white"
|
||||
data-bs-dismiss="modal"></button>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<input type="hidden" id="inputId">
|
||||
|
||||
<label class="fw-semibold">
|
||||
Employee
|
||||
</label>
|
||||
|
||||
<select class="form-control select-search"
|
||||
id="employee_id">
|
||||
|
||||
<option value="">
|
||||
Pilih Employee
|
||||
</option>
|
||||
|
||||
<?php foreach($employees as $e): ?>
|
||||
|
||||
<option value="<?= $e->id ?>">
|
||||
|
||||
<?= $e->fingerprint_user_id ?>
|
||||
-
|
||||
<?= $e->full_name ?>
|
||||
|
||||
</option>
|
||||
|
||||
<?php endforeach; ?>
|
||||
|
||||
</select>
|
||||
|
||||
<label class="fw-semibold mt-2">
|
||||
Jenis Cuti / Izin
|
||||
</label>
|
||||
|
||||
<select class="form-control"
|
||||
id="leave_type">
|
||||
|
||||
<option value="annual">
|
||||
Cuti Tahunan
|
||||
</option>
|
||||
|
||||
<option value="sick">
|
||||
Sakit
|
||||
</option>
|
||||
|
||||
<option value="permit">
|
||||
Izin
|
||||
</option>
|
||||
|
||||
<option value="maternity">
|
||||
Cuti Melahirkan
|
||||
</option>
|
||||
|
||||
<option value="unpaid">
|
||||
Cuti Tidak Dibayar
|
||||
</option>
|
||||
|
||||
</select>
|
||||
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-6">
|
||||
|
||||
<label class="fw-semibold mt-2">
|
||||
Start Date
|
||||
</label>
|
||||
|
||||
<input type="date"
|
||||
class="form-control"
|
||||
id="start_date">
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
|
||||
<label class="fw-semibold mt-2">
|
||||
End Date
|
||||
</label>
|
||||
|
||||
<input type="date"
|
||||
class="form-control"
|
||||
id="end_date">
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<label class="fw-semibold mt-2">
|
||||
Approval Status
|
||||
</label>
|
||||
|
||||
<select class="form-control"
|
||||
id="approval_status">
|
||||
|
||||
<option value="pending">
|
||||
Pending
|
||||
</option>
|
||||
|
||||
<option value="approved">
|
||||
Approved
|
||||
</option>
|
||||
|
||||
<option value="rejected">
|
||||
Rejected
|
||||
</option>
|
||||
|
||||
</select>
|
||||
|
||||
<label class="fw-semibold mt-2">
|
||||
Reason
|
||||
</label>
|
||||
|
||||
<textarea class="form-control"
|
||||
id="reason"
|
||||
rows="4"></textarea>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
|
||||
<button class="btn btn-secondary"
|
||||
data-bs-dismiss="modal">
|
||||
|
||||
Batal
|
||||
|
||||
</button>
|
||||
|
||||
<button class="btn btn-warning"
|
||||
id="btnSimpan">
|
||||
|
||||
Simpan
|
||||
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<div class="leave-metrics">
|
||||
<article><small>Semua Pengajuan</small><strong data-leave-count="all"><?=number_format($counts['all'],0,',','.')?></strong><span>Seluruh dokumen</span></article>
|
||||
<article><small>Draft</small><strong data-leave-count="draft"><?=number_format($counts['draft'],0,',','.')?></strong><span>Belum diajukan</span></article>
|
||||
<article class="metric-warning"><small>Menunggu Persetujuan</small><strong data-leave-count="submitted"><?=number_format($counts['submitted'],0,',','.')?></strong><span>Perlu tindakan HR</span></article>
|
||||
<article class="metric-success"><small>Disetujui</small><strong data-leave-count="approved"><?=number_format($counts['approved'],0,',','.')?></strong><span>Aktif pada kehadiran</span></article>
|
||||
</div>
|
||||
|
||||
<section class="card border-0 leave-card">
|
||||
<div class="card-body p-0">
|
||||
<nav class="leave-tabs" aria-label="Status pengajuan">
|
||||
<button type="button" class="active" data-status="">Semua <b data-tab-count="all"><?=$counts['all']?></b></button>
|
||||
<button type="button" data-status="draft">Draft <b data-tab-count="draft"><?=$counts['draft']?></b></button>
|
||||
<button type="button" data-status="submitted">Menunggu <b data-tab-count="submitted"><?=$counts['submitted']?></b></button>
|
||||
<button type="button" data-status="approved">Disetujui <b data-tab-count="approved"><?=$counts['approved']?></b></button>
|
||||
<button type="button" data-status="rejected">Ditolak <b data-tab-count="rejected"><?=$counts['rejected']?></b></button>
|
||||
</nav>
|
||||
<div class="leave-toolbar">
|
||||
<select id="filterEmployee" class="form-select select-search" data-placeholder="Semua karyawan"><option value="">Semua karyawan</option><?php foreach($employees as$employee):?><option value="<?=$employee->id?>"><?=html_escape(($employee->employee_code?:$employee->fingerprint_user_id).' - '.$employee->full_name)?></option><?php endforeach?></select>
|
||||
<select id="filterType" class="form-select"><option value="">Semua jenis</option><option value="annual">Cuti Tahunan</option><option value="sick">Sakit</option><option value="permit">Izin</option><option value="maternity">Cuti Melahirkan</option><option value="unpaid">Cuti Tidak Dibayar</option></select>
|
||||
<button type="button" class="btn btn-outline-secondary" id="refreshLeave"><i class="bi bi-arrow-clockwise"></i> Muat Ulang</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table id="tableLeave" class="table align-middle w-100 mb-0">
|
||||
<thead><tr><th>Pengajuan</th><th>Karyawan</th><th>Jenis</th><th>Periode</th><th>Status</th><th>Diproses Oleh</th><th>Aksi</th></tr></thead>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal fade" id="leaveModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg"><div class="modal-content">
|
||||
<div class="modal-header"><div><small class="text-muted">FORM PENGAJUAN</small><h5 class="modal-title">Buat Pengajuan Cuti / Izin</h5></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Tutup"></button></div>
|
||||
<form id="leaveForm" data-ajax="true">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="id" id="leaveId">
|
||||
<div id="rejectionNotice" class="alert alert-danger d-none"></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12"><label class="form-label">Karyawan <span class="text-danger">*</span></label><select class="form-select select-search" name="employee_id" id="employeeId" data-placeholder="Cari karyawan" required><option value=""></option><?php foreach($employees as$employee):?><option value="<?=$employee->id?>"><?=html_escape(($employee->employee_code?:$employee->fingerprint_user_id).' - '.$employee->full_name)?></option><?php endforeach?></select></div>
|
||||
<div class="col-md-6"><label class="form-label">Jenis Cuti / Izin <span class="text-danger">*</span></label><select class="form-select" name="leave_type" id="leaveType" required><option value="annual">Cuti Tahunan</option><option value="sick">Sakit</option><option value="permit">Izin</option><option value="maternity">Cuti Melahirkan</option><option value="unpaid">Cuti Tidak Dibayar</option></select></div>
|
||||
<div class="col-md-3"><label class="form-label">Tanggal Mulai <span class="text-danger">*</span></label><input type="date" class="form-control" name="start_date" id="startDate" required></div>
|
||||
<div class="col-md-3"><label class="form-label">Tanggal Selesai <span class="text-danger">*</span></label><input type="date" class="form-control" name="end_date" id="endDate" required></div>
|
||||
<div class="col-12"><label class="form-label">Alasan Pengajuan <span class="text-danger">*</span></label><textarea class="form-control" name="reason" id="leaveReason" rows="4" maxlength="2000" placeholder="Jelaskan kebutuhan cuti atau izin secara ringkas dan jelas" required></textarea><div class="form-text">Status persetujuan ditentukan melalui proses Ajukan dan tindakan approver, bukan dari form ini.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"><button type="button" class="btn btn-light" data-bs-dismiss="modal">Tutup</button><button type="submit" class="btn btn-warning" id="saveLeave"><i class="bi bi-save"></i> Simpan Draft</button></div>
|
||||
</form>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="leaveDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg"><div class="modal-content">
|
||||
<div class="modal-header"><div><small class="text-muted">DETAIL & RIWAYAT</small><h5 class="modal-title" id="detailTitle">Pengajuan Cuti</h5></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Tutup"></button></div>
|
||||
<div class="modal-body" id="leaveDetailBody"></div>
|
||||
<div class="modal-footer"><button type="button" class="btn btn-light" data-bs-dismiss="modal">Tutup</button></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
$(function(){
|
||||
|
||||
let table = $('#tableLeave').DataTable({
|
||||
processing:true,
|
||||
responsive:true,
|
||||
autoWidth:false,
|
||||
ajax:{
|
||||
url:`<?= base_url(); ?>leaverequests/get_data`,
|
||||
dataSrc:'data'
|
||||
},
|
||||
order:[]
|
||||
});
|
||||
|
||||
// =====================================
|
||||
// RESET
|
||||
// =====================================
|
||||
function resetForm(){
|
||||
|
||||
$('#inputId').val('');
|
||||
$('#employee_id').val('');
|
||||
$('#leave_type').val('annual');
|
||||
$('#start_date').val('');
|
||||
$('#end_date').val('');
|
||||
$('#approval_status').val('pending');
|
||||
$('#reason').val('');
|
||||
|
||||
$('#modalLeave .modal-title')
|
||||
.text('Tambah Leave');
|
||||
}
|
||||
|
||||
// =====================================
|
||||
// ADD
|
||||
// =====================================
|
||||
$('.btn-add').click(function(){
|
||||
|
||||
resetForm();
|
||||
|
||||
$('#btnSimpan')
|
||||
.data('action','add');
|
||||
|
||||
$('#modalLeave').modal('show');
|
||||
|
||||
});
|
||||
|
||||
// =====================================
|
||||
// SAVE UPDATE
|
||||
// =====================================
|
||||
$('#btnSimpan').click(function(){
|
||||
|
||||
let action = $(this).data('action');
|
||||
|
||||
let data = {
|
||||
|
||||
id: $('#inputId').val(),
|
||||
|
||||
employee_id: $('#employee_id').val(),
|
||||
|
||||
leave_type: $('#leave_type').val(),
|
||||
|
||||
start_date: $('#start_date').val(),
|
||||
|
||||
end_date: $('#end_date').val(),
|
||||
|
||||
approval_status: $('#approval_status').val(),
|
||||
|
||||
reason: $('#reason').val()
|
||||
};
|
||||
|
||||
if(
|
||||
!data.employee_id ||
|
||||
!data.start_date ||
|
||||
!data.end_date
|
||||
){
|
||||
|
||||
Swal.fire(
|
||||
'Warning',
|
||||
'Data wajib diisi',
|
||||
'warning'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let url = action == 'add'
|
||||
? `<?= base_url(); ?>leaverequests/save`
|
||||
: `<?= base_url(); ?>leaverequests/update`;
|
||||
|
||||
$.ajax({
|
||||
url:url,
|
||||
type:'POST',
|
||||
data:data,
|
||||
dataType:'json',
|
||||
success:function(res){
|
||||
|
||||
if(res.status){
|
||||
|
||||
$('#modalLeave').modal('hide');
|
||||
|
||||
table.ajax.reload(null,false);
|
||||
|
||||
Swal.fire(
|
||||
'Sukses',
|
||||
res.message,
|
||||
'success'
|
||||
);
|
||||
|
||||
} else {
|
||||
|
||||
Swal.fire(
|
||||
'Error',
|
||||
res.message,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// =====================================
|
||||
// EDIT
|
||||
// =====================================
|
||||
$(document).on('click','.btn-edit',function(){
|
||||
|
||||
let id = $(this).data('id');
|
||||
|
||||
$.get(
|
||||
`<?= base_url(); ?>leaverequests/detail/` + id,
|
||||
function(res){
|
||||
|
||||
$('#inputId').val(res.id);
|
||||
|
||||
$('#employee_id').val(res.employee_id);
|
||||
|
||||
$('#leave_type').val(res.leave_type);
|
||||
|
||||
$('#start_date').val(res.start_date);
|
||||
|
||||
$('#end_date').val(res.end_date);
|
||||
|
||||
$('#approval_status')
|
||||
.val(res.approval_status);
|
||||
|
||||
$('#reason').val(res.reason);
|
||||
|
||||
$('#modalLeave .modal-title')
|
||||
.text('Edit Leave');
|
||||
|
||||
$('#btnSimpan')
|
||||
.data('action','edit');
|
||||
|
||||
$('#modalLeave').modal('show');
|
||||
|
||||
},
|
||||
'json'
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
// =====================================
|
||||
// DELETE
|
||||
// =====================================
|
||||
$(document).on('click','.btn-delete',function(){
|
||||
|
||||
let id = $(this).data('id');
|
||||
|
||||
Swal.fire({
|
||||
title:'Yakin hapus?',
|
||||
text:'Data tidak bisa dikembalikan',
|
||||
icon:'warning',
|
||||
showCancelButton:true
|
||||
}).then((result)=>{
|
||||
|
||||
if(result.isConfirmed){
|
||||
|
||||
$.get(
|
||||
`<?= base_url(); ?>leaverequests/delete/` + id,
|
||||
function(res){
|
||||
|
||||
if(res.status){
|
||||
|
||||
table.ajax.reload(null,false);
|
||||
|
||||
Swal.fire(
|
||||
'Sukses',
|
||||
res.message,
|
||||
'success'
|
||||
);
|
||||
}
|
||||
|
||||
},
|
||||
'json'
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
const permissions=<?=json_encode($permissions)?>,base='<?=base_url('leaverequests/')?>',csrf=()=>({[APP_CSRF.name]:APP_CSRF.value});
|
||||
const types={annual:'Cuti Tahunan',sick:'Sakit',permit:'Izin',maternity:'Cuti Melahirkan',unpaid:'Cuti Tidak Dibayar'};
|
||||
const statuses={draft:'Draft',submitted:'Menunggu Persetujuan',approved:'Disetujui',rejected:'Ditolak',cancelled:'Dibatalkan'};
|
||||
const esc=value=>$('<div>').text(value==null?'-':value).html();
|
||||
const date=value=>value?String(value).slice(0,10).split('-').reverse().join('/'):'-';
|
||||
const badge=status=>'<span class="leave-status leave-status-'+esc(status)+'">'+esc(statuses[status]||status)+'</span>';
|
||||
const toast=(message,icon)=>AppAlert.toast(message,icon||'success');
|
||||
function fail(xhr){let message=xhr.responseJSON&&xhr.responseJSON.message;if(!message&&xhr.statusText==='timeout')message='Waktu proses habis. Silakan coba kembali.';toast(message||'Proses tidak berhasil.','error')}
|
||||
function post(url,data){return $.ajax({url:url,type:'POST',data:Object.assign({},data||{},csrf()),dataType:'json',timeout:30000})}
|
||||
function actions(row){let out='<div class="d-flex flex-wrap gap-1"><button type="button" class="btn btn-sm btn-outline-primary leave-detail" data-id="'+row.id+'"><i class="bi bi-eye"></i> Detail</button>';if((row.workflow_status==='draft'||row.workflow_status==='rejected')&&permissions.update)out+='<button type="button" class="btn btn-sm btn-outline-secondary leave-edit" data-id="'+row.id+'"><i class="bi bi-pencil"></i> Edit</button>';if(row.workflow_status==='draft'&&permissions.create)out+='<button type="button" class="btn btn-sm btn-warning leave-submit" data-id="'+row.id+'"><i class="bi bi-send"></i> Ajukan</button>';if(row.workflow_status==='submitted'&&permissions.approve)out+='<button type="button" class="btn btn-sm btn-success leave-approve" data-id="'+row.id+'"><i class="bi bi-check-lg"></i> Setujui</button><button type="button" class="btn btn-sm btn-outline-danger leave-reject" data-id="'+row.id+'"><i class="bi bi-x-lg"></i> Tolak</button>';if((row.workflow_status==='draft'||row.workflow_status==='rejected')&&(permissions.update||permissions.create))out+='<button type="button" class="btn btn-sm btn-outline-danger leave-cancel" data-id="'+row.id+'" title="Batalkan"><i class="bi bi-trash"></i></button>';return out+'</div>'}
|
||||
const table=$('#tableLeave').DataTable({processing:true,responsive:true,autoWidth:false,pageLength:25,order:[],ajax:{url:base+'get_data',data:d=>{d.status=$('.leave-tabs button.active').data('status')||'';d.type=$('#filterType').val();d.employee_id=$('#filterEmployee').val()},dataSrc:function(response){updateCounts(response.summary||{});return response.data||[]}},columns:[{data:'request_no',render:(value,type,row)=>'<button type="button" class="btn btn-link p-0 fw-semibold leave-detail" data-id="'+row.id+'">'+esc(value)+'</button><small class="d-block text-muted">Dibuat '+esc(row.creator_name||'-')+'</small>'},{data:'full_name',render:(value,type,row)=>'<strong>'+esc(value)+'</strong><small class="d-block text-muted">'+esc(row.employee_code||row.fingerprint_user_id)+' · '+esc(row.department_name||'-')+'</small>'},{data:'leave_type',render:value=>types[value]||value},{data:null,render:row=>date(row.start_date)+' – '+date(row.end_date)+'<small class="d-block text-muted">'+Number(row.total_days||0).toLocaleString('id-ID')+' hari</small>'},{data:'workflow_status',render:(value,type,row)=>badge(value)+(value==='rejected'&&row.rejection_reason?'<small class="d-block text-danger mt-1">'+esc(row.rejection_reason)+'</small>':'')},{data:null,render:row=>row.workflow_status==='approved'?esc(row.approver_name||'-'):row.workflow_status==='rejected'?esc(row.rejector_name||'-'):row.workflow_status==='submitted'?'<span class="text-warning-emphasis">Menunggu HR</span>':'-'},{data:null,orderable:false,searchable:false,render:actions}],language:{search:'Cari:',lengthMenu:'Tampilkan _MENU_',info:'_START_–_END_ dari _TOTAL_',infoEmpty:'Belum ada pengajuan',zeroRecords:'Pengajuan tidak ditemukan',processing:'Memuat data...',paginate:{previous:'Sebelumnya',next:'Berikutnya'}}});
|
||||
function updateCounts(summary){Object.keys(summary).forEach(key=>{$('[data-leave-count="'+key+'"]').text(Number(summary[key]||0).toLocaleString('id-ID'));$('[data-tab-count="'+key+'"]').text(Number(summary[key]||0).toLocaleString('id-ID'))})}
|
||||
function reload(){table.ajax.reload(null,false)}
|
||||
$('.leave-tabs button').on('click',function(){$('.leave-tabs button').removeClass('active');$(this).addClass('active');reload()});
|
||||
$('#filterEmployee,#filterType').on('change',reload);$('#refreshLeave').on('click',reload);
|
||||
function resetForm(){$('#leaveForm')[0].reset();$('#leaveId').val('');$('#employeeId').val(null).trigger('change');$('#leaveType').val('annual');$('#rejectionNotice').addClass('d-none').empty();$('#leaveModal .modal-title').text('Buat Pengajuan Cuti / Izin')}
|
||||
$('.btn-add-leave').on('click',function(){resetForm();bootstrap.Modal.getOrCreateInstance(document.getElementById('leaveModal')).show()});
|
||||
$('#leaveForm').on('submit',function(event){event.preventDefault();let button=$('#saveLeave').prop('disabled',true).html('<span class="spinner-border spinner-border-sm"></span> Menyimpan');$.ajax({url:base+'save',type:'POST',data:$(this).serialize(),dataType:'json',timeout:30000}).done(response=>{bootstrap.Modal.getInstance(document.getElementById('leaveModal')).hide();reload();toast(response.message,'success')}).fail(fail).always(()=>button.prop('disabled',false).html('<i class="bi bi-save"></i> Simpan Draft'))});
|
||||
$(document).on('click','.leave-edit',function(){let id=$(this).data('id');$.get(base+'detail/'+id).done(response=>{let row=response.leave;resetForm();$('#leaveId').val(row.id);$('#employeeId').val(String(row.employee_id)).trigger('change');$('#leaveType').val(row.leave_type);$('#startDate').val(row.start_date);$('#endDate').val(row.end_date);$('#leaveReason').val(row.reason);if(row.rejection_reason)$('#rejectionNotice').removeClass('d-none').html('<strong>Alasan penolakan:</strong> '+esc(row.rejection_reason));$('#leaveModal .modal-title').text('Perbaiki '+row.request_no);bootstrap.Modal.getOrCreateInstance(document.getElementById('leaveModal')).show()}).fail(fail)});
|
||||
$(document).on('click','.leave-detail',function(){let id=$(this).data('id');$('#leaveDetailBody').html('<div class="text-center py-5"><span class="spinner-border text-warning"></span><p class="mt-2 mb-0">Memuat detail...</p></div>');bootstrap.Modal.getOrCreateInstance(document.getElementById('leaveDetailModal')).show();$.get(base+'detail/'+id).done(response=>{let row=response.leave,history=(response.history||[]).map(item=>'<li><span class="leave-history-dot"></span><div><strong>'+esc({submitted:'Diajukan',approved:'Disetujui',rejected:'Ditolak',cancelled:'Dibatalkan'}[item.action]||item.action)+'</strong><small>'+esc(item.user_name||'-')+' · '+esc(item.created_at)+'</small>'+(item.notes?'<p>'+esc(item.notes)+'</p>':'')+'</div></li>').join('');$('#detailTitle').text(row.request_no);$('#leaveDetailBody').html('<div class="leave-detail-grid"><span>Karyawan<strong>'+esc(row.full_name)+'</strong><small>'+esc(row.employee_code||row.fingerprint_user_id)+'</small></span><span>Jenis<strong>'+esc(types[row.leave_type]||row.leave_type)+'</strong></span><span>Periode<strong>'+date(row.start_date)+' – '+date(row.end_date)+'</strong><small>'+esc(row.total_days)+' hari</small></span><span>Status<strong>'+badge(row.workflow_status)+'</strong></span><span>Diajukan oleh<strong>'+esc(row.submitter_name||'-')+'</strong><small>'+esc(row.submitted_at||'-')+'</small></span><span>Disetujui oleh<strong>'+esc(row.approver_name||'-')+'</strong><small>'+esc(row.approved_at||'-')+'</small></span></div><div class="leave-reason"><small>ALASAN PENGAJUAN</small><p>'+esc(row.reason)+'</p></div>'+(row.rejection_reason?'<div class="alert alert-danger"><strong>Alasan penolakan</strong><br>'+esc(row.rejection_reason)+'</div>':'')+'<h6 class="mt-4">Riwayat Persetujuan</h6><ul class="leave-history">'+(history||'<li><span class="leave-history-dot"></span><div><strong>Draft dibuat</strong><small>'+esc(row.creator_name||'-')+' · '+esc(row.created_at)+'</small></div></li>')+'</ul>')}).fail(fail)});
|
||||
$(document).on('click','.leave-submit',function(){let id=$(this).data('id'),button=$(this),original=button.html();AppAlert.confirm({title:'Ajukan cuti / izin?',text:'Setelah diajukan, data tidak dapat diedit sampai diputuskan oleh approver.',confirmButtonText:'Ya, Ajukan'}).then(result=>{if(!result.isConfirmed)return;button.prop('disabled',true).html('<span class="spinner-border spinner-border-sm"></span> Memproses');post(base+'submit/'+id).done(response=>{reload();toast(response.message,'success')}).fail(fail).always(()=>button.prop('disabled',false).html(original))})});
|
||||
$(document).on('click','.leave-approve',function(){let id=$(this).data('id');AppAlert.prompt({title:'Setujui pengajuan?',input:'textarea',inputLabel:'Catatan approver (opsional)',confirmButtonText:'Setujui',icon:'question'}).then(result=>{if(result.isConfirmed)post(base+'approve/'+id,{notes:result.value||''}).done(response=>{reload();toast(response.message,'success')}).fail(fail)})});
|
||||
$(document).on('click','.leave-reject',function(){let id=$(this).data('id');AppAlert.prompt({title:'Tolak pengajuan?',input:'textarea',inputLabel:'Alasan penolakan',inputPlaceholder:'Jelaskan alasan agar pengajuan dapat diperbaiki',inputValidator:value=>!String(value||'').trim()?'Alasan penolakan wajib diisi.':undefined,confirmButtonText:'Tolak Pengajuan',icon:'warning'}).then(result=>{if(result.isConfirmed)post(base+'reject/'+id,{reason:result.value}).done(response=>{reload();toast(response.message,'success')}).fail(fail)})});
|
||||
$(document).on('click','.leave-cancel',function(){let id=$(this).data('id');AppAlert.confirm({title:'Batalkan Draft?',text:'Dokumen tetap disimpan sebagai histori dan tidak memengaruhi kehadiran.',confirmButtonText:'Ya, Batalkan'}).then(result=>{if(result.isConfirmed)post(base+'cancel/'+id).done(response=>{reload();toast(response.message,'success')}).fail(fail)})});
|
||||
const focus=new URLSearchParams(location.search).get('focus');if(focus)table.one('draw.dt',()=>setTimeout(()=>$('.leave-detail[data-id="'+String(focus).replace(/[^0-9]/g,'')+'"]').first().trigger('click'),100));
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -1,3 +1,4 @@
|
||||
<?php $this->load->view('hr_payroll/_header',array('active_menu'=>'shift','title'=>'Master Shift','subtitle'=>'Atur jam kerja, toleransi keterlambatan, dan pola shift karyawan.')); ?>
|
||||
<div class="container mt-4">
|
||||
|
||||
<div class="card modern-card shadow-sm border-0 rounded-4">
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
$hrActive = isset($active_menu) ? $active_menu : '';
|
||||
$hrTitle = isset($title) ? $title : 'HR & Payroll';
|
||||
$hrSubtitle = isset($subtitle) ? $subtitle : 'Kelola administrasi karyawan secara tertib dan mudah dipahami.';
|
||||
$employeePages = array('employees','organization_master');
|
||||
$attendancePages = array('generate_schedule','attendance_monitoring','attendance_monthly','shift','holidays','leave_requests');
|
||||
if (in_array($hrActive, $employeePages, true)) {
|
||||
$hrLinks = array(
|
||||
array('employees','Data Karyawan','employees','hr_employees','bi-person-vcard'),
|
||||
array('organization_master','Departemen & Posisi','organizationmaster','hr_organization','bi-diagram-3')
|
||||
);
|
||||
} elseif (in_array($hrActive, $attendancePages, true)) {
|
||||
$hrLinks = array(
|
||||
array('generate_schedule','Jadwal Kerja','generateschedule','hr_schedules','bi-calendar3'),
|
||||
array('attendance_monitoring','Monitoring Absen','attendancemonitoring','hr_attendance','bi-person-check'),
|
||||
array('attendance_monthly','Absen Bulanan','attendancemonthly','hr_attendance','bi-calendar-check'),
|
||||
array('holidays','Hari Libur','holidays','hr_schedules','bi-calendar-event'),
|
||||
array('shift','Shift','shifts','hr_schedules','bi-clock'),
|
||||
array('leave_requests','Cuti & Izin','leaverequests','hr_leave','bi-calendar2-minus')
|
||||
);
|
||||
} else {
|
||||
$hrLinks = array(
|
||||
array('hr_payroll','Daftar Periode Gaji','hrpayroll?section=periods','payroll','bi-calendar2-range'),
|
||||
array('hr_payroll_salary','Master Gaji Karyawan','hrpayroll?section=salaries','payroll','bi-person-lines-fill'),
|
||||
array('hr_payroll_settings','Setting Payroll','hrpayroll?section=settings','payroll','bi-sliders'),
|
||||
array('hr_payroll_adjustments','Penyesuaian Periode','hrpayroll?section=adjustments','payroll','bi-plus-minus')
|
||||
);
|
||||
}
|
||||
?>
|
||||
<section class="hr-module-hero">
|
||||
<div><span>HR & PAYROLL WORKSPACE</span><h2 class="text-light"><?=html_escape($hrTitle)?></h2><p><?=html_escape($hrSubtitle)?></p></div>
|
||||
<?php if (!empty($action_html)) echo $action_html; ?>
|
||||
</section>
|
||||
<nav class="hr-module-nav" aria-label="Navigasi HR & Payroll">
|
||||
<?php foreach ($hrLinks as $link): if (!is_master_admin_user() && !check_permission($link[3],'can_view') && !check_permission('employees','can_view')) continue; ?>
|
||||
<?php $isActive = $hrActive === $link[0] || ($hrActive === 'hr_payroll' && strpos($link[2], 'section='.((string)($payroll_section ?? 'periods'))) !== false); ?>
|
||||
<a class="<?=$isActive?'active':''?>" href="<?=base_url($link[2])?>"><i class="bi <?=$link[4]?>"></i><span><?=html_escape($link[1])?></span></a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
@@ -1,3 +1,57 @@
|
||||
<div class="container-fluid py-3"><h4>HR & Payroll Control</h4><p class="text-muted">Lock absensi → Draft → Review HR → Approval → Final → Jurnal → Pembayaran.</p><?php foreach(array('success','error')as$f)if($this->session->flashdata($f)):?><div class="alert alert-<?=$f==='success'?'success':'danger'?>"><?=html_escape($this->session->flashdata($f))?></div><?php endif?><div class="row g-3"><div class="col-lg-4"><div class="card h-100"><div class="card-header">1. Lock Absensi Bulanan</div><div class="card-body"><form method="post" action="<?=base_url('hrpayroll/lock_attendance')?>"><input class="form-control mb-2" type="month" name="period" value="<?=date('Y-m')?>" required><input class="form-control mb-2" name="notes" required placeholder="Catatan validasi absensi"><button class="btn btn-warning">Validasi & Lock</button></form></div></div></div><div class="col-lg-4"><div class="card h-100"><div class="card-header">2. Hitung Draft Payroll</div><div class="card-body"><form method="post" action="<?=base_url('hrpayroll/calculate')?>"><input class="form-control mb-2" type="month" name="month" value="<?=date('Y-m')?>" required><p class="small">Menggunakan snapshot absensi terkunci dan formula komponen aktif.</p><button class="btn btn-primary">Generate/Hitung Ulang Draft</button></form></div></div></div><div class="col-lg-4"><div class="card h-100"><div class="card-header">Formula Configurable</div><div class="card-body"><small>Variabel: basic_salary, work_days, present_days, unpaid_days, overtime_hours, overtime_rate, daily_rate.</small><form class="mt-2" method="post" action="<?=base_url('hrpayroll/component')?>"><div class="d-flex gap-1"><input class="form-control" name="code" placeholder="Kode" required><input class="form-control" name="name" placeholder="Nama" required></div><div class="d-flex gap-1 my-1"><select class="form-select" name="component_type"><option value="earning">Pendapatan</option><option value="deduction">Potongan</option><option value="employer_cost">Beban perusahaan</option></select><select class="form-select" name="category"><option>allowance</option><option>overtime</option><option>unpaid_leave</option><option>loan</option><option>bpjs</option><option>tax</option><option>thr</option><option>bonus</option><option>other</option></select></div><input class="form-control mb-1" name="formula_expression" placeholder="Contoh: overtime_hours * overtime_rate"><input class="form-control mb-1" type="number" name="fixed_amount" placeholder="Atau nominal tetap"><label class="me-2"><input type="checkbox" name="taxable" value="1"> Taxable</label><label><input type="checkbox" name="prorated" value="1"> Prorata</label><br><button class="btn btn-sm btn-outline-primary mt-2">Simpan Komponen</button></form></div></div></div></div>
|
||||
<div class="card mt-3"><div class="card-header">Payroll Register</div><div class="table-responsive"><table class="table mb-0"><tr><th>Periode</th><th>Karyawan</th><th>Bruto</th><th>Potongan</th><th>Neto</th><th>Status</th><th>Workflow</th></tr><?php foreach($periods as$p):?><tr><td><?=html_escape($p->period_name)?></td><td><?=$p->employee_count?></td><td class="text-end"><?=number_format($p->gross,0,',','.')?></td><td class="text-end"><?=number_format($p->deductions,0,',','.')?></td><td class="text-end"><?=number_format($p->net,0,',','.')?></td><td><span class="badge bg-secondary"><?=$p->workflow_status?></span></td><td><div class="d-flex gap-1"><?php if($p->workflow_status==='draft'):?><form method="post" action="<?=base_url('hrpayroll/workflow/'.$p->id.'/review')?>"><button class="btn btn-sm btn-info">Review HR</button></form><?php elseif($p->workflow_status==='reviewed'):?><form method="post" action="<?=base_url('hrpayroll/workflow/'.$p->id.'/approve')?>"><button class="btn btn-sm btn-warning">Approve</button></form><?php elseif($p->workflow_status==='approved'):?><form method="post" action="<?=base_url('hrpayroll/workflow/'.$p->id.'/finalize')?>"><button class="btn btn-sm btn-danger">Final & Lock</button></form><?php elseif($p->workflow_status==='final'):?><form class="d-flex" method="post" action="<?=base_url('hrpayroll/post/'.$p->id)?>"><input class="form-control form-control-sm" type="date" name="date" value="<?=$p->end_date?>"><button class="btn btn-sm btn-success">Post Jurnal</button></form><?php elseif($p->workflow_status==='posted'):?><form class="d-flex" method="post" action="<?=base_url('hrpayroll/pay/'.$p->id)?>"><input class="form-control form-control-sm" type="date" name="date" value="<?=date('Y-m-d')?>"><select class="form-select form-select-sm" name="cash_account_id"><?php foreach($cash_accounts as$c):?><option value="<?=$c->id?>"><?=html_escape($c->name)?></option><?php endforeach?></select><button class="btn btn-sm btn-primary">Bayar</button></form><?php endif?></div></td></tr><?php endforeach?></table></div></div>
|
||||
<div class="card mt-3"><div class="card-header">Komponen Aktif</div><div class="table-responsive"><table class="table table-sm"><tr><th>Kode</th><th>Nama</th><th>Jenis</th><th>Kategori</th><th>Formula/Nominal</th><th>Pajak</th></tr><?php foreach($components as$c):?><tr><td><?=$c->code?></td><td><?=html_escape($c->name)?></td><td><?=$c->component_type?></td><td><?=$c->category?></td><td><?=html_escape($c->formula_expression?:number_format($c->fixed_amount,0,',','.'))?></td><td><?=$c->taxable?'Ya':'Tidak'?></td></tr><?php endforeach?></table></div></div></div>
|
||||
<?php
|
||||
$sectionLabels=array('periods'=>array('hr_payroll','Daftar Periode Gaji','Kelola rentang kehadiran, proses payroll, jurnal, dan pembayaran dalam satu alur.'),'salaries'=>array('hr_payroll_salary','Master Gaji Karyawan','Atur pendapatan dan potongan berulang dengan histori tanggal efektif.'),'settings'=>array('hr_payroll_settings','Setting Payroll','Kelola komponen, template posisi, dan aturan kehadiran tanpa input berulang setiap bulan.'),'adjustments'=>array('hr_payroll_adjustments','Penyesuaian Periode','Catat bonus, rapel, insentif, atau potongan yang hanya berlaku pada satu periode.'));
|
||||
$meta=$sectionLabels[$payroll_section];
|
||||
$this->load->view('hr_payroll/_header',array('active_menu'=>$meta[0],'payroll_section'=>$payroll_section,'title'=>$meta[1],'subtitle'=>$meta[2]));
|
||||
$csrf='<input type="hidden" name="'.$this->security->get_csrf_token_name().'" value="'.$this->security->get_csrf_hash().'">';
|
||||
$methodLabels=array('fixed'=>'Nominal tetap','per_present_day'=>'Per hari hadir','per_absent_day'=>'Per hari tidak hadir','per_minute'=>'Per menit','per_hour'=>'Per jam','percentage_basic'=>'Persentase gaji pokok','formula'=>'Formula tervalidasi');
|
||||
$statusLabels=array('draft'=>'Draft','reviewed'=>'Sudah Direview','approved'=>'Disetujui','final'=>'Final','posted'=>'Terposting','paid'=>'Dibayar','cancelled'=>'Dibatalkan');
|
||||
?>
|
||||
<?php foreach(array('success','error')as$flash):if($this->session->flashdata($flash)):?><div data-app-toast data-icon="<?=$flash==='success'?'success':'error'?>" data-message="<?=html_escape($this->session->flashdata($flash))?>"></div><?php endif;endforeach?>
|
||||
<?php if(!$can_manage_all_salary):?><div class="alert alert-warning"><i class="bi bi-lock-fill me-1"></i> Tampilan ini tidak memuat nilai maupun total gaji terbatas. Akses tersebut memerlukan permission <b>Manage All Gaji</b>.</div><?php endif?>
|
||||
|
||||
<?php if($payroll_section==='periods'):?>
|
||||
<div class="hr-workflow-strip" aria-label="Tahapan payroll"><span><b>1</b>Buat Periode</span><span><b>2</b>Lock Kehadiran</span><span><b>3</b>Hitung Draft</span><span><b>4</b>Review HR</span><span><b>5</b>Approve & Final</span><span><b>6</b>Posting Jurnal</span><span><b>7</b>Pembayaran</span></div>
|
||||
<?php if($permissions['create']):?><section class="card hr-panel mb-3"><div class="card-header bg-white"><b>Buat Periode Gaji</b><small class="d-block text-muted">Rentang kehadiran bebas melintasi bulan; periode pada perusahaan yang sama tidak boleh tumpang tindih.</small></div><div class="card-body"><form method="post" action="<?=base_url('hrpayroll/create_period')?>" class="row g-3"><?=$csrf?><div class="col-lg-3"><label class="form-label">Nama Periode</label><input class="form-control" name="period_name" placeholder="Contoh: Gaji September 2026" required></div><div class="col-md-3 col-lg-2"><label class="form-label">Awal Kehadiran</label><input type="date" class="form-control" name="attendance_start_date" required></div><div class="col-md-3 col-lg-2"><label class="form-label">Akhir Kehadiran</label><input type="date" class="form-control" name="attendance_end_date" required></div><div class="col-md-3 col-lg-2"><label class="form-label">Tanggal Bayar</label><input type="date" class="form-control" name="payment_date" required></div><div class="col-lg-3"><label class="form-label">Keterangan</label><div class="input-group"><input class="form-control" name="notes" placeholder="Opsional"><button class="btn btn-primary"><i class="bi bi-plus-lg"></i> Buat</button></div></div></form></div></section><?php endif?>
|
||||
<section class="card hr-panel"><div class="card-header bg-white"><b>Daftar Periode Payroll</b><small class="d-block text-muted">Aksi hanya muncul sesuai status dan permission. Finalisasi tidak membuat jurnal; jurnal dibuat pada tahap Posting.</small></div><div class="table-responsive"><table class="table align-middle mb-0 payroll-table"><thead><tr><th>Periode</th><th>Lock Kehadiran</th><th>Status</th><th class="text-end">Karyawan</th><th class="text-end">Bruto</th><th class="text-end">Potongan</th><th class="text-end">Neto</th><th>Aksi Berikutnya</th></tr></thead><tbody>
|
||||
<?php if(!$periods):?><tr><td colspan="8" class="text-center text-muted py-5">Belum ada periode payroll. Buat satu periode untuk memulai.</td></tr><?php endif;foreach($periods as$period):$start=$period->attendance_start_date?:$period->start_date;$end=$period->attendance_end_date?:$period->end_date;$locked=$period->attendance_lock_status==='locked';?>
|
||||
<tr><td><b><?=html_escape($period->period_name)?></b><small class="d-block text-muted"><?=html_escape($start)?> s.d. <?=html_escape($end)?></small><small class="text-muted">Bayar: <?=html_escape($period->payment_date?:'-')?></small></td><td><span class="badge <?=$locked?'bg-success':'bg-secondary'?>"><?=$locked?'Terkunci':'Belum dikunci'?></span></td><td><span class="badge bg-<?=in_array($period->workflow_status,array('posted','paid'),true)?'success':($period->workflow_status==='draft'?'secondary':'warning')?>"><?=html_escape($statusLabels[$period->workflow_status]??$period->workflow_status)?></span></td><td class="text-end"><?=number_format($period->employee_count)?></td><td class="text-end">Rp <?=number_format($period->gross,2,',','.')?></td><td class="text-end">Rp <?=number_format($period->deductions,2,',','.')?></td><td class="text-end fw-bold">Rp <?=number_format($period->net,2,',','.')?></td><td><div class="payroll-actions"><button class="btn btn-sm btn-outline-primary payroll-detail" data-id="<?=$period->id?>"><i class="bi bi-eye"></i> Detail</button>
|
||||
<?php if($permissions['post']&&$period->workflow_status==='draft'&&!$locked):?><form method="post" action="<?=base_url('hrpayroll/lock_attendance')?>" class="js-confirm" data-confirm-title="Kunci rentang kehadiran?" data-confirm-text="Absensi dalam rentang ini tidak dapat diubah setelah dikunci."><?=$csrf?><input type="hidden" name="period_id" value="<?=$period->id?>"><input type="hidden" name="notes" value="Validasi payroll <?=html_escape($period->period_name)?>"><button class="btn btn-sm btn-warning">Lock Kehadiran</button></form><?php endif?>
|
||||
<?php if($permissions['create']&&$period->workflow_status==='draft'&&$locked):?><form method="post" action="<?=base_url('hrpayroll/calculate')?>" class="js-confirm" data-confirm-title="Hitung draft payroll?" data-confirm-text="Hasil Draft periode ini akan dibuat ulang tanpa menggandakan rincian."><?=$csrf?><input type="hidden" name="period_id" value="<?=$period->id?>"><button class="btn btn-sm btn-primary"><?=empty($period->calculated_at)?'Hitung Draft':'Hitung Ulang'?></button></form><?php endif?>
|
||||
<?php if($permissions['approve']&&$period->workflow_status==='draft'&&!empty($period->calculated_at)):?><form method="post" action="<?=base_url('hrpayroll/workflow/'.$period->id.'/review')?>" class="js-confirm"><?=$csrf?><button class="btn btn-sm btn-warning">Review HR</button></form><?php endif?>
|
||||
<?php if($permissions['approve']&&$period->workflow_status==='reviewed'):?><form method="post" action="<?=base_url('hrpayroll/workflow/'.$period->id.'/approve')?>" class="js-confirm"><?=$csrf?><button class="btn btn-sm btn-success">Approve</button></form><form method="post" action="<?=base_url('hrpayroll/workflow/'.$period->id.'/revert')?>" class="js-confirm"><?=$csrf?><button class="btn btn-sm btn-outline-secondary">Kembali ke Draft</button></form><?php endif?>
|
||||
<?php if($permissions['approve']&&$period->workflow_status==='approved'):?><form method="post" action="<?=base_url('hrpayroll/workflow/'.$period->id.'/finalize')?>" class="js-confirm"><?=$csrf?><button class="btn btn-sm btn-dark">Finalisasi</button></form><form method="post" action="<?=base_url('hrpayroll/workflow/'.$period->id.'/revert')?>" class="js-confirm"><?=$csrf?><button class="btn btn-sm btn-outline-secondary">Kembali ke Draft</button></form><?php endif?>
|
||||
<?php if($permissions['post']&&$period->workflow_status==='final'):?><form method="post" action="<?=base_url('hrpayroll/post/'.$period->id)?>" class="d-flex gap-1 js-confirm" data-confirm-title="Posting jurnal payroll?"><?=$csrf?><input class="form-control form-control-sm" type="date" name="date" value="<?=$period->payment_date?:date('Y-m-d')?>" required><button class="btn btn-sm btn-success">Posting</button></form><?php endif?>
|
||||
<?php if($permissions['post']&&$period->workflow_status==='posted'):?><form method="post" action="<?=base_url('hrpayroll/pay/'.$period->id)?>" class="d-flex gap-1 js-confirm" data-confirm-title="Bayar payroll?"><?=$csrf?><input class="form-control form-control-sm" type="date" name="date" value="<?=$period->payment_date?:date('Y-m-d')?>" required><select class="form-select form-select-sm" name="cash_account_id" required><option value="">Kas/Bank</option><?php foreach($cash_accounts as$account):?><option value="<?=$account->id?>"><?=html_escape($account->code.' - '.$account->name)?></option><?php endforeach?></select><button class="btn btn-sm btn-success">Bayar</button></form><?php endif?>
|
||||
<?php if($permissions['export']&&in_array($period->workflow_status,array('final','posted','paid'),true)):?><a class="btn btn-sm btn-outline-danger" href="<?=base_url('hrpayroll/export_pdf/'.$period->id)?>">PDF</a><a class="btn btn-sm btn-outline-success" href="<?=base_url('hrpayroll/export_excel/'.$period->id.'/general')?>">Excel</a><div class="dropdown"><button class="btn btn-sm btn-outline-primary dropdown-toggle" data-bs-toggle="dropdown">Sumber Bank</button><div class="dropdown-menu"><a class="dropdown-item" href="<?=base_url('hrpayroll/export_excel/'.$period->id.'/bri')?>">BRI / QLola</a><a class="dropdown-item" href="<?=base_url('hrpayroll/export_excel/'.$period->id.'/bca')?>">BCA</a></div></div><?php endif?></div></td></tr><?php endforeach?>
|
||||
</tbody></table></div></section>
|
||||
|
||||
<?php elseif($payroll_section==='salaries'):?>
|
||||
<section class="card hr-panel"><div class="card-header bg-white"><b>Master Gaji Karyawan</b><small class="d-block text-muted">Estimasi hanya menjumlahkan komponen nominal tetap yang aktif saat ini; nilai final dihitung pada periode payroll.</small></div><div class="card-body border-bottom"><div class="row g-2"><div class="col-md-4"><label class="form-label">Departemen</label><select id="salaryDepartmentFilter" class="form-select"><option value="">Semua departemen</option><?php foreach($departments as$d):?><option><?=html_escape($d->department_name)?></option><?php endforeach?></select></div><div class="col-md-4"><label class="form-label">Posisi</label><select id="salaryPositionFilter" class="form-select"><option value="">Semua posisi</option><?php foreach($positions as$p):?><option><?=html_escape($p->position_name)?></option><?php endforeach?></select></div><div class="col-md-4"><label class="form-label">Status</label><select id="salaryStatusFilter" class="form-select"><option value="">Semua status</option><option>Aktif</option><option>Nonaktif</option></select></div></div></div><div class="table-responsive"><table id="salaryTable" class="table align-middle mb-0"><thead><tr><th>Karyawan</th><th>Departemen</th><th>Posisi</th><th>Status</th><th class="text-end">Pendapatan Tetap</th><th class="text-end">Potongan Tetap</th><th class="text-end">Estimasi Neto</th><th>Efektif</th><th>Aksi</th></tr></thead><tbody><?php foreach($salary_rows as$row):?><tr><td><b><?=html_escape($row->full_name)?></b><small class="d-block text-muted"><?=html_escape($row->employee_code)?></small></td><td><?=html_escape($row->department_name?:'-')?></td><td><?=html_escape($row->position_name?:'-')?></td><td><span class="badge <?=$row->is_active?'bg-success':'bg-secondary'?>"><?=$row->is_active?'Aktif':'Nonaktif'?></span><?php if($row->salary_restricted):?><span class="badge bg-danger">Gaji terbatas</span><?php endif?></td><td class="text-end">Rp <?=number_format($row->fixed_earnings,2,',','.')?></td><td class="text-end">Rp <?=number_format($row->fixed_deductions,2,',','.')?></td><td class="text-end fw-bold">Rp <?=number_format($row->estimated_net,2,',','.')?></td><td><?=html_escape($row->effective_from?:'-')?></td><td><?php if($permissions['update']):?><button class="btn btn-sm btn-outline-primary salary-manage" data-id="<?=$row->id?>"><i class="bi bi-sliders"></i> Atur Gaji</button><?php else:?><button class="btn btn-sm btn-outline-primary salary-manage" data-id="<?=$row->id?>">Detail</button><?php endif?></td></tr><?php endforeach?></tbody></table></div></section>
|
||||
|
||||
<?php elseif($payroll_section==='settings'):?>
|
||||
<ul class="nav nav-pills payroll-setting-tabs mb-3" role="tablist"><li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#componentsTab">Master Komponen</button></li><li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#positionsTab">Template Posisi</button></li><li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#attendanceRulesTab">Aturan Kehadiran</button></li><li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#generalTab">Pengaturan Umum</button></li></ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="componentsTab"><section class="card hr-panel"><div class="card-header bg-white"><b>Master Komponen</b><small class="d-block text-muted">Komponen tidak otomatis berlaku untuk semua karyawan.</small></div><?php if($permissions['update']):?><div class="card-body border-bottom"><form method="post" action="<?=base_url('hrpayroll/component')?>" class="row g-2"><?=$csrf?><div class="col-md-2"><label class="form-label">Kode</label><input class="form-control" name="code" required></div><div class="col-md-3"><label class="form-label">Nama</label><input class="form-control" name="name" required></div><div class="col-md-2"><label class="form-label">Jenis</label><select class="form-select" name="component_type"><option value="earning">Pendapatan</option><option value="deduction">Potongan</option><option value="employer_cost">Beban perusahaan</option></select></div><div class="col-md-2"><label class="form-label">Kategori</label><select class="form-select" name="category"><option value="basic">Gaji Pokok</option><option value="allowance">Tunjangan</option><option value="overtime">Lembur</option><option value="unpaid_leave">Cuti tak dibayar</option><option value="loan">Pinjaman</option><option value="bpjs">BPJS</option><option value="tax">Pajak</option><option value="thr">THR</option><option value="bonus">Bonus</option><option value="other">Lainnya</option></select></div><div class="col-md-3"><label class="form-label">Metode</label><select class="form-select" name="calculation_method"><?php foreach($methodLabels as$key=>$label):?><option value="<?=$key?>"><?=html_escape($label)?></option><?php endforeach?></select></div><div class="col-md-2"><label class="form-label">Nilai Default</label><input type="number" min="0" step="0.01" class="form-control" name="default_value" value="0"></div><div class="col-md-3"><label class="form-label">Formula</label><input class="form-control" name="formula_expression" placeholder="present_days * 25000"></div><div class="col-md-2"><label class="form-label">Akun Beban</label><select class="form-select searchable" name="expense_account_id"><option value="">Mapping umum</option><?php foreach($accounts as$a):?><option value="<?=$a->id?>"><?=html_escape($a->kode_akun.' - '.$a->nama_akun)?></option><?php endforeach?></select></div><div class="col-md-2"><label class="form-label">Akun Kewajiban</label><select class="form-select searchable" name="liability_account_id"><option value="">Mapping umum</option><?php foreach($accounts as$a):?><option value="<?=$a->id?>"><?=html_escape($a->kode_akun.' - '.$a->nama_akun)?></option><?php endforeach?></select></div><div class="col-md-2"><label class="form-label">Mulai Berlaku</label><input type="date" class="form-control" name="effective_from" value="<?=date('Y-m-d')?>"></div><div class="col-md-1 d-flex align-items-end"><label class="form-check mb-2"><input class="form-check-input" type="checkbox" name="is_active" value="1" checked> Aktif</label></div><div class="col-md-2 d-flex align-items-end justify-content-end"><button class="btn btn-primary">Simpan Komponen</button></div></form></div><?php endif?><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Kode</th><th>Komponen</th><th>Jenis</th><th>Metode</th><th class="text-end">Default</th><th>Mapping</th><th>Status</th></tr></thead><tbody><?php foreach($components as$c):?><tr><td><b><?=html_escape($c->code)?></b></td><td><?=html_escape($c->name)?></td><td><?=html_escape($c->component_type)?></td><td><?=html_escape($methodLabels[$c->calculation_method]??$c->calculation_method)?></td><td class="text-end">Rp <?=number_format($c->default_value??$c->fixed_amount,2,',','.')?></td><td><?=html_escape(($c->expense_account_id?:'mapping umum').' / '.($c->liability_account_id?:'mapping umum'))?></td><td><span class="badge <?=$c->is_active?'bg-success':'bg-secondary'?>"><?=$c->is_active?'Aktif':'Nonaktif'?></span></td></tr><?php endforeach?></tbody></table></div></section></div>
|
||||
<div class="tab-pane fade" id="positionsTab"><section class="card hr-panel"><div class="card-header bg-white"><b>Template Posisi</b><small class="d-block text-muted">Override karyawan tetap memiliki prioritas di atas template posisi.</small></div><?php if($permissions['update']):?><div class="card-body border-bottom"><form method="post" action="<?=base_url('hrpayroll/save_position_line')?>" class="row g-2"><?=$csrf?><div class="col-md-3"><label class="form-label">Posisi</label><select class="form-select searchable" name="position_id" required><option value="">Pilih posisi</option><?php foreach($positions as$p):?><option value="<?=$p->id?>"><?=html_escape($p->position_name)?></option><?php endforeach?></select></div><div class="col-md-3"><label class="form-label">Komponen</label><select class="form-select searchable" name="component_id" required><option value="">Pilih komponen</option><?php foreach($components as$c):?><option value="<?=$c->id?>"><?=html_escape($c->code.' - '.$c->name)?></option><?php endforeach?></select></div><div class="col-md-2"><label class="form-label">Metode</label><select class="form-select" name="calculation_method"><?php foreach($methodLabels as$key=>$label):?><option value="<?=$key?>"><?=html_escape($label)?></option><?php endforeach?></select></div><div class="col-md-2"><label class="form-label">Nilai/Tarif</label><input type="number" min="0" step="0.01" class="form-control" name="amount_override" value="0"></div><div class="col-md-2"><label class="form-label">Mulai Berlaku</label><input type="date" class="form-control" name="effective_from" value="<?=date('Y-m-d')?>" required></div><div class="col-md-10"><input class="form-control" name="notes" placeholder="Keterangan template, opsional"></div><div class="col-md-2"><button class="btn btn-primary w-100">Tambah Template</button></div></form></div><?php endif?><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Posisi</th><th>Komponen</th><th>Metode</th><th class="text-end">Nilai</th><th>Efektif</th><th>Status</th></tr></thead><tbody><?php if(!$position_assignments):?><tr><td colspan="6" class="text-center text-muted py-4">Belum ada template posisi.</td></tr><?php endif;foreach($position_assignments as$x):?><tr><td><?=html_escape($x->position_name)?></td><td><?=html_escape($x->component_code.' - '.$x->component_name)?></td><td><?=html_escape($methodLabels[$x->calculation_method]??$x->calculation_method)?></td><td class="text-end">Rp <?=number_format($x->amount_override,2,',','.')?></td><td><?=html_escape($x->effective_from)?><?=!empty($x->effective_to)?' s.d. '.html_escape($x->effective_to):''?></td><td><span class="badge <?=$x->is_active?'bg-success':'bg-secondary'?>"><?=$x->is_active?'Aktif':'Nonaktif'?></span></td></tr><?php endforeach?></tbody></table></div></section></div>
|
||||
<div class="tab-pane fade" id="attendanceRulesTab"><section class="card hr-panel"><div class="card-header bg-white"><b>Aturan Kehadiran</b><small class="d-block text-muted">Terlambat secara default tidak dipotong. Cuti tahunan, sakit, izin, dan hari libur tidak dianggap alpa.</small></div><?php if($permissions['update']):?><div class="card-body border-bottom"><form method="post" action="<?=base_url('hrpayroll/save_attendance_rule')?>" class="row g-2"><?=$csrf?><div class="col-md-3"><label class="form-label">Nama Aturan</label><input class="form-control" name="rule_name" required></div><div class="col-md-2"><label class="form-label">Kondisi</label><select class="form-select" name="condition_type"><option value="alpha">Alpa</option><option value="late">Terlambat</option><option value="early_leave">Pulang cepat</option><option value="unpaid">Cuti tidak dibayar</option><option value="missing_checkin">Tidak check-in</option><option value="missing_checkout">Tidak check-out</option></select></div><div class="col-md-2"><label class="form-label">Metode Potongan</label><select class="form-select" name="calculation_method"><option value="none">Tidak dipotong</option><option value="fixed">Nominal tetap</option><option value="per_minute">Per menit</option><option value="per_hour">Per jam</option><option value="per_day">Per hari</option><option value="percentage_daily">Persentase tarif harian</option><option value="formula">Formula</option></select></div><div class="col-md-2"><label class="form-label">Komponen Potongan</label><select class="form-select searchable" name="component_id"><option value="">Tidak ada</option><?php foreach($components as$c):if($c->component_type!=='deduction')continue;?><option value="<?=$c->id?>"><?=html_escape($c->name)?></option><?php endforeach?></select></div><div class="col-md-1"><label class="form-label">Toleransi</label><input type="number" min="0" class="form-control" name="tolerance_minutes" value="0"></div><div class="col-md-2"><label class="form-label">Tarif</label><input type="number" min="0" step="0.01" class="form-control" name="rate_value" value="0"></div><div class="col-md-2"><label class="form-label">Maksimum</label><input type="number" min="0" step="0.01" class="form-control" name="maximum_amount"></div><div class="col-md-3"><label class="form-label">Mulai Berlaku</label><input type="date" class="form-control" name="effective_from" value="<?=date('Y-m-d')?>" required></div><div class="col-md-4"><label class="form-label">Formula / Keterangan</label><input class="form-control" name="formula_expression" placeholder="condition_quantity * rule_rate"></div><div class="col-md-1 d-flex align-items-end"><label class="form-check mb-2"><input class="form-check-input" type="checkbox" name="is_active" value="1" checked> Aktif</label></div><div class="col-md-2 d-flex align-items-end"><button class="btn btn-primary w-100">Simpan Versi</button></div></form></div><?php endif?><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Aturan</th><th>Kondisi</th><th>Metode</th><th>Toleransi</th><th class="text-end">Tarif</th><th>Komponen</th><th>Efektif</th></tr></thead><tbody><?php foreach($attendance_rules as$r):?><tr><td><?=html_escape($r->rule_name)?></td><td><?=html_escape($r->condition_type)?></td><td><?=html_escape($r->calculation_method)?></td><td><?=number_format($r->tolerance_minutes)?> menit</td><td class="text-end"><?=number_format($r->rate_value,2,',','.')?></td><td><?=html_escape($r->component_name?:'Tidak memotong')?></td><td><?=html_escape($r->effective_from)?></td></tr><?php endforeach?></tbody></table></div></section></div>
|
||||
<div class="tab-pane fade" id="generalTab"><section class="card hr-panel"><div class="card-header bg-white"><b>Pengaturan Umum Payroll</b><small class="d-block text-muted">Berlaku untuk perusahaan aktif dan tidak mengubah histori payroll lama.</small></div><div class="card-body"><div class="row g-3 mb-4"><div class="col-md-4"><div class="payroll-info-box"><b>Sumber Kehadiran</b><span>Rentang tanggal pada periode payroll</span></div></div><div class="col-md-4"><div class="payroll-info-box"><b>Prioritas Komponen</b><span>Override karyawan → template posisi → nilai default</span></div></div><div class="col-md-4"><div class="payroll-info-box"><b>Pengamanan</b><span>Snapshot, separation of duties, idempotency jurnal</span></div></div></div><?php if($permissions['update']):?><form method="post" action="<?=base_url('hrpayroll/save_settings')?>" class="row g-3"><?=$csrf?><div class="col-md-4"><label class="form-label">Pemisahan Tugas</label><label class="form-check form-switch"><input class="form-check-input" type="checkbox" name="separation_of_duties" value="1" <?=!$payroll_settings||$payroll_settings->separation_of_duties?'checked':''?>> Reviewer, approver, dan finalizer harus berbeda</label></div><div class="col-md-3"><label class="form-label">Tanggal Bayar Default</label><input type="number" min="1" max="31" class="form-control" name="default_payment_day" value="<?=html_escape($payroll_settings->default_payment_day??'')?>" placeholder="1–31"></div><div class="col-md-5"><label class="form-label">Catatan Export Bank</label><div class="input-group"><input class="form-control" name="bank_export_note" value="<?=html_escape($payroll_settings->bank_export_note??'')?>" placeholder="Instruksi internal sebelum upload"><button class="btn btn-primary">Simpan</button></div></div></form><?php endif?><p class="text-muted small mt-3 mb-0">Akun jurnal utama dikelola melalui System Account Mapping. Akun khusus dapat dipilih pada masing-masing komponen.</p></div></section></div>
|
||||
</div>
|
||||
|
||||
<?php else:?>
|
||||
<?php if($permissions['create']):?><section class="card hr-panel mb-3"><div class="card-header bg-white"><b>Tambah Penyesuaian Satu Periode</b><small class="d-block text-muted">Tidak membuat jurnal saat disimpan dan tidak berulang ke periode berikutnya.</small></div><div class="card-body"><form method="post" action="<?=base_url('hrpayroll/save_adjustment')?>" class="row g-2"><?=$csrf?><div class="col-md-3"><label class="form-label">Periode Draft</label><select class="form-select searchable" name="payroll_period_id" required><option value="">Pilih periode</option><?php foreach($periods as$p):if($p->workflow_status!=='draft')continue;?><option value="<?=$p->id?>"><?=html_escape($p->period_name)?></option><?php endforeach?></select></div><div class="col-md-3"><label class="form-label">Karyawan</label><select class="form-select searchable" name="employee_id" required><option value="">Pilih karyawan</option><?php foreach($employees as$e):?><option value="<?=$e->id?>"><?=html_escape($e->employee_code.' - '.$e->full_name)?></option><?php endforeach?></select></div><div class="col-md-2"><label class="form-label">Jenis</label><select class="form-select" name="adjustment_type"><option value="earning">Pendapatan</option><option value="deduction">Potongan</option></select></div><div class="col-md-2"><label class="form-label">Komponen</label><select class="form-select searchable" name="component_id" required><option value="">Pilih komponen</option><?php foreach($components as$c):if($c->component_type==='employer_cost')continue;?><option value="<?=$c->id?>"><?=html_escape($c->code.' - '.$c->name)?></option><?php endforeach?></select></div><div class="col-md-2"><label class="form-label">Nominal</label><input type="number" min="0.01" step="0.01" class="form-control" name="amount" required></div><div class="col-md-10"><label class="form-label">Keterangan</label><input class="form-control" name="notes" placeholder="Alasan bonus, rapel, koreksi, atau potongan" required></div><div class="col-md-2 d-flex align-items-end"><button class="btn btn-primary w-100">Simpan</button></div></form></div></section><?php endif?>
|
||||
<section class="card hr-panel"><div class="card-header bg-white"><b>Daftar Penyesuaian</b></div><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Periode</th><th>Karyawan</th><th>Komponen</th><th>Jenis</th><th class="text-end">Nominal</th><th>Keterangan</th><th>Status</th><th>Aksi</th></tr></thead><tbody><?php if(!$adjustments):?><tr><td colspan="8" class="text-center text-muted py-5">Belum ada penyesuaian periode.</td></tr><?php endif;foreach($adjustments as$a):?><tr><td><?=html_escape($a->period_name)?></td><td><b><?=html_escape($a->full_name)?></b><small class="d-block text-muted"><?=html_escape($a->employee_code)?></small></td><td><?=html_escape($a->component_name)?></td><td><span class="badge <?=$a->adjustment_type==='earning'?'bg-success':'bg-warning text-dark'?>"><?=html_escape($a->adjustment_type==='earning'?'Pendapatan':'Potongan')?></span></td><td class="text-end">Rp <?=number_format($a->amount,2,',','.')?></td><td><?=html_escape($a->notes)?></td><td><?=html_escape($statusLabels[$a->workflow_status]??$a->workflow_status)?></td><td><?php if($permissions['update']&&$a->workflow_status==='draft'):?><form method="post" action="<?=base_url('hrpayroll/deactivate_adjustment/'.$a->id)?>" class="js-confirm"><?=$csrf?><button class="btn btn-sm btn-outline-danger">Nonaktifkan</button></form><?php else:?>-<?php endif?></td></tr><?php endforeach?></tbody></table></div></section>
|
||||
<?php endif?>
|
||||
|
||||
<div class="modal fade" id="payrollDetailModal" tabindex="-1"><div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content"><div class="modal-header"><div><small class="text-muted">RINCIAN PERIODE</small><h5 class="modal-title" id="payrollDetailTitle">Payroll</h5></div><button class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="table-responsive"><table class="table align-middle"><thead><tr><th>Karyawan</th><th class="text-end">Hadir</th><th class="text-end">Alpa</th><th class="text-end">Lembur</th><th class="text-end">Bruto</th><th class="text-end">Potongan</th><th class="text-end">Neto</th><th>Slip</th></tr></thead><tbody id="payrollDetailRows"></tbody></table></div></div></div></div></div>
|
||||
<div class="modal fade" id="salaryModal" tabindex="-1" data-bs-backdrop="static"><div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content"><div class="modal-header"><div><small class="text-muted">STRUKTUR GAJI EFEKTIF</small><h5 class="modal-title" id="salaryModalTitle">Karyawan</h5><small id="salaryModalMeta" class="text-muted"></small></div><button class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="table-responsive mb-4"><table class="table align-middle"><thead><tr><th>Komponen</th><th>Jenis</th><th>Metode</th><th class="text-end">Nilai/Tarif</th><th>Efektif</th><th>Status</th><th>Aksi</th></tr></thead><tbody id="salaryLineRows"></tbody></table></div><?php if($permissions['update']):?><form id="salaryLineForm" data-ajax="true" class="row g-2"><?=$csrf?><input type="hidden" name="employee_id" id="salaryEmployeeId"><div class="col-md-3"><label class="form-label">Komponen</label><select class="form-select" name="component_id" required><option value="">Pilih komponen</option><?php foreach($components as$c):?><option value="<?=$c->id?>"><?=html_escape($c->code.' - '.$c->name)?></option><?php endforeach?></select></div><div class="col-md-2"><label class="form-label">Metode</label><select class="form-select" name="calculation_method"><?php foreach($methodLabels as$key=>$label):?><option value="<?=$key?>"><?=html_escape($label)?></option><?php endforeach?></select></div><div class="col-md-2"><label class="form-label">Nilai/Tarif</label><input type="number" min="0" step="0.01" class="form-control" name="amount_override" value="0" required></div><div class="col-md-2"><label class="form-label">Mulai Berlaku</label><input type="date" class="form-control" name="effective_from" value="<?=date('Y-m-d')?>" required></div><div class="col-md-2"><label class="form-label">Selesai (opsional)</label><input type="date" class="form-control" name="effective_to"></div><div class="col-md-1 d-flex align-items-end"><button class="btn btn-primary w-100">Tambah</button></div><div class="col-md-6"><input class="form-control" name="formula_expression" placeholder="Formula bila metode Formula"></div><div class="col-md-6"><input class="form-control" name="notes" placeholder="Keterangan perubahan"></div></form><?php endif?></div></div></div></div>
|
||||
|
||||
<script>
|
||||
$(function(){
|
||||
const esc=v=>$('<div>').text(v??'').html(),money=v=>'Rp '+Number(v||0).toLocaleString('id-ID',{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||
$('.searchable').select2({width:'100%'});
|
||||
if($('#salaryTable').length){const table=$('#salaryTable').DataTable({pageLength:25,order:[[0,'asc']],language:{search:'Cari:',lengthMenu:'Tampilkan _MENU_ data',info:'_START_–_END_ dari _TOTAL_',paginate:{previous:'Sebelumnya',next:'Berikutnya'}}});$('#salaryDepartmentFilter').on('change',function(){table.column(1).search(this.value).draw()});$('#salaryPositionFilter').on('change',function(){table.column(2).search(this.value).draw()});$('#salaryStatusFilter').on('change',function(){table.column(3).search(this.value).draw()})}
|
||||
const detailModal=bootstrap.Modal.getOrCreateInstance('#payrollDetailModal');$('.payroll-detail').on('click',function(){$.getJSON('<?=base_url('hrpayroll/period_detail/')?>'+this.dataset.id).done(r=>{if(!r.status)return AppAlert.toast(r.message,'error');$('#payrollDetailTitle').text(r.period.period_name);$('#payrollDetailRows').html((r.rows||[]).map(x=>`<tr><td><b>${esc(x.full_name)}</b><small class="d-block text-muted">${esc(x.employee_code)}${x.salary_restricted==1?' · Gaji terbatas':''}</small></td><td class="text-end">${x.total_present}</td><td class="text-end">${x.total_alpha}</td><td class="text-end">${x.total_overtime_hours}</td><td class="text-end">${money(x.gross_salary)}</td><td class="text-end">${money(x.total_deductions)}</td><td class="text-end fw-bold">${money(x.total_salary)}</td><td>${['final','posted','paid'].includes(r.period.workflow_status)?`<a class="btn btn-sm btn-outline-primary" target="_blank" href="<?=base_url('hrpayroll/slip/')?>${x.id}">Slip</a>`:'-'}</td></tr>`).join('')||'<tr><td colspan="8" class="text-center text-muted">Belum ada rincian.</td></tr>');detailModal.show()}).fail(x=>AppAlert.toast(x.responseJSON?.message||'Rincian gagal dimuat.','error'))});
|
||||
const salaryModal=bootstrap.Modal.getOrCreateInstance('#salaryModal');let currentEmployee=0;function loadSalary(id){currentEmployee=id;$('#salaryLineRows').html('<tr><td colspan="7" class="text-center py-4"><span class="spinner-border spinner-border-sm"></span> Memuat…</td></tr>');salaryModal.show();$.getJSON('<?=base_url('hrpayroll/salary_detail/')?>'+id).done(r=>{if(!r.status){salaryModal.hide();return AppAlert.toast(r.message,'error')}$('#salaryEmployeeId').val(id);$('#salaryModalTitle').text(r.data?.employee?.full_name||r.employee?.full_name||'Karyawan');const employee=r.data?.employee||r.employee,lines=r.data?.lines||r.lines||[];$('#salaryModalMeta').text((employee?.employee_code||'')+' · '+(employee?.department_name||'-')+' · '+(employee?.position_name||'-'));$('#salaryLineRows').html(lines.map(x=>`<tr><td><b>${esc(x.component_code)}</b><small class="d-block text-muted">${esc(x.component_name)}</small></td><td>${esc(x.component_type)}</td><td>${esc(x.calculation_method)}</td><td class="text-end">${money(x.amount_override)}</td><td>${esc(x.effective_from)}${x.effective_to?' s.d. '+esc(x.effective_to):''}</td><td><span class="badge ${x.is_active==1?'bg-success':'bg-secondary'}">${x.is_active==1?'Aktif':'Selesai'}</span></td><td>${x.is_active==1?`<button class="btn btn-sm btn-outline-danger deactivate-salary" data-id="${x.id}">Nonaktifkan</button>`:'-'}</td></tr>`).join('')||'<tr><td colspan="7" class="text-center text-muted py-4">Belum ada komponen gaji.</td></tr>')}).fail(x=>{salaryModal.hide();AppAlert.toast(x.responseJSON?.message||'Struktur gaji gagal dimuat.','error')})}
|
||||
$('.salary-manage').on('click',function(){loadSalary(this.dataset.id)});$('#salaryLineForm').on('submit',function(e){e.preventDefault();const button=this.querySelector('button[type=submit]');button.disabled=true;$.ajax({url:'<?=base_url('hrpayroll/save_salary_line')?>',method:'POST',data:new FormData(this),processData:false,contentType:false,dataType:'json'}).done(r=>{if(!r.status)return AppAlert.toast(r.message,'error');AppAlert.toast(r.message,'success');this.reset();this.elements.employee_id.value=currentEmployee;loadSalary(currentEmployee)}).fail(x=>AppAlert.toast(x.responseJSON?.message||'Komponen gagal disimpan.','error')).always(()=>button.disabled=false)});$(document).on('click','.deactivate-salary',function(){if(!confirm('Nonaktifkan komponen tanpa menghapus historinya?'))return;const form=new FormData();form.append('<?=$this->security->get_csrf_token_name()?>','<?=$this->security->get_csrf_hash()?>');$.ajax({url:'<?=base_url('hrpayroll/deactivate_salary_line/')?>'+this.dataset.id,method:'POST',data:form,processData:false,contentType:false,dataType:'json'}).done(r=>{AppAlert.toast(r.message,r.status?'success':'error');if(r.status)loadSalary(currentEmployee)}).fail(x=>AppAlert.toast(x.responseJSON?.message||'Komponen gagal dinonaktifkan.','error'))});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="container-fluid py-3">
|
||||
<div class="mb-3"><h4 class="mb-1">Master Departemen & Posisi</h4><p class="text-muted mb-0">Satu sumber data untuk karyawan, Purchase Request, payroll, jadwal, dan modul lainnya.</p></div>
|
||||
<?php $this->load->view('hr_payroll/_header',array('active_menu'=>'organization_master','title'=>'Departemen & Posisi','subtitle'=>'Satu sumber data organisasi untuk karyawan, pembelian, payroll, jadwal, dan modul terkait.')); ?>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-6"><div class="card h-100"><div class="card-header"><b>Departemen</b></div><div class="card-body">
|
||||
<form id="departmentForm" class="row g-2 mb-3"><input type="hidden" name="<?=$this->security->get_csrf_token_name()?>" value="<?=$this->security->get_csrf_hash()?>"><input type="hidden" name="id"><div class="col-md-3"><input class="form-control" name="code" placeholder="Kode" required></div><div class="col-md-4"><input class="form-control" name="name" placeholder="Nama departemen" required></div><div class="col-md-5"><input class="form-control" name="description" placeholder="Keterangan"></div><div class="col-8"><label><input type="checkbox" name="is_active" value="1" checked> Aktif</label></div><div class="col-4 text-end"><button type="button" class="btn btn-light btn-reset">Reset</button> <button class="btn btn-primary">Simpan</button></div></form>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>@page{margin:24px 28px}body{font:10px DejaVu Sans;color:#1f2937}h1{font-size:18px;margin:0}p{margin:3px 0}.head{display:table;width:100%;border-bottom:2px solid #ef7d00;padding-bottom:10px;margin-bottom:14px}.head>div{display:table-cell;vertical-align:top}.right{text-align:right}table{width:100%;border-collapse:collapse}th{background:#17284a;color:#fff;text-align:left}th,td{padding:7px;border:1px solid #dce3ed}.num{text-align:right}.total{font-weight:bold;background:#fff4e8}.foot{margin-top:14px;color:#64748b}</style></head><body>
|
||||
<div class="head"><div><h1><?=html_escape($company->name??$company->company_name??'Perusahaan')?></h1><p>Register Payroll</p></div><div class="right"><b><?=html_escape($period->period_name)?></b><p>Kehadiran: <?=html_escape($period->attendance_start_date?:$period->start_date)?> s.d. <?=html_escape($period->attendance_end_date?:$period->end_date)?></p><p>Tanggal bayar: <?=html_escape($period->payment_date?:'-')?> · Status: <?=strtoupper(html_escape($period->workflow_status))?></p></div></div>
|
||||
<table><thead><tr><th>No</th><th>Kode</th><th>Karyawan</th><th>Bank / Rekening</th><th class="num">Gaji Pokok</th><th class="num">Bruto</th><th class="num">Potongan</th><th class="num">Neto</th></tr></thead><tbody><?php $basic=$gross=$deduction=$net=0;foreach($rows as$i=>$r):$basic+=(float)$r->basic_salary;$gross+=(float)$r->gross_salary;$deduction+=(float)$r->total_deductions;$net+=(float)$r->total_salary;?><tr><td><?=$i+1?></td><td><?=html_escape($r->employee_code)?></td><td><?=html_escape($r->full_name)?></td><td><?=html_escape(trim(($r->bank_name?:'-').' / '.($r->bank_account_number?:'-'),' /'))?></td><td class="num"><?=number_format($r->basic_salary,2,',','.')?></td><td class="num"><?=number_format($r->gross_salary,2,',','.')?></td><td class="num"><?=number_format($r->total_deductions,2,',','.')?></td><td class="num"><?=number_format($r->total_salary,2,',','.')?></td></tr><?php endforeach?><tr class="total"><td colspan="4">TOTAL</td><td class="num"><?=number_format($basic,2,',','.')?></td><td class="num"><?=number_format($gross,2,',','.')?></td><td class="num"><?=number_format($deduction,2,',','.')?></td><td class="num"><?=number_format($net,2,',','.')?></td></tr></tbody></table>
|
||||
<p class="foot">Dokumen payroll bersifat rahasia. Dibuat pada <?=date('d/m/Y H:i')?>.</p></body></html>
|
||||
@@ -1 +1 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Slip Gaji</title><style>body{font:14px Arial;max-width:760px;margin:30px auto}table{width:100%;border-collapse:collapse}td,th{padding:8px;border-bottom:1px solid #ddd}.r{text-align:right}@media print{button{display:none}}</style></head><body><button onclick="print()">Cetak</button><h2>SLIP GAJI</h2><p><b><?=html_escape($payroll->full_name)?></b> — <?=html_escape($payroll->employee_code)?><br>Periode <?=html_escape($payroll->period_name)?></p><table><tr><th>Komponen</th><th>Jenis</th><th class="r">Nilai</th></tr><?php foreach($lines as$l):?><tr><td><?=html_escape($l->component_name)?></td><td><?=$l->component_type?></td><td class="r"><?=number_format($l->amount,0,',','.')?></td></tr><?php endforeach?><tr><th colspan="2">Gaji Neto</th><th class="r"><?=number_format($payroll->total_salary,0,',','.')?></th></tr></table><small>Dokumen rahasia. Status payroll: <?=$payroll->workflow_status?></small></body></html>
|
||||
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Slip Gaji</title><style>body{font:14px Arial;color:#1f2937;max-width:760px;margin:30px auto}.head{display:flex;justify-content:space-between;border-bottom:3px solid #ed7900;padding-bottom:12px}table{width:100%;border-collapse:collapse;margin-top:20px}td,th{padding:9px;border-bottom:1px solid #dce3ed}th{background:#f1f5f9;text-align:left}.r{text-align:right}.net{background:#17284a;color:#fff}.muted{color:#64748b;font-size:12px}@media print{button{display:none}}</style></head><body><button onclick="print()">Cetak</button><div class="head"><div><h2>SLIP GAJI</h2><b><?=html_escape($payroll->full_name)?></b> — <?=html_escape($payroll->employee_code)?></div><div class="r"><b><?=html_escape($payroll->period_name)?></b><div class="muted"><?=html_escape($payroll->attendance_start_date?:$payroll->period_start_date)?> s.d. <?=html_escape($payroll->attendance_end_date?:$payroll->period_end_date)?></div><div class="muted">Tanggal bayar: <?=html_escape($payroll->payment_date?:'-')?></div></div></div><table><tr><th>Komponen</th><th>Jenis</th><th class="r">Nilai</th></tr><?php foreach($lines as$line):?><tr><td><?=html_escape($line->component_name)?></td><td><?=html_escape($line->component_type)?></td><td class="r"><?=number_format($line->amount,2,',','.')?></td></tr><?php endforeach?><tr class="net"><th colspan="2">Gaji Neto</th><th class="r">Rp <?=number_format($payroll->total_salary,2,',','.')?></th></tr></table><p class="muted">Dokumen rahasia. Status payroll: <?=html_escape(strtoupper($payroll->workflow_status))?></p></body></html>
|
||||
|
||||
@@ -40,11 +40,12 @@
|
||||
$(function(){
|
||||
const permissions=<?=json_encode($inventory_permissions)?>,csrf=()=>({[APP_CSRF.name]:APP_CSRF.value}),esc=v=>$('<div>').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:'<?=base_url('inventoryprofessional/document_data')?>',data:d=>{d.type=$('#filterType').val();d.status=$('#filterStatus').val()}},columns:[{data:'document_no',render:(v,t,r)=>'<button class="btn btn-link p-0 fw-semibold btn-detail" data-id="'+r.id+'">'+esc(v)+'</button>'},{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=>'<span class="status-chip status-'+v+'">'+esc(labels[v]||v)+'</span>'},{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(){$('<button type="button" class="d-none btn-detail" data-id="'+String(focusDocument).replace(/[^0-9]/g,'')+'"></button>').appendTo(document.body).trigger('click').remove()},100)});
|
||||
function actions(r){let a='<div class="d-flex flex-wrap gap-1"><button class="btn btn-sm btn-outline-primary btn-detail" data-id="'+r.id+'"><i class="bi bi-eye"></i></button>';if(permissions.export)a+='<button class="btn btn-sm btn-outline-danger btn-pdf" data-id="'+r.id+'"><i class="bi bi-file-earmark-pdf"></i></button>';if(r.status==='draft'&&permissions.create)a+='<button class="btn btn-sm btn-primary btn-state" data-action="submit" data-id="'+r.id+'">Ajukan</button>';if(r.status==='submitted'&&permissions.approve)a+='<button class="btn btn-sm btn-success btn-state" data-action="approve" data-id="'+r.id+'">Setujui</button><button class="btn btn-sm btn-outline-danger btn-reject" data-id="'+r.id+'">Tolak</button>';if(r.status==='approved'&&permissions.post)a+='<button class="btn btn-sm btn-success btn-state" data-action="post" data-id="'+r.id+'">Posting</button>';if(r.status==='posted'&&permissions.post)a+='<button class="btn btn-sm btn-outline-danger btn-reverse" data-id="'+r.id+'">Reversal</button>';return a+'</div>'}
|
||||
function actions(r){let a='<div class="d-flex flex-wrap gap-1"><button type="button" class="btn btn-sm btn-outline-primary btn-detail" data-id="'+r.id+'"><i class="bi bi-eye"></i></button>';if(permissions.export)a+='<button type="button" class="btn btn-sm btn-outline-danger btn-pdf" data-id="'+r.id+'"><i class="bi bi-file-earmark-pdf"></i></button>';if(r.status==='draft'&&permissions.create)a+='<button type="button" class="btn btn-sm btn-primary btn-state" data-action="submit" data-id="'+r.id+'">Ajukan</button>';if(r.status==='submitted'&&permissions.approve)a+='<button type="button" class="btn btn-sm btn-success btn-state" data-action="approve" data-id="'+r.id+'">Setujui</button><button type="button" class="btn btn-sm btn-outline-danger btn-reject" data-id="'+r.id+'">Tolak</button>';if(r.status==='approved'&&permissions.post)a+='<button type="button" class="btn btn-sm btn-success btn-state" data-action="post" data-id="'+r.id+'">Posting</button>';if(r.status==='posted'&&permissions.post)a+='<button type="button" class="btn btn-sm btn-outline-danger btn-reverse" data-id="'+r.id+'">Reversal</button>';return a+'</div>'}
|
||||
$('#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 class="document-line"><td><select class="form-select line-item"></select></td><td><select class="form-select line-barcode"><option value="">Tanpa barcode</option></select></td><td><select class="form-select line-batch"><option value="">Tanpa batch</option></select></td><td class="direction-column"><select class="form-select line-direction"><option value="in">Tambah</option><option value="out">Kurangi</option></select></td><td><input class="form-control line-qty" inputmode="decimal" placeholder="0" required></td><td><input class="form-control line-cost format-rupiah" inputmode="decimal" placeholder="0"></td><td><input class="form-control line-reason" placeholder="Catatan baris"></td><td><button type="button" class="btn btn-sm btn-outline-danger remove-line"><i class="bi bi-x"></i></button></td></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('<?=date('Y-m-d')?>');$('#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?'<img src="'+URL.createObjectURL(f)+'" alt="">':'<i class="bi bi-file-earmark-pdf attachment-file-icon"></i>';return'<div class="attachment-tile">'+preview+'<button type="button" class="remove-attachment" data-index="'+i+'" aria-label="Hapus"><i class="bi bi-x"></i></button><small>'+esc(f.name)+'</small></div>'}).join('');$('#attachmentNames').html(h+'<button type="button" class="attachment-add" id="chooseAttachments"><i class="bi bi-plus-lg"></i><span>Tambah</span></button>')}$(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('<?=base_url('inventoryprofessional/create')?>',{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:'<?=base_url('accountingattachments/upload')?>',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('<?=base_url('inventoryprofessional')?>/'+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('<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span> Memproses');api('<?=base_url('inventoryprofessional')?>/'+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('<?=base_url('inventoryprofessional/reject')?>/'+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:'<label class="form-label d-block text-start">Tanggal reversal</label><input id="reverseDate" type="date" class="form-control mb-3" value="<?=date('Y-m-d')?>"><label class="form-label d-block text-start">Alasan</label><textarea id="reverseReason" class="form-control"></textarea>',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('<?=base_url('inventoryprofessional/reverse')?>/'+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('<div class="inventory-loading"><span class="spinner-border spinner-border-sm"></span> Memuat detail…</div>');bootstrap.Modal.getOrCreateInstance('#detailModal').show();$.get('<?=base_url('inventoryprofessional/document_detail')?>/'+id).done(function(r){let d=r.document,lines=d.lines.map(l=>'<tr><td>'+esc(l.kode_detail+' - '+l.nama_barang)+'</td><td>'+esc(l.barcode)+(l.serial_number?'<small class="d-block">SN '+esc(l.serial_number)+'</small>':'')+'</td><td>'+esc(l.batch_no)+'</td><td class="text-end">'+fmt(d.document_type==='opname'?l.counted_qty:l.qty)+' '+esc(l.unit)+'</td><td class="text-end">'+money(l.unit_cost)+'</td><td>'+esc(l.reason)+'</td></tr>').join(''),hist=d.history.map(h=>'<li><strong>'+esc(labels[h.to_status]||h.to_status)+'</strong><span>'+esc(h.user_name)+' · '+esc(h.created_at)+'</span><span>'+esc(h.notes)+'</span></li>').join(''),atts=d.attachments.map(a=>'<button class="btn btn-sm btn-outline-secondary attachment-preview" data-url="'+a.preview_url+'"><i class="bi bi-paperclip"></i> '+esc(a.original_name)+'</button>').join(' ');$('#detailTitle').text(d.document_no);$('#detailBody').html('<div class="document-meta"><span>Jenis<strong>'+esc(labels[d.document_type]||d.document_type)+'</strong></span><span>Tanggal<strong>'+esc(d.document_date)+'</strong></span><span>Status<strong><span class="status-chip status-'+d.status+'">'+esc(labels[d.status]||d.status)+'</span></strong></span><span>Gudang Asal<strong>'+esc(d.from_warehouse)+'</strong></span><span>Gudang Tujuan<strong>'+esc(d.to_warehouse)+'</strong></span><span>Dokumen Sumber<strong>'+esc(d.source_type)+' #'+esc(d.source_id)+'</strong></span><span>Pembuat<strong>'+esc(d.creator_name)+'</strong></span><span>Pengaju<strong>'+esc(d.submitter_name)+'</strong></span><span>Approver / Poster<strong>'+esc(d.approver_name)+' / '+esc(d.poster_name)+'</strong></span></div><div class="alert alert-light border mt-3"><strong>Alasan:</strong> '+esc(d.reason)+'</div><div class="table-responsive"><table class="table inventory-table"><thead><tr><th>Barang</th><th>Barcode/SN</th><th>Batch</th><th class="text-end">Qty</th><th class="text-end">Biaya</th><th>Catatan</th></tr></thead><tbody>'+lines+'</tbody></table></div><h6>Lampiran</h6><div class="mb-3 d-flex flex-wrap gap-2">'+(atts||'<span class="text-muted">Tidak ada lampiran.</span>')+'</div><h6>Riwayat Status</h6><ul class="timeline-list">'+hist+'</ul>');$('#detailActions').html('<button class="btn btn-outline-danger btn-pdf" data-id="'+d.id+'"><i class="bi bi-file-earmark-pdf"></i> PDF</button><button class="btn btn-light" data-bs-dismiss="modal">Tutup</button>')}).fail(fail)});
|
||||
|
||||
@@ -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');
|
||||
?><!doctype html><html lang="id"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#ee7800">
|
||||
<meta name="csrf-name" content="<?=$this->security->get_csrf_token_name()?>"><meta name="csrf-token" content="<?=$this->security->get_csrf_hash()?>">
|
||||
@@ -20,7 +21,7 @@ $userRole=$this->session->userdata('role')?:'User';
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" onerror="this.onerror=null;this.href='<?=base_url('assets/vendor/bootstrap.min.css')?>'"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
||||
<?php if(in_array($active_menu,$dataPages,true)):?><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/datatables.net-bs5@1.13.8/css/dataTables.bootstrap5.min.css"><?php endif?>
|
||||
<?php if(in_array($active_menu,$selectPages,true)):?><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2@4.0.13/dist/css/select2.min.css"><?php endif?>
|
||||
<link rel="stylesheet" href="<?=base_url('assets/css/style-custom.css').'?v='.filemtime(FCPATH.'assets/css/style-custom.css')?>"><link rel="stylesheet" href="<?=base_url('assets/css/app-final.css').'?v='.filemtime(FCPATH.'assets/css/app-final.css')?>">
|
||||
<link rel="stylesheet" href="<?=base_url('assets/css/style-custom.css').'?v='.filemtime(FCPATH.'assets/css/style-custom.css')?>"><link rel="stylesheet" href="<?=base_url('assets/css/app-final.css').'?v='.filemtime(FCPATH.'assets/css/app-final.css')?>"><?php if($active_menu==='profile'):?><link rel="stylesheet" href="<?=base_url('assets/css/profile.css').'?v='.filemtime(FCPATH.'assets/css/profile.css')?>"><?php endif?><?php if($active_menu==='leave_requests'):?><link rel="stylesheet" href="<?=base_url('assets/css/leave-requests.css').'?v='.filemtime(FCPATH.'assets/css/leave-requests.css')?>"><?php endif?><?php if(in_array($active_menu,array('organization_master','employees','generate_schedule','attendance_monitoring','attendance_monthly','shift','holidays','leave_requests','hr_payroll'),true)):?><link rel="stylesheet" href="<?=base_url('assets/css/hr-payroll.css').'?v='.filemtime(FCPATH.'assets/css/hr-payroll.css')?>"><?php endif?>
|
||||
<script src="<?=base_url('assets/vendor/jquery.min.js')?>"></script>
|
||||
<?php if(in_array($active_menu,$dataPages,true)):?><script src="https://cdn.jsdelivr.net/npm/datatables.net@1.13.8/js/jquery.dataTables.min.js"></script><script src="https://cdn.jsdelivr.net/npm/datatables.net-bs5@1.13.8/js/dataTables.bootstrap5.min.js"></script><script src="https://cdn.jsdelivr.net/npm/datatables.net-responsive@2.5.0/js/dataTables.responsive.min.js"></script><?php endif?>
|
||||
<?php if(in_array($active_menu,$selectPages,true)):?><script src="https://cdn.jsdelivr.net/npm/select2@4.0.13/dist/js/select2.min.js"></script><?php endif?>
|
||||
@@ -31,12 +32,12 @@ $userRole=$this->session->userdata('role')?:'User';
|
||||
<a class="sidebar-brand" href="<?=base_url()?>"><span class="brand-mark"><img src="<?=base_url('assets/img/accounting-white.png')?>" alt=""></span><span><strong>Accounting</strong><small>Business Suite</small></span></a>
|
||||
<div class="sidebar-search"><i class="bi bi-search"></i><input class="menu-filter" type="search" placeholder="Cari menu..." aria-label="Cari menu"></div>
|
||||
<nav class="sidebar-nav" aria-label="Navigasi utama"><?php $this->load->view('partials/navigation_items',compact('menu_items','active_menu'))?></nav>
|
||||
<div class="sidebar-user"><span class="user-avatar"><?=html_escape(strtoupper(substr($userName,0,1)))?></span><span class="user-copy"><strong><?=html_escape($userName)?></strong><small><?=html_escape($userRole)?></small></span><a href="<?=base_url('auth/logout')?>" title="Keluar"><i class="bi bi-box-arrow-right"></i></a></div>
|
||||
<div class="sidebar-user"><span class="user-avatar"><?php if($userPhoto&&is_file(FCPATH.$userPhoto)):?><img src="<?=base_url($userPhoto)?>" alt=""><?php else:?><?=html_escape(strtoupper(substr($userName,0,1)))?><?php endif?></span><span class="user-copy"><a href="<?=base_url('profile')?>"><strong><?=html_escape($userName)?></strong><small><?=html_escape($userRole)?></small></a></span><a href="<?=base_url('auth/logout')?>" title="Keluar"><i class="bi bi-box-arrow-right"></i></a></div>
|
||||
</aside>
|
||||
<section class="app-workspace">
|
||||
<header class="app-topbar">
|
||||
<div class="topbar-leading"><button class="icon-button d-lg-none" data-bs-toggle="offcanvas" data-bs-target="#mobileMenu" aria-label="Buka menu"><i class="bi bi-list"></i></button><div><span class="topbar-kicker">Accounting Workspace</span><h1><?=html_escape($page_title)?></h1></div></div>
|
||||
<div class="topbar-actions"><button class="icon-button" data-bs-toggle="modal" data-bs-target="#menuSearchModal" aria-label="Cari menu"><i class="bi bi-search"></i></button><button class="icon-button quick-button" data-bs-toggle="modal" data-bs-target="#quickActions" aria-label="Aksi cepat"><i class="bi bi-lightning-charge"></i></button><a class="icon-button d-lg-none" href="<?=base_url('auth/logout')?>" aria-label="Keluar"><i class="bi bi-box-arrow-right"></i></a></div>
|
||||
<div class="topbar-actions"><button class="icon-button" data-bs-toggle="modal" data-bs-target="#menuSearchModal" aria-label="Cari menu"><i class="bi bi-search"></i></button><button class="icon-button quick-button" data-bs-toggle="modal" data-bs-target="#quickActions" aria-label="Aksi cepat"><i class="bi bi-lightning-charge"></i></button><a class="icon-button d-lg-none" href="<?=base_url('profile')?>" aria-label="Profil"><i class="bi bi-person"></i></a><a class="icon-button d-lg-none" href="<?=base_url('auth/logout')?>" aria-label="Keluar"><i class="bi bi-box-arrow-right"></i></a></div>
|
||||
</header>
|
||||
<div class="offcanvas offcanvas-start app-mobile-drawer" id="mobileMenu" tabindex="-1"><div class="offcanvas-header"><a class="drawer-brand" href="<?=base_url()?>"><span class="brand-mark"><img src="<?=base_url('assets/img/accounting-white.png')?>" alt=""></span><span><strong>Accounting</strong><small>Business Suite</small></span></a><button class="btn-close" data-bs-dismiss="offcanvas" aria-label="Tutup"></button></div><div class="offcanvas-body"><div class="sidebar-search"><i class="bi bi-search"></i><input class="menu-filter" type="search" placeholder="Cari menu..."></div><nav class="sidebar-nav mobile-nav"><?php $this->load->view('partials/navigation_items',compact('menu_items','active_menu'))?></nav></div></div>
|
||||
<div class="modal fade" id="menuSearchModal" tabindex="-1"><div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-header"><div><small class="text-muted">NAVIGASI</small><h5 class="modal-title">Cari menu</h5></div><button class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="command-search"><i class="bi bi-search"></i><input class="form-control form-control-lg menu-filter" type="search" placeholder="Ketik nama menu..." autofocus></div><div class="search-menu-results mt-3"></div></div></div></div></div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<?php foreach($menu_items as$item):
|
||||
$children=isset($item['children'])?$item['children']:array();
|
||||
$active=$active_menu===$item['key'];
|
||||
foreach($children as$child)$active=$active||$active_menu===$child['key'];
|
||||
foreach($children as$child)$active=$active||$active_menu===$child['key']||in_array($active_menu,isset($child['active_keys'])?$child['active_keys']:array(),true);
|
||||
$searchLabel=strtolower($item['label'].' '.implode(' ',array_column($children,'label')));
|
||||
?>
|
||||
<?php if($children):?>
|
||||
<details class="app-menu-item nav-group <?=$active?'is-active':''?>" data-menu-label="<?=html_escape($searchLabel)?>" <?=$active?'open':''?>>
|
||||
<summary class="nav-entry"><span class="nav-entry-icon"><i class="bi <?=$item['icon']?>"></i></span><span class="nav-entry-label"><?=html_escape($item['label'])?></span><?php if(!empty($item['badge'])):?><span class="nav-badge <?=html_escape($item['badge_class']??'')?>" title="<?=html_escape($item['badge_title']??'')?>"><?=(int)$item['badge']?></span><?php endif?><i class="bi bi-chevron-down nav-chevron"></i></summary>
|
||||
<div class="nav-children"><?php foreach($children as$child):?><a class="nav-child <?=$active_menu===$child['key']?'active':''?>" href="<?=base_url($child['url'])?>"><span></span><?=html_escape($child['label'])?><?php if(!empty($child['badge'])):?><span class="nav-badge <?=html_escape($child['badge_class']??'')?> ms-auto" title="<?=html_escape($child['badge_title']??'')?>"><?=(int)$child['badge']?></span><?php endif?></a><?php endforeach?></div>
|
||||
<div class="nav-children"><?php foreach($children as$child):$childActive=$active_menu===$child['key']||in_array($active_menu,isset($child['active_keys'])?$child['active_keys']:array(),true);?><a class="nav-child <?=$childActive?'active':''?>" href="<?=base_url($child['url'])?>"><span></span><?=html_escape($child['label'])?><?php if(!empty($child['badge'])):?><span class="nav-badge <?=html_escape($child['badge_class']??'')?> ms-auto" title="<?=html_escape($child['badge_title']??'')?>"><?=(int)$child['badge']?></span><?php endif?></a><?php endforeach?></div>
|
||||
</details>
|
||||
<?php else:?>
|
||||
<div class="app-menu-item" data-menu-label="<?=html_escape($searchLabel)?>"><a class="nav-entry <?=$active?'active':''?>" href="<?=base_url($item['url'])?>"><span class="nav-entry-icon"><i class="bi <?=$item['icon']?>"></i></span><span class="nav-entry-label"><?=html_escape($item['label'])?></span><?php if(!empty($item['badge'])):?><span class="nav-badge <?=html_escape($item['badge_class']??'')?>" title="<?=html_escape($item['badge_title']??'')?>"><?=(int)$item['badge']?></span><?php endif?></a></div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
$csrfName=$this->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)):'-';}
|
||||
?>
|
||||
<div class="profile-page">
|
||||
<section class="profile-hero"><div class="profile-identity"><div class="profile-avatar-large"><?php if(!empty($user->profile_photo)&&is_file(FCPATH.$user->profile_photo)):?><img src="<?=base_url($user->profile_photo)?>" alt="Foto profil"><?php else:?><?=html_escape($initial)?><?php endif?></div><div><span>AKUN PENGGUNA</span><h2 class="text-light"><?=html_escape($user->nama?:$user->username)?></h2><p>@<?=html_escape($user->username)?> • <?=html_escape($user->email?:'Email belum diatur')?></p></div></div><div class="profile-security-summary"><small>Keamanan Login</small><b><?=$active_count?> / <?=(int)($user->max_active_devices?:1)?> perangkat aktif</b><span>Durasi maksimum <?=(int)($user->session_duration_hours?:24)?> jam dari login pertama</span></div></section>
|
||||
|
||||
<?php foreach(array('success'=>'success','error'=>'danger')as$key=>$class)if($this->session->flashdata($key)):?><div class="alert alert-<?=$class?>"><?=html_escape($this->session->flashdata($key))?></div><?php endif?>
|
||||
<nav class="profile-tabs"><a class="<?=$profile_tab==='settings'?'active':''?>" href="<?=base_url('profile')?>"><i class="bi bi-person-gear"></i><span>Profil & Keamanan</span></a><a class="<?=$profile_tab==='devices'?'active':''?>" href="<?=base_url('profile?tab=devices')?>"><i class="bi bi-laptop"></i><span>Perangkat & Aktivitas</span><?php if($active_count):?><b><?=$active_count?></b><?php endif?></a></nav>
|
||||
|
||||
<?php if($profile_tab==='settings'):?>
|
||||
<div class="profile-grid">
|
||||
<section class="profile-card"><div class="profile-card-head"><div><h5>Informasi Profil</h5><p>Identitas yang tampil di dalam aplikasi.</p></div><i class="bi bi-person-vcard"></i></div><form method="post" enctype="multipart/form-data" action="<?=base_url('profile/update-profile')?>"><input type="hidden" name="<?=$csrfName?>" value="<?=$csrfHash?>"><div class="profile-photo-field"><div class="profile-avatar-preview"><?php if(!empty($user->profile_photo)&&is_file(FCPATH.$user->profile_photo)):?><img src="<?=base_url($user->profile_photo)?>" alt="Foto profil"><?php else:?><?=html_escape($initial)?><?php endif?></div><div><label class="form-label">Foto Profil</label><input class="form-control" type="file" name="profile_photo" accept="image/jpeg,image/png,image/webp"><small>JPG, PNG, atau WEBP; maksimal 2 MB.</small></div></div><div class="mb-3"><label class="form-label">Nama Lengkap</label><input class="form-control" name="nama" value="<?=html_escape($user->nama)?>" maxlength="255" required></div><div class="mb-3"><label class="form-label">Email</label><input class="form-control" type="email" name="email" value="<?=html_escape($user->email)?>" maxlength="150"></div><div class="mb-3"><label class="form-label">Username</label><input class="form-control" value="<?=html_escape($user->username)?>" disabled><small>Username dikelola oleh administrator.</small></div><button class="btn btn-primary"><i class="bi bi-check2-circle"></i> Simpan Profil</button></form></section>
|
||||
<div class="profile-stack">
|
||||
<section class="profile-card"><div class="profile-card-head"><div><h5>Pengaturan Session Login</h5><p>Batas waktu tetap dihitung sejak login pertama.</p></div><i class="bi bi-shield-check"></i></div><form method="post" action="<?=base_url('profile/update-security')?>"><input type="hidden" name="<?=$csrfName?>" value="<?=$csrfHash?>"><div class="row g-3"><div class="col-md-7"><label class="form-label">Durasi Maksimum Login</label><select class="form-select" name="session_duration_hours"><?php foreach(array(24=>'24 Jam',72=>'3 Hari',168=>'7 Hari')as$value=>$label):?><option value="<?=$value?>" <?=(int)$user->session_duration_hours===$value?'selected':''?>><?=$label?></option><?php endforeach?></select></div><div class="col-md-5"><label class="form-label">Batas Perangkat</label><input class="form-control" type="number" name="max_active_devices" min="1" max="10" value="<?=(int)($user->max_active_devices?:1)?>" required></div></div><div class="profile-notice"><i class="bi bi-info-circle"></i><span>Pengaturan baru berlaku saat login berikutnya. Rotasi ID session setiap 5 menit tidak memperpanjang batas ini.</span></div><button class="btn btn-primary"><i class="bi bi-save"></i> Simpan Pengaturan</button></form></section>
|
||||
<section class="profile-card"><div class="profile-card-head"><div><h5>Ganti Password</h5><p>Perangkat lain otomatis dikeluarkan setelah password diubah.</p></div><i class="bi bi-key"></i></div><form method="post" action="<?=base_url('profile/update-password')?>"><input type="hidden" name="<?=$csrfName?>" value="<?=$csrfHash?>"><div class="row g-3"><div class="col-12"><label class="form-label">Password Saat Ini</label><input class="form-control" type="password" name="current_password" autocomplete="current-password" required></div><div class="col-md-6"><label class="form-label">Password Baru</label><input class="form-control" type="password" name="new_password" autocomplete="new-password" minlength="8" required></div><div class="col-md-6"><label class="form-label">Konfirmasi Password</label><input class="form-control" type="password" name="confirm_password" autocomplete="new-password" minlength="8" required></div></div><small class="d-block mt-2">Minimal 8 karakter serta mengandung huruf dan angka.</small><button class="btn btn-outline-primary mt-3"><i class="bi bi-key"></i> Ubah Password</button></form></section>
|
||||
</div>
|
||||
</div>
|
||||
<?php else:?>
|
||||
<section class="profile-card mb-3"><div class="profile-card-head"><div><h5>Perangkat Aktif</h5><p>Session aktif menggunakan satu slot perangkat sampai logout, diakhiri, atau mencapai batas waktunya.</p></div><span class="profile-count"><?=count($activeSessions)?> aktif</span></div><div class="device-list"><?php foreach($activeSessions as$session):?><article class="device-row"><div class="device-icon"><i class="bi <?=$session->device_type==='Mobile'?'bi-phone':($session->device_type==='Tablet'?'bi-tablet':'bi-laptop')?>"></i></div><div class="device-main"><div><b><?=html_escape($session->browser.' di '.$session->platform)?></b><?php if($session->is_current):?><span class="badge bg-success">Perangkat ini</span><?php endif?></div><small><?=html_escape($session->device_type)?> • IP <?=html_escape($session->ip_address?:'-')?></small><div class="device-times"><span>Login <b><?=profileDateTime($session->login_at)?></b></span><span>Aktif terakhir <b><?=profileDateTime($session->last_seen_at)?></b></span><span>Berakhir <b><?=profileDateTime($session->expires_at)?></b></span></div><details><summary>Detail perangkat</summary><div class="user-agent"><?=html_escape($session->user_agent?:'-')?></div></details></div><form method="post" action="<?=base_url('profile/revoke-session/'.$session->id)?>" class="revoke-session-form"><input type="hidden" name="<?=$csrfName?>" value="<?=$csrfHash?>"><button class="btn btn-sm btn-outline-danger"><?=$session->is_current?'Logout perangkat ini':'Akhiri session'?></button></form></article><?php endforeach?><?php if(!$activeSessions):?><div class="profile-empty">Tidak ada perangkat aktif.</div><?php endif?></div></section>
|
||||
|
||||
<section class="profile-card mb-3"><div class="profile-card-head"><div><h5>Aktivitas & Percobaan Login</h5><p>Termasuk login berhasil, kredensial salah, dan permintaan yang ditolak karena batas perangkat.</p></div><i class="bi bi-clock-history"></i></div><div class="table-responsive"><table class="table align-middle profile-table"><thead><tr><th>Waktu</th><th>Perangkat</th><th>Alamat IP</th><th>Hasil</th><th>Keterangan</th></tr></thead><tbody><?php foreach($attempts as$attempt):?><tr><td><?=profileDateTime($attempt->attempted_at)?></td><td><b><?=html_escape($attempt->browser)?></b><small><?=html_escape($attempt->device_type)?> • <?=html_escape($attempt->platform)?></small></td><td><?=html_escape($attempt->ip_address?:'-')?></td><td><span class="badge bg-<?=$resultClasses[$attempt->result]??'secondary'?>"><?=html_escape($resultLabels[$attempt->result]??$attempt->result)?></span></td><td><?=html_escape($attempt->reason?:'-')?><?php if($attempt->result==='device_limit'):?><small><?=$attempt->active_session_count?> dari <?=$attempt->device_limit?> slot sedang aktif</small><?php endif?></td></tr><?php endforeach?><?php if(!$attempts):?><tr><td colspan="5" class="text-center text-muted py-4">Belum ada aktivitas login yang tercatat.</td></tr><?php endif?></tbody></table></div></section>
|
||||
|
||||
<?php if($historySessions):?><section class="profile-card"><div class="profile-card-head"><div><h5>Riwayat Session Terakhir</h5><p>Session yang telah logout, kedaluwarsa, atau diakhiri.</p></div></div><div class="table-responsive"><table class="table align-middle profile-table"><thead><tr><th>Perangkat</th><th>Login</th><th>Terakhir Aktif</th><th>Status</th><th>Alasan</th></tr></thead><tbody><?php foreach($historySessions as$session):?><tr><td><b><?=html_escape($session->browser.' di '.$session->platform)?></b><small><?=html_escape($session->device_type)?> • <?=html_escape($session->ip_address?:'-')?></small></td><td><?=profileDateTime($session->login_at)?></td><td><?=profileDateTime($session->last_seen_at)?></td><td><span class="badge bg-<?=$statusClasses[$session->status]??'secondary'?>"><?=html_escape($statusLabels[$session->status]??$session->status)?></span></td><td><?=html_escape($session->end_reason?:'-')?></td></tr><?php endforeach?></tbody></table></div></section><?php endif?>
|
||||
<?php endif?>
|
||||
</div>
|
||||
<script>document.querySelectorAll('.revoke-session-form').forEach(function(form){form.addEventListener('submit',function(event){event.preventDefault();var current=this.querySelector('button').textContent.indexOf('Logout')!==-1;AppAlert.confirm({title:current?'Logout perangkat ini?':'Akhiri session perangkat?',text:current?'Anda harus login kembali pada perangkat ini.':'Perangkat tersebut akan diminta login kembali.',icon:'warning'}).then(result=>{if(result.isConfirmed)form.submit()});});});</script>
|
||||
@@ -6,7 +6,7 @@
|
||||
<?php foreach($roles as$r):?><button type="button" class="list-group-item list-group-item-action role-item" data-id="<?=$r->id?>"><div class="d-flex justify-content-between"><b><?=html_escape($r->nama_role)?></b><span><?php if($r->is_super_admin):?><span class="badge bg-danger">Master Admin</span><?php elseif($r->is_system):?><span class="badge bg-secondary">Bawaan</span><?php else:?><span class="badge bg-info text-dark">Custom</span><?php endif?></span></div><small class="text-muted"><?=html_escape($r->description?:'Belum ada uraian tanggung jawab')?></small></button><?php endforeach?>
|
||||
</div></div></div>
|
||||
<div class="col-lg-8"><div class="card"><div class="card-header" id="formTitle">Pilih role untuk melihat permission</div><div class="card-body"><form id="roleForm"><input type="hidden" name="id" id="roleId"><div class="row g-2 mb-3"><div class="col-md-4"><label class="form-label">Nama Role</label><input class="form-control" name="name" id="roleName" required></div><div class="col-md-6"><label class="form-label">Tanggung Jawab Role</label><input class="form-control" name="description" id="roleDescription" required placeholder="Jelaskan pekerjaan yang ditangani"></div><div class="col-md-2 d-flex align-items-end"><label class="form-check mb-2"><input class="form-check-input" type="checkbox" name="is_active" id="roleActive" value="1"> Aktif</label></div></div>
|
||||
<div class="table-responsive"><table class="table table-sm align-middle"><thead><tr><th>Modul</th><?php foreach($actions as$label):?><th class="text-center"><?=$label?></th><?php endforeach?></tr></thead><tbody><?php foreach($features as$key=>$label):?><tr><td><b><?=$label?></b></td><?php foreach($actions as$action=>$actionLabel):?><td class="text-center"><input class="form-check-input permission-box" type="checkbox" name="permissions[<?=$key?>][]" value="<?=$action?>" data-feature="<?=$key?>" data-action="<?=$action?>"></td><?php endforeach?></tr><?php endforeach?></tbody></table></div>
|
||||
<div class="table-responsive"><table class="table table-sm align-middle"><thead><tr><th>Modul</th><?php foreach($actions as$label):?><th class="text-center"><?=$label?></th><?php endforeach?></tr></thead><tbody><?php foreach($features as$key=>$label):?><tr><td><b><?=$label?></b><?php if($key==='payroll'):?><div class="form-check mt-2"><input class="form-check-input permission-box" type="checkbox" name="permissions[payroll][]" value="can_manage_all_salary" id="manageAllSalary" data-feature="payroll" data-action="can_manage_all_salary"><label class="form-check-label small text-danger" for="manageAllSalary">Manage All Gaji (termasuk gaji terbatas)</label></div><?php endif?></td><?php foreach($actions as$action=>$actionLabel):?><td class="text-center"><input class="form-check-input permission-box" type="checkbox" name="permissions[<?=$key?>][]" value="<?=$action?>" data-feature="<?=$key?>" data-action="<?=$action?>"></td><?php endforeach?></tr><?php endforeach?></tbody></table></div>
|
||||
<div class="d-flex justify-content-between mt-3"><button type="button" class="btn btn-outline-danger" id="deleteRole" hidden>Hapus Role Custom</button><button class="btn btn-success ms-auto" id="saveRole">Simpan Permission</button></div>
|
||||
</form></div></div></div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
+569
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user