feat: implement attendance management features including penalty application, reminders, and HR settings management

This commit is contained in:
Yoga Pangestu 2026-06-18 01:52:50 +07:00
parent fe7a3e4813
commit 55b779c76e
20 changed files with 567 additions and 8 deletions

View File

@ -0,0 +1,178 @@
<?php
namespace App\Console\Commands;
use App\Enums\EmployeeStatus;
use App\Enums\LeaveRequestStatus;
use App\Enums\PayrollAdjustmentType;
use App\Enums\PayrollPeriodStatus;
use App\Models\Attendance;
use App\Models\Employee;
use App\Models\LeaveRequest;
use App\Models\Payroll;
use App\Models\PayrollPeriod;
use App\Settings\HrSettings;
use Carbon\Carbon;
use Illuminate\Console\Command;
class ApplyAttendancePenalties extends Command
{
protected $signature = 'attendance:apply-penalties {--date= : Tanggal yang diproses (default: kemarin)}';
protected $description = 'Buat potongan gaji otomatis untuk keterlambatan dan bolos';
public function handle(): int
{
$date = $this->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();
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Console\Commands;
use App\Enums\EmployeeStatus;
use App\Models\Attendance;
use App\Models\Employee;
use App\Services\System\PushNotificationService;
use App\Settings\HrSettings;
use Carbon\Carbon;
use Illuminate\Console\Command;
class SendAttendanceReminder extends Command
{
protected $signature = 'attendance:send-reminder';
protected $description = 'Kirim pengingat presensi ke pegawai 30 menit sebelum jam masuk';
public function handle(PushNotificationService $pushNotificationService): int
{
$settings = app(HrSettings::class);
$scheduledTime = $settings->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;
}
}

View File

@ -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,

View File

@ -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');
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests\Admin\System\Setting;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class HrSettingRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::SETTINGS_UPDATE->value) ?? false;
}
/**
* @return array<string, mixed>
*/
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<string, string>
*/
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',
];
}
}

View File

@ -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(

View File

@ -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
*/

View File

@ -0,0 +1,31 @@
<?php
namespace App\Services\System\Setting;
use App\Settings\HrSettings;
class HrSettingService
{
public function hrData(): array
{
$settings = app(HrSettings::class);
return [
'scheduled_check_in_time' => $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();
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Settings;
use Spatie\LaravelSettings\Settings;
class HrSettings extends Settings
{
public string $scheduled_check_in_time;
public string $scheduled_check_out_time;
public int $late_penalty_amount;
public int $absent_penalty_amount;
public static function group(): string
{
return 'hr';
}
}

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('payroll_adjustments', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,16 @@
<?php
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->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);
});
}
};

View File

@ -0,0 +1,14 @@
<?php
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void {
$blueprint->add('scheduled_check_in_time', '08:00');
});
}
};

View File

@ -0,0 +1,14 @@
<?php
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration;
return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void {
$blueprint->add('scheduled_check_out_time', '17:00');
});
}
};

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { toast } from 'vue-sonner';
import { RupiahInput } from '@/components/form/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { formErrors } from '@/lib/form';
import type { HrSettingsData } from '@/types/setting';
const props = defineProps<{
data: HrSettingsData;
}>();
const form = useForm({
scheduled_check_in_time: props.data.scheduled_check_in_time,
scheduled_check_out_time: props.data.scheduled_check_out_time,
late_penalty_amount: String(props.data.late_penalty_amount),
absent_penalty_amount: String(props.data.absent_penalty_amount),
});
function submit() {
form.put('/admin/system/setting/hr', {
preserveScroll: true,
onError: () => {
toast.error('Gagal menyimpan pengaturan HR.');
},
});
}
</script>
<template>
<form @submit.prevent="submit">
<Card>
<CardContent>
<FieldSet>
<FieldGroup class="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel for="scheduled_check_in_time" required>Jam Masuk Kerja</FieldLabel>
<Input id="scheduled_check_in_time" v-model="form.scheduled_check_in_time" type="time" />
<p class="text-xs text-muted-foreground mt-1">
Jam masuk yang dijadwalkan. Keterlambatan dihitung dari jam ini. Notifikasi pengingat
dikirim 30 menit sebelum jam ini.
</p>
<FieldError :errors="formErrors(form, 'scheduled_check_in_time')" />
</Field>
<Field>
<FieldLabel for="scheduled_check_out_time" required>Jam Pulang Kerja</FieldLabel>
<Input id="scheduled_check_out_time" v-model="form.scheduled_check_out_time" type="time" />
<p class="text-xs text-muted-foreground mt-1">
Jam pulang yang dijadwalkan. Digunakan sebagai referensi waktu kerja.
</p>
<FieldError :errors="formErrors(form, 'scheduled_check_out_time')" />
</Field>
<Field>
<FieldLabel for="late_penalty_amount">Denda Keterlambatan</FieldLabel>
<RupiahInput id="late_penalty_amount" v-model="form.late_penalty_amount" placeholder="0" />
<p class="text-xs text-muted-foreground mt-1">
Potongan gaji otomatis jika pegawai terlambat melakukan presensi masuk.
</p>
<FieldError :errors="formErrors(form, 'late_penalty_amount')" />
</Field>
<Field>
<FieldLabel for="absent_penalty_amount">Denda Bolos</FieldLabel>
<RupiahInput id="absent_penalty_amount" v-model="form.absent_penalty_amount" placeholder="0" />
<p class="text-xs text-muted-foreground mt-1">
Potongan gaji otomatis jika pegawai tidak melakukan presensi masuk dan pulang (bolos).
</p>
<FieldError :errors="formErrors(form, 'absent_penalty_amount')" />
</Field>
</FieldGroup>
</FieldSet>
</CardContent>
</Card>
<div class="mt-6 flex justify-end">
<Button type="submit" :disabled="form.processing">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
</Button>
</div>
</form>
</template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { Settings2, Share2, ShoppingBag } from '@lucide/vue';
import { Settings2, Share2, ShoppingBag, Users } from '@lucide/vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { cn } from '@/lib/utils';
import type { SettingSection } from '@/types/setting';
@ -14,6 +14,7 @@ const navItems: Array<{
{ key: 'system', label: 'Sistem', icon: Settings2 },
{ key: 'social', label: 'Media Sosial', icon: Share2 },
{ key: 'marketplace', label: 'Marketplace', icon: ShoppingBag },
{ key: 'hr', label: 'HR / Pegawai', icon: Users },
];
</script>

View File

@ -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 }}
</Badge>
<span v-if="isOnLeave" class="text-muted-foreground text-sm">
Anda sedang dalam masa cuti. Presensi tidak diperlukan.
</span>
<span v-if="todayAttendance?.check_in_at_formatted" class="text-muted-foreground text-sm">
Masuk: {{ todayAttendance.check_in_at_formatted }}
</span>

View File

@ -1,11 +1,13 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { ref } from 'vue';
import HrSection from '@/components/admin/setting/HrSection.vue';
import MarketplaceSection from '@/components/admin/setting/MarketplaceSection.vue';
import SocialMediaSection from '@/components/admin/setting/SocialMediaSection.vue';
import SystemSection from '@/components/admin/setting/SystemSection.vue';
import SettingLayout from '@/layouts/SettingLayout.vue';
import type {
HrSettingsData,
MarketplaceSettingsData,
SettingSection,
SocialMediaSettingsData,
@ -16,6 +18,7 @@ defineProps<{
system: SystemSettingsData;
socialMedia: SocialMediaSettingsData;
marketplace: MarketplaceSettingsData;
hr: HrSettingsData;
}>();
const activeSection = ref<SettingSection>('system');
@ -29,5 +32,6 @@ const activeSection = ref<SettingSection>('system');
<SystemSection v-if="activeSection === 'system'" :data="system" />
<SocialMediaSection v-else-if="activeSection === 'social'" :data="socialMedia" />
<MarketplaceSection v-else-if="activeSection === 'marketplace'" :data="marketplace" />
<HrSection v-else-if="activeSection === 'hr'" :data="hr" />
</SettingLayout>
</template>

View File

@ -1,4 +1,4 @@
export type SettingSection = 'system' | 'social' | 'marketplace';
export type SettingSection = 'system' | 'social' | 'marketplace' | 'hr';
export type MarketplacePlatform = 'tiktok_shop' | 'shopee';
@ -45,3 +45,10 @@ export type MarketplaceSettingsData = {
shopee_pre_order: MarketplaceFeeRule;
shopee_live_extra: MarketplaceFeeRule;
};
export type HrSettingsData = {
scheduled_check_in_time: string;
scheduled_check_out_time: string;
late_penalty_amount: number;
absent_penalty_amount: number;
};

View File

@ -1,8 +1,15 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('attendance:send-reminder')
->daily()
->at('07:30')
->withoutOverlapping()
->appendOutputTo(storage_path('logs/attendance-reminder.log'));
Schedule::command('attendance:apply-penalties')
->daily()
->at('01:00')
->withoutOverlapping()
->appendOutputTo(storage_path('logs/attendance-penalties.log'));

View File

@ -321,6 +321,10 @@
Route::put('marketplace', [SettingController::class, 'updateMarketplace'])
->middleware('permission:'.Permission::SETTINGS_UPDATE->value)
->name('marketplace.update');
Route::put('hr', [SettingController::class, 'updateHr'])
->middleware('permission:'.Permission::SETTINGS_UPDATE->value)
->name('hr.update');
});
});