diff --git a/app/Console/Commands/ApplyAttendancePenalties.php b/app/Console/Commands/ApplyAttendancePenalties.php new file mode 100644 index 0000000..9135098 --- /dev/null +++ b/app/Console/Commands/ApplyAttendancePenalties.php @@ -0,0 +1,178 @@ +option('date') + ? Carbon::parse($this->option('date'))->startOfDay() + : Carbon::yesterday()->startOfDay(); + + $settings = app(HrSettings::class); + $latePenalty = $settings->late_penalty_amount; + $absentPenalty = $settings->absent_penalty_amount; + $scheduledTime = $settings->scheduled_check_in_time; + + if ($latePenalty <= 0 && $absentPenalty <= 0) { + $this->info('Denda keterlambatan dan bolos belum diatur (0). Lewatkan.'); + + return self::SUCCESS; + } + + $scheduledCheckIn = Carbon::createFromFormat('Y-m-d H:i', $date->format('Y-m-d').' '.$scheduledTime); + + $employees = Employee::query() + ->where('status', EmployeeStatus::ACTIVE) + ->get(); + + $lateCount = 0; + $absentCount = 0; + $skippedLeave = 0; + $skippedDuplicate = 0; + + foreach ($employees as $employee) { + $hasLeave = LeaveRequest::query() + ->where('employee_id', $employee->id) + ->where('status', LeaveRequestStatus::APPROVED) + ->whereDate('start_date', '<=', $date) + ->whereDate('end_date', '>=', $date) + ->exists(); + + if ($hasLeave) { + $skippedLeave++; + + continue; + } + + $attendance = Attendance::query() + ->where('employee_id', $employee->id) + ->whereDate('attendance_date', $date) + ->first(); + + if ($attendance === null) { + if ($absentPenalty <= 0) { + continue; + } + + $payroll = $this->findOpenPayroll($employee->id); + + if ($payroll === null) { + continue; + } + + $alreadyExists = $payroll->adjustments() + ->where('attendance_id', null) + ->where('type', PayrollAdjustmentType::DEDUCTION) + ->where('description', 'like', "%Bolos {$date->format('d/m/Y')}%") + ->exists(); + + if ($alreadyExists) { + $skippedDuplicate++; + + continue; + } + + $payroll->adjustments()->create([ + 'type' => PayrollAdjustmentType::DEDUCTION, + 'amount' => $absentPenalty, + 'description' => "Bolos {$date->format('d/m/Y')}", + 'created_by_id' => 1, + ]); + + $payroll->load('adjustments'); + $payroll->recalculateAmounts(); + $payroll->save(); + + $absentCount++; + + continue; + } + + if ($latePenalty <= 0) { + continue; + } + + $checkInTime = Carbon::parse($attendance->check_in_at); + + if ($checkInTime->lte($scheduledCheckIn)) { + continue; + } + + $payroll = $this->findOpenPayroll($employee->id); + + if ($payroll === null) { + continue; + } + + $alreadyExists = $payroll->adjustments() + ->where('attendance_id', $attendance->id) + ->where('type', PayrollAdjustmentType::DEDUCTION) + ->exists(); + + if ($alreadyExists) { + $skippedDuplicate++; + + continue; + } + + $minutesLate = (int) $scheduledCheckIn->diffInMinutes($checkInTime); + + $payroll->adjustments()->create([ + 'type' => PayrollAdjustmentType::DEDUCTION, + 'amount' => $latePenalty, + 'description' => "Terlambat {$date->format('d/m/Y')} ({$minutesLate} menit)", + 'attendance_id' => $attendance->id, + 'created_by_id' => 1, + ]); + + $payroll->load('adjustments'); + $payroll->recalculateAmounts(); + $payroll->save(); + + $lateCount++; + } + + $this->info("Selesai memproses tanggal {$date->format('d/m/Y')}:"); + $this->info(" - Terlambat: {$lateCount} pegawai"); + $this->info(" - Bolos: {$absentCount} pegawai"); + $this->info(" - Lewati (cuti): {$skippedLeave} pegawai"); + $this->info(" - Lewati (duplikat): {$skippedDuplicate} pegawai"); + + return self::SUCCESS; + } + + private function findOpenPayroll(int $employeeId): ?Payroll + { + $period = PayrollPeriod::query() + ->where('status', PayrollPeriodStatus::OPEN) + ->first(); + + if ($period === null) { + return null; + } + + return Payroll::query() + ->where('payroll_period_id', $period->id) + ->where('employee_id', $employeeId) + ->first(); + } +} diff --git a/app/Console/Commands/SendAttendanceReminder.php b/app/Console/Commands/SendAttendanceReminder.php new file mode 100644 index 0000000..fb8741a --- /dev/null +++ b/app/Console/Commands/SendAttendanceReminder.php @@ -0,0 +1,64 @@ +scheduled_check_in_time; + + if ($scheduledTime === '00:00') { + $this->info('Jam masuk kerja belum diatur. Lewatkan.'); + + return self::SUCCESS; + } + + $now = Carbon::now(); + $scheduledCheckIn = Carbon::createFromFormat('Y-m-d H:i', $now->format('Y-m-d').' '.$scheduledTime); + + $employees = Employee::query() + ->where('status', EmployeeStatus::ACTIVE) + ->whereNotNull('user_id') + ->get(); + + $sentCount = 0; + + foreach ($employees as $employee) { + $alreadyCheckedIn = Attendance::query() + ->where('employee_id', $employee->id) + ->whereDate('attendance_date', $now->toDateString()) + ->exists(); + + if ($alreadyCheckedIn) { + continue; + } + + $pushNotificationService->sendToUser( + '⏰ Pengingat Presensi', + "Jangan lupa melakukan presensi masuk sebelum pukul {$scheduledTime}.", + $employee->user_id, + '/admin/hr/attendances', + ); + + $sentCount++; + } + + $this->info("Pengingat presensi dikirim ke {$sentCount} pegawai."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/Hr/AttendanceController.php b/app/Http/Controllers/Admin/Hr/AttendanceController.php index 2d72b88..2a62a43 100644 --- a/app/Http/Controllers/Admin/Hr/AttendanceController.php +++ b/app/Http/Controllers/Admin/Hr/AttendanceController.php @@ -44,6 +44,9 @@ public function index(Request $request): Response 'todayAttendance' => $employee ? $this->attendanceService->todayAttendanceForEmployee($employee) : null, + 'isOnLeave' => $employee + ? $this->attendanceService->isOnLeaveToday($employee) + : false, 'canCheckIn' => ($user?->can(Permission::ATTENDANCES_CREATE->value) ?? false) && $employee !== null, 'canManageAll' => $isManager, diff --git a/app/Http/Controllers/Admin/System/SettingController.php b/app/Http/Controllers/Admin/System/SettingController.php index 7133034..efd7af8 100644 --- a/app/Http/Controllers/Admin/System/SettingController.php +++ b/app/Http/Controllers/Admin/System/SettingController.php @@ -4,9 +4,11 @@ use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\System\Setting\HrSettingRequest; use App\Http\Requests\Admin\System\Setting\MarketplaceRequest; use App\Http\Requests\Admin\System\Setting\SocialMediaRequest; use App\Http\Requests\Admin\System\Setting\SystemRequest; +use App\Services\System\Setting\HrSettingService; use App\Services\System\Setting\MarketplaceService; use App\Services\System\Setting\SocialMediaService; use App\Services\System\Setting\SystemService; @@ -22,6 +24,7 @@ public function __construct( private readonly SystemService $systemService, private readonly SocialMediaService $socialMediaService, private readonly MarketplaceService $marketplaceService, + private readonly HrSettingService $hrSettingService, ) {} public function index(): Response @@ -30,6 +33,7 @@ public function index(): Response 'system' => $this->systemService->systemData(), 'socialMedia' => $this->socialMediaService->socialMediaData(), 'marketplace' => $this->marketplaceService->marketplaceData(), + 'hr' => $this->hrSettingService->hrData(), ]); } @@ -59,4 +63,13 @@ public function updateMarketplace(MarketplaceRequest $request): RedirectResponse return redirect()->route('admin.system.setting.index'); } + + public function updateHr(HrSettingRequest $request): RedirectResponse + { + $this->hrSettingService->updateHr($request->validated()); + + $this->flashSuccess('Pengaturan HR berhasil disimpan.'); + + return redirect()->route('admin.system.setting.index'); + } } diff --git a/app/Http/Requests/Admin/System/Setting/HrSettingRequest.php b/app/Http/Requests/Admin/System/Setting/HrSettingRequest.php new file mode 100644 index 0000000..c8096a4 --- /dev/null +++ b/app/Http/Requests/Admin/System/Setting/HrSettingRequest.php @@ -0,0 +1,40 @@ +user()?->can(Permission::SETTINGS_UPDATE->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'scheduled_check_in_time' => ['required', 'string', 'max:5', 'regex:/^\d{2}:\d{2}$/'], + 'scheduled_check_out_time' => ['required', 'string', 'max:5', 'regex:/^\d{2}:\d{2}$/'], + 'late_penalty_amount' => ['required', 'integer', 'min:0'], + 'absent_penalty_amount' => ['required', 'integer', 'min:0'], + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'scheduled_check_in_time' => 'jam masuk kerja', + 'scheduled_check_out_time' => 'jam pulang kerja', + 'late_penalty_amount' => 'denda keterlambatan', + 'absent_penalty_amount' => 'denda bolos', + ]; + } +} diff --git a/app/Models/PayrollAdjustment.php b/app/Models/PayrollAdjustment.php index e725f27..ab2a2dd 100644 --- a/app/Models/PayrollAdjustment.php +++ b/app/Models/PayrollAdjustment.php @@ -39,6 +39,11 @@ public function payroll(): BelongsTo return $this->belongsTo(Payroll::class); } + public function attendance(): BelongsTo + { + return $this->belongsTo(Attendance::class); + } + public function amountFormatted(): Attribute { return Attribute::make( diff --git a/app/Services/Hr/AttendanceService.php b/app/Services/Hr/AttendanceService.php index 1b4c179..3a5374b 100644 --- a/app/Services/Hr/AttendanceService.php +++ b/app/Services/Hr/AttendanceService.php @@ -2,8 +2,10 @@ namespace App\Services\Hr; +use App\Enums\LeaveRequestStatus; use App\Models\Attendance; use App\Models\Employee; +use App\Models\LeaveRequest; use App\Models\User; use App\Services\Concerns\ResolvesAuthEmployee; use App\Services\Media\MediaService; @@ -58,6 +60,16 @@ public function todayAttendanceForEmployee(Employee $employee): ?array return $attendance?->toArray(); } + public function isOnLeaveToday(Employee $employee): bool + { + return LeaveRequest::query() + ->where('employee_id', $employee->id) + ->where('status', LeaveRequestStatus::APPROVED) + ->whereDate('start_date', '<=', today()) + ->whereDate('end_date', '>=', today()) + ->exists(); + } + /** * @param array{photo: string, latitude: float, longitude: float, location_tag: string} $validated */ diff --git a/app/Services/System/Setting/HrSettingService.php b/app/Services/System/Setting/HrSettingService.php new file mode 100644 index 0000000..590aa51 --- /dev/null +++ b/app/Services/System/Setting/HrSettingService.php @@ -0,0 +1,31 @@ + $settings->scheduled_check_in_time, + 'scheduled_check_out_time' => $settings->scheduled_check_out_time, + 'late_penalty_amount' => $settings->late_penalty_amount, + 'absent_penalty_amount' => $settings->absent_penalty_amount, + ]; + } + + public function updateHr(array $validated): void + { + $settings = app(HrSettings::class); + + $settings->scheduled_check_in_time = $validated['scheduled_check_in_time']; + $settings->scheduled_check_out_time = $validated['scheduled_check_out_time']; + $settings->late_penalty_amount = (int) $validated['late_penalty_amount']; + $settings->absent_penalty_amount = (int) $validated['absent_penalty_amount']; + $settings->save(); + } +} diff --git a/app/Settings/HrSettings.php b/app/Settings/HrSettings.php new file mode 100644 index 0000000..2a07515 --- /dev/null +++ b/app/Settings/HrSettings.php @@ -0,0 +1,21 @@ +foreignId('attendance_id')->nullable()->after('payroll_id')->constrained()->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('payroll_adjustments', function (Blueprint $table): void { + $table->dropForeign(['attendance_id']); + $table->dropColumn('attendance_id'); + }); + } +}; diff --git a/database/settings/2026_06_18_100000_create_hr_settings.php b/database/settings/2026_06_18_100000_create_hr_settings.php new file mode 100644 index 0000000..ed121d5 --- /dev/null +++ b/database/settings/2026_06_18_100000_create_hr_settings.php @@ -0,0 +1,16 @@ +migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void { + $blueprint->add('scheduled_check_in_time', '08:00'); + $blueprint->add('late_penalty_amount', 0); + $blueprint->add('absent_penalty_amount', 0); + }); + } +}; diff --git a/database/settings/2026_06_18_100001_add_scheduled_check_in_time_to_hr_settings.php b/database/settings/2026_06_18_100001_add_scheduled_check_in_time_to_hr_settings.php new file mode 100644 index 0000000..b5a9a08 --- /dev/null +++ b/database/settings/2026_06_18_100001_add_scheduled_check_in_time_to_hr_settings.php @@ -0,0 +1,14 @@ +migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void { + $blueprint->add('scheduled_check_in_time', '08:00'); + }); + } +}; diff --git a/database/settings/2026_06_18_100002_add_scheduled_check_out_time_to_hr_settings.php b/database/settings/2026_06_18_100002_add_scheduled_check_out_time_to_hr_settings.php new file mode 100644 index 0000000..9b6bd83 --- /dev/null +++ b/database/settings/2026_06_18_100002_add_scheduled_check_out_time_to_hr_settings.php @@ -0,0 +1,14 @@ +migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void { + $blueprint->add('scheduled_check_out_time', '17:00'); + }); + } +}; diff --git a/resources/js/components/admin/setting/HrSection.vue b/resources/js/components/admin/setting/HrSection.vue new file mode 100644 index 0000000..fcca33a --- /dev/null +++ b/resources/js/components/admin/setting/HrSection.vue @@ -0,0 +1,88 @@ + + + diff --git a/resources/js/layouts/SettingLayout.vue b/resources/js/layouts/SettingLayout.vue index 8eb71cd..bcdad3e 100644 --- a/resources/js/layouts/SettingLayout.vue +++ b/resources/js/layouts/SettingLayout.vue @@ -1,5 +1,5 @@ diff --git a/resources/js/pages/admin/hr/attendances/Index.vue b/resources/js/pages/admin/hr/attendances/Index.vue index 6337824..d09a5b9 100644 --- a/resources/js/pages/admin/hr/attendances/Index.vue +++ b/resources/js/pages/admin/hr/attendances/Index.vue @@ -13,6 +13,7 @@ import type { AttendanceListItem, CalendarRange, TodayAttendance } from '@/types const props = defineProps<{ attendances: AttendanceListItem[]; todayAttendance: TodayAttendance; + isOnLeave: boolean; canCheckIn: boolean; canManageAll: boolean; calendarRange: CalendarRange; @@ -21,14 +22,19 @@ const props = defineProps<{ const webcamModalOpen = ref(false); const webcamMode = ref<'check-in' | 'check-out'>('check-in'); -const canCheckInToday = computed(() => props.canCheckIn && props.todayAttendance === null); +const canCheckInToday = computed(() => props.canCheckIn && props.todayAttendance === null && !props.isOnLeave); const canCheckOutToday = computed(() => ( props.canCheckIn + && !props.isOnLeave && props.todayAttendance !== null && props.todayAttendance.check_out_at === null )); const todayStatusLabel = computed(() => { + if (props.isOnLeave) { + return 'Sedang cuti'; + } + if (!props.todayAttendance) { return 'Belum presensi'; } @@ -41,6 +47,10 @@ const todayStatusLabel = computed(() => { }); const todayStatusVariant = computed(() => { + if (props.isOnLeave) { + return 'secondary' as const; + } + if (!props.todayAttendance) { return 'secondary' as const; } @@ -92,6 +102,10 @@ function openCheckOutModal() { {{ todayStatusLabel }} + + Anda sedang dalam masa cuti. Presensi tidak diperlukan. + + Masuk: {{ todayAttendance.check_in_at_formatted }} diff --git a/resources/js/pages/admin/system/setting/Index.vue b/resources/js/pages/admin/system/setting/Index.vue index c9ad8cb..78f6316 100644 --- a/resources/js/pages/admin/system/setting/Index.vue +++ b/resources/js/pages/admin/system/setting/Index.vue @@ -1,11 +1,13 @@