From 3f05e3cdc2026d4cdd42b08413fa42b4662cdd42 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 11 Jun 2026 14:40:54 +0700 Subject: [PATCH] Add payroll management features including Payroll, PayrollPeriod, and PayrollAdjustment models, along with PayrollService for handling payroll operations. Implement corresponding controllers for payroll and payroll period management, and integrate request validation for payroll adjustments. Enhance UI components for payroll listing and management, including data tables and modals. Update permissions in the Permission and Role enums, and add scheduled command for opening payroll periods. --- .../Commands/OpenPayrollPeriodCommand.php | 26 ++ app/Enums/PayrollAdjustmentType.php | 21 ++ app/Enums/PayrollPeriodStatus.php | 21 ++ app/Enums/PayrollStatus.php | 21 ++ app/Enums/Permission.php | 12 + app/Enums/Role.php | 8 + .../Admin/Finance/PayrollController.php | 43 +++ .../Admin/Finance/PayrollPeriodController.php | 56 +++ .../Finance/PayrollAdjustmentRequest.php | 28 ++ app/Models/CashTransaction.php | 1 + app/Models/Employee.php | 5 + app/Models/Payroll.php | 162 +++++++++ app/Models/PayrollAdjustment.php | 64 ++++ app/Models/PayrollPeriod.php | 61 ++++ app/Services/Finance/PayrollService.php | 343 ++++++++++++++++++ bootstrap/app.php | 6 + ...11_100001_create_payroll_periods_table.php | 31 ++ ...026_06_11_100002_create_payrolls_table.php | 37 ++ ...00003_create_payroll_adjustments_table.php | 29 ++ resources/js/components/AppSidebar.vue | 13 +- .../payroll/PayrollAdjustmentModal.vue | 132 +++++++ .../admin/finance/payroll/columns.ts | 61 ++++ .../finance/payroll/data-table-actions.vue | 69 ++++ .../js/pages/admin/finance/payroll/Index.vue | 217 +++++++++++ resources/js/types/payroll.ts | 73 ++++ routes/web.php | 20 + 26 files changed, 1558 insertions(+), 2 deletions(-) create mode 100644 app/Console/Commands/OpenPayrollPeriodCommand.php create mode 100644 app/Enums/PayrollAdjustmentType.php create mode 100644 app/Enums/PayrollPeriodStatus.php create mode 100644 app/Enums/PayrollStatus.php create mode 100644 app/Http/Controllers/Admin/Finance/PayrollController.php create mode 100644 app/Http/Controllers/Admin/Finance/PayrollPeriodController.php create mode 100644 app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php create mode 100644 app/Models/Payroll.php create mode 100644 app/Models/PayrollAdjustment.php create mode 100644 app/Models/PayrollPeriod.php create mode 100644 app/Services/Finance/PayrollService.php create mode 100644 database/migrations/2026_06_11_100001_create_payroll_periods_table.php create mode 100644 database/migrations/2026_06_11_100002_create_payrolls_table.php create mode 100644 database/migrations/2026_06_11_100003_create_payroll_adjustments_table.php create mode 100644 resources/js/components/admin/finance/payroll/PayrollAdjustmentModal.vue create mode 100644 resources/js/components/admin/finance/payroll/columns.ts create mode 100644 resources/js/components/admin/finance/payroll/data-table-actions.vue create mode 100644 resources/js/pages/admin/finance/payroll/Index.vue create mode 100644 resources/js/types/payroll.ts diff --git a/app/Console/Commands/OpenPayrollPeriodCommand.php b/app/Console/Commands/OpenPayrollPeriodCommand.php new file mode 100644 index 0000000..796331f --- /dev/null +++ b/app/Console/Commands/OpenPayrollPeriodCommand.php @@ -0,0 +1,26 @@ +openCurrentPeriod(); + + $this->info(sprintf( + 'Periode gaji %s dibuka. Total %d slip gaji.', + $period->period_label, + $period->payrolls()->count(), + )); + + return self::SUCCESS; + } +} diff --git a/app/Enums/PayrollAdjustmentType.php b/app/Enums/PayrollAdjustmentType.php new file mode 100644 index 0000000..1d0aa8c --- /dev/null +++ b/app/Enums/PayrollAdjustmentType.php @@ -0,0 +1,21 @@ + 'Tunjangan', + self::DEDUCTION => 'Potongan', + }; + } +} diff --git a/app/Enums/PayrollPeriodStatus.php b/app/Enums/PayrollPeriodStatus.php new file mode 100644 index 0000000..8d88d22 --- /dev/null +++ b/app/Enums/PayrollPeriodStatus.php @@ -0,0 +1,21 @@ + 'Dibuka', + self::CLOSED => 'Ditutup', + }; + } +} diff --git a/app/Enums/PayrollStatus.php b/app/Enums/PayrollStatus.php new file mode 100644 index 0000000..ce82901 --- /dev/null +++ b/app/Enums/PayrollStatus.php @@ -0,0 +1,21 @@ + 'Belum Dibayar', + self::PAID => 'Sudah Dibayar', + }; + } +} diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index 9048ff0..5cf6357 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -61,6 +61,11 @@ enum Permission: string case EMPLOYEE_ADVANCES_VERIFY = 'employee-advances.verify'; case EMPLOYEE_ADVANCES_PAY = 'employee-advances.pay'; + case PAYROLL_VIEW = 'payroll.view'; + case PAYROLL_PAY = 'payroll.pay'; + case PAYROLL_ADJUST = 'payroll.adjust'; + case PAYROLL_CLOSE = 'payroll.close'; + public function label(): string { return match ($this) { @@ -116,6 +121,11 @@ public function label(): string self::EMPLOYEE_ADVANCES_DELETE => 'Hapus Kasbon', self::EMPLOYEE_ADVANCES_VERIFY => 'Setujui/Tolak Kasbon', self::EMPLOYEE_ADVANCES_PAY => 'Pelunasi Kasbon', + + self::PAYROLL_VIEW => 'Lihat Gaji', + self::PAYROLL_PAY => 'Bayar Gaji', + self::PAYROLL_ADJUST => 'Sesuaikan Gaji', + self::PAYROLL_CLOSE => 'Tutup Periode Gaji', }; } @@ -141,6 +151,8 @@ public function group(): string self::EMPLOYEE_ADVANCES_VIEW, self::EMPLOYEE_ADVANCES_CREATE, self::EMPLOYEE_ADVANCES_UPDATE, self::EMPLOYEE_ADVANCES_DELETE, self::EMPLOYEE_ADVANCES_VERIFY, self::EMPLOYEE_ADVANCES_PAY => 'Kasbon', + self::PAYROLL_VIEW, self::PAYROLL_PAY, self::PAYROLL_ADJUST, + self::PAYROLL_CLOSE => 'Gaji', }; } diff --git a/app/Enums/Role.php b/app/Enums/Role.php index 4c3e46d..a5a2f46 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -79,6 +79,10 @@ public function permissions(): array Permission::EMPLOYEE_ADVANCES_UPDATE, Permission::EMPLOYEE_ADVANCES_DELETE, Permission::EMPLOYEE_ADVANCES_PAY, + Permission::PAYROLL_VIEW, + Permission::PAYROLL_PAY, + Permission::PAYROLL_ADJUST, + Permission::PAYROLL_CLOSE, ], self::ADMIN_TOKO => [ Permission::DASHBOARD_VIEW, @@ -117,6 +121,10 @@ public function permissions(): array Permission::EMPLOYEE_ADVANCES_UPDATE, Permission::EMPLOYEE_ADVANCES_DELETE, Permission::EMPLOYEE_ADVANCES_PAY, + Permission::PAYROLL_VIEW, + Permission::PAYROLL_PAY, + Permission::PAYROLL_ADJUST, + Permission::PAYROLL_CLOSE, ], self::ADMIN_BAHAN_BAKU => [ Permission::DASHBOARD_VIEW, diff --git a/app/Http/Controllers/Admin/Finance/PayrollController.php b/app/Http/Controllers/Admin/Finance/PayrollController.php new file mode 100644 index 0000000..de6f32e --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/PayrollController.php @@ -0,0 +1,43 @@ +payrollService->pay($payroll, auth()->user()); + + Inertia::flash('success', 'Gaji berhasil dibayar.'); + + return redirect()->route('admin.finance.payroll.index', [ + 'period_id' => $payroll->payroll_period_id, + ]); + } + + public function storeAdjustment(PayrollAdjustmentRequest $request, Payroll $payroll): RedirectResponse + { + $this->payrollService->addAdjustment( + $payroll, + $request->validated(), + auth()->user(), + ); + + Inertia::flash('success', 'Penyesuaian gaji berhasil ditambahkan.'); + + return redirect()->route('admin.finance.payroll.index', [ + 'period_id' => $payroll->payroll_period_id, + ]); + } +} diff --git a/app/Http/Controllers/Admin/Finance/PayrollPeriodController.php b/app/Http/Controllers/Admin/Finance/PayrollPeriodController.php new file mode 100644 index 0000000..9615961 --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/PayrollPeriodController.php @@ -0,0 +1,56 @@ +parseDataTableQuery($request); + $periodId = $request->integer('period_id') ?: null; + $period = $this->payrollService->resolvePeriod($periodId); + + return Inertia::render('admin/finance/payroll/Index', [ + 'periods' => $this->payrollService->listPeriods(), + 'currentPeriod' => $period, + 'payrolls' => $period + ? $this->payrollService->paginateForPeriod($period, $tableQuery) + : null, + 'summary' => $period + ? $this->payrollService->periodSummary($period) + : null, + 'adjustmentTypes' => PayrollAdjustmentType::selectOptions(), + 'filters' => array_merge( + $this->dataTableFilters($tableQuery), + ['period_id' => $period?->id], + ), + ]); + } + + public function close(PayrollPeriod $payrollPeriod): RedirectResponse + { + $this->payrollService->closePeriod($payrollPeriod, auth()->user()); + + Inertia::flash('success', 'Periode gaji berhasil ditutup.'); + + return redirect()->route('admin.finance.payroll.index', [ + 'period_id' => $payrollPeriod->id, + ]); + } +} diff --git a/app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php b/app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php new file mode 100644 index 0000000..34b737a --- /dev/null +++ b/app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php @@ -0,0 +1,28 @@ +user()?->can(Permission::PAYROLL_ADJUST->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'type' => ['required', Rule::enum(PayrollAdjustmentType::class)], + 'amount' => ['required', 'integer', 'min:1'], + 'description' => ['required', 'string', 'max:500'], + ]; + } +} diff --git a/app/Models/CashTransaction.php b/app/Models/CashTransaction.php index 44c719a..4223849 100644 --- a/app/Models/CashTransaction.php +++ b/app/Models/CashTransaction.php @@ -96,6 +96,7 @@ public static function labelForReferenceType(?string $referenceType): string return match ($referenceType) { Expense::class => 'Pengeluaran', EmployeeAdvance::class => 'Kasbon Pegawai', + Payroll::class => 'Gaji Pegawai', default => class_basename($referenceType), }; } diff --git a/app/Models/Employee.php b/app/Models/Employee.php index 78108ec..21c69cd 100644 --- a/app/Models/Employee.php +++ b/app/Models/Employee.php @@ -97,4 +97,9 @@ public function advances(): HasMany { return $this->hasMany(EmployeeAdvance::class); } + + public function payrolls(): HasMany + { + return $this->hasMany(Payroll::class); + } } diff --git a/app/Models/Payroll.php b/app/Models/Payroll.php new file mode 100644 index 0000000..d036480 --- /dev/null +++ b/app/Models/Payroll.php @@ -0,0 +1,162 @@ + 'integer', + 'bonus_amount' => 'integer', + 'deduction_amount' => 'integer', + 'net_amount' => 'integer', + 'status' => PayrollStatus::class, + 'paid_at' => 'datetime', + ]; + } + + public function baseSalaryFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->base_salary, 0, ',', '.'), + ); + } + + public function bonusAmountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->bonus_amount, 0, ',', '.'), + ); + } + + public function deductionAmountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->deduction_amount, 0, ',', '.'), + ); + } + + public function netAmountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->net_amount, 0, ',', '.'), + ); + } + + public function statusLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->status?->label(), + ); + } + + public function employeeName(): Attribute + { + return Attribute::make( + get: fn () => $this->employee?->user?->profile?->full_name + ?? $this->employee?->user?->username, + ); + } + + public function paidAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->paid_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function canPay(): Attribute + { + return Attribute::make( + get: fn () => $this->status === PayrollStatus::UNPAID + && $this->relationLoaded('payrollPeriod') + && $this->payrollPeriod?->isOpen(), + ); + } + + public function canAdjust(): Attribute + { + return Attribute::make( + get: fn () => $this->status === PayrollStatus::UNPAID + && $this->relationLoaded('payrollPeriod') + && $this->payrollPeriod?->isOpen(), + ); + } + + public function payrollPeriod(): BelongsTo + { + return $this->belongsTo(PayrollPeriod::class); + } + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function paidBy(): BelongsTo + { + return $this->belongsTo(User::class, 'paid_by_id'); + } + + public function cashTransaction(): BelongsTo + { + return $this->belongsTo(CashTransaction::class); + } + + public function adjustments(): HasMany + { + return $this->hasMany(PayrollAdjustment::class); + } + + public function recalculateAmounts(): void + { + $bonusAmount = (int) $this->adjustments() + ->where('type', PayrollAdjustmentType::BONUS) + ->sum('amount'); + + $manualDeduction = (int) $this->adjustments() + ->where('type', PayrollAdjustmentType::DEDUCTION) + ->sum('amount'); + + $kasbonDeduction = $this->calculateKasbonDeduction(); + + $this->bonus_amount = $bonusAmount; + $this->deduction_amount = $kasbonDeduction + $manualDeduction; + $this->net_amount = max(0, $this->base_salary + $bonusAmount - $this->deduction_amount); + } + + public function calculateKasbonDeduction(): int + { + $outstanding = (int) EmployeeAdvance::query() + ->where('employee_id', $this->employee_id) + ->where('status', EmployeeAdvanceStatus::APPROVED) + ->sum('amount'); + + return min($outstanding, $this->base_salary + (int) $this->adjustments() + ->where('type', PayrollAdjustmentType::BONUS) + ->sum('amount')); + } +} diff --git a/app/Models/PayrollAdjustment.php b/app/Models/PayrollAdjustment.php new file mode 100644 index 0000000..60ff6f7 --- /dev/null +++ b/app/Models/PayrollAdjustment.php @@ -0,0 +1,64 @@ + PayrollAdjustmentType::class, + 'amount' => 'integer', + 'created_at' => 'datetime', + ]; + } + + public function amountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'), + ); + } + + public function typeLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->type?->label(), + ); + } + + public function createdAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function createdByName(): Attribute + { + return Attribute::make( + get: fn () => $this->createdBy?->profile?->full_name ?? $this->createdBy?->username, + ); + } + + public function payroll(): BelongsTo + { + return $this->belongsTo(Payroll::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_id'); + } +} diff --git a/app/Models/PayrollPeriod.php b/app/Models/PayrollPeriod.php new file mode 100644 index 0000000..84cd7ca --- /dev/null +++ b/app/Models/PayrollPeriod.php @@ -0,0 +1,61 @@ + PayrollPeriodStatus::class, + 'closed_at' => 'datetime', + ]; + } + + public function periodLabel(): Attribute + { + return Attribute::make( + get: fn () => Carbon::create($this->year, $this->month, 1)->translatedFormat('F Y'), + ); + } + + public function statusLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->status?->label(), + ); + } + + public function closedAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->closed_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function payrolls(): HasMany + { + return $this->hasMany(Payroll::class); + } + + public function closedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'closed_by_id'); + } + + public function isOpen(): bool + { + return $this->status === PayrollPeriodStatus::OPEN; + } +} diff --git a/app/Services/Finance/PayrollService.php b/app/Services/Finance/PayrollService.php new file mode 100644 index 0000000..604c4b7 --- /dev/null +++ b/app/Services/Finance/PayrollService.php @@ -0,0 +1,343 @@ + + */ + public function listPeriods(): Collection + { + return PayrollPeriod::query() + ->orderByDesc('year') + ->orderByDesc('month') + ->get(); + } + + public function resolvePeriod(?int $periodId): ?PayrollPeriod + { + if ($periodId !== null) { + return PayrollPeriod::query()->find($periodId); + } + + return PayrollPeriod::query() + ->where('status', PayrollPeriodStatus::OPEN) + ->orderByDesc('year') + ->orderByDesc('month') + ->first() + ?? PayrollPeriod::query() + ->orderByDesc('year') + ->orderByDesc('month') + ->first(); + } + + /** + * @return array{total_net_amount: int, total_net_amount_formatted: string, unpaid_count: int, paid_count: int} + */ + public function periodSummary(PayrollPeriod $period): array + { + $totalNetAmount = (int) Payroll::query() + ->where('payroll_period_id', $period->id) + ->where('status', PayrollStatus::UNPAID) + ->sum('net_amount'); + + $unpaidCount = Payroll::query() + ->where('payroll_period_id', $period->id) + ->where('status', PayrollStatus::UNPAID) + ->count(); + + $paidCount = Payroll::query() + ->where('payroll_period_id', $period->id) + ->where('status', PayrollStatus::PAID) + ->count(); + + return [ + 'total_net_amount' => $totalNetAmount, + 'total_net_amount_formatted' => 'Rp '.number_format($totalNetAmount, 0, ',', '.'), + 'unpaid_count' => $unpaidCount, + 'paid_count' => $paidCount, + ]; + } + + /** + * @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery + */ + public function paginateForPeriod(PayrollPeriod $period, array $tableQuery): LengthAwarePaginator + { + $query = Payroll::query() + ->with(['employee.user.profile', 'payrollPeriod']) + ->where('payroll_period_id', $period->id) + ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { + $search = $tableQuery['search']; + $query->where(function (Builder $query) use ($search): void { + $query->whereHas('employee.user.profile', fn (Builder $query) => $query->where('full_name', 'like', "%{$search}%")) + ->orWhereHas('employee.user', fn (Builder $query) => $query->where('username', 'like', "%{$search}%")); + }); + }); + + $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']); + + return $query + ->paginate(10) + ->withQueryString(); + } + + public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod + { + return DB::transaction(function () use ($closedBy): PayrollPeriod { + $now = now(); + $year = $now->year; + $month = $now->month; + + PayrollPeriod::query() + ->where('status', PayrollPeriodStatus::OPEN) + ->update([ + 'status' => PayrollPeriodStatus::CLOSED, + 'closed_at' => now(), + 'closed_by_id' => $closedBy?->id, + ]); + + $period = PayrollPeriod::query() + ->where('year', $year) + ->where('month', $month) + ->first(); + + if ($period === null) { + $period = PayrollPeriod::create([ + 'year' => $year, + 'month' => $month, + 'status' => PayrollPeriodStatus::OPEN, + ]); + } elseif ($period->status === PayrollPeriodStatus::CLOSED) { + $period->status = PayrollPeriodStatus::OPEN; + $period->closed_at = null; + $period->closed_by_id = null; + $period->save(); + } + + $this->generatePayrollsForPeriod($period); + + return $period->fresh(); + }); + } + + public function closePeriod(PayrollPeriod $period, User $user): void + { + if (! $period->isOpen()) { + throw ValidationException::withMessages([ + 'payroll_period' => 'Periode gaji ini sudah ditutup.', + ]); + } + + $unpaidCount = Payroll::query() + ->where('payroll_period_id', $period->id) + ->where('status', PayrollStatus::UNPAID) + ->where('net_amount', '>', 0) + ->count(); + + if ($unpaidCount > 0) { + throw ValidationException::withMessages([ + 'payroll_period' => 'Masih ada gaji yang belum dibayar.', + ]); + } + + $period->status = PayrollPeriodStatus::CLOSED; + $period->closed_at = now(); + $period->closed_by_id = $user->id; + $period->save(); + } + + public function generatePayrollsForPeriod(PayrollPeriod $period): void + { + $employees = $this->payrollEligibleEmployees($period); + + foreach ($employees as $employee) { + $exists = Payroll::query() + ->where('payroll_period_id', $period->id) + ->where('employee_id', $employee->id) + ->exists(); + + if ($exists) { + continue; + } + + $payroll = new Payroll([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'base_salary' => $employee->base_salary, + 'bonus_amount' => 0, + 'deduction_amount' => 0, + 'net_amount' => 0, + 'status' => PayrollStatus::UNPAID, + ]); + + $payroll->save(); + $payroll->recalculateAmounts(); + $payroll->save(); + } + } + + /** + * @param array{type: string, amount: int, description: string} $validated + */ + public function addAdjustment(Payroll $payroll, array $validated, User $user): void + { + $payroll->loadMissing('payrollPeriod'); + + if (! $payroll->can_adjust) { + throw ValidationException::withMessages([ + 'payroll' => 'Penyesuaian hanya dapat ditambahkan pada gaji yang belum dibayar di periode terbuka.', + ]); + } + + DB::transaction(function () use ($payroll, $validated, $user): void { + $payroll->adjustments()->create([ + 'type' => PayrollAdjustmentType::from($validated['type']), + 'amount' => (int) $validated['amount'], + 'description' => $validated['description'], + 'created_by_id' => $user->id, + ]); + + $payroll->load('adjustments'); + $payroll->recalculateAmounts(); + $payroll->save(); + }); + } + + public function pay(Payroll $payroll, User $user): void + { + $payroll->loadMissing(['payrollPeriod', 'employee.user.profile']); + + if ($payroll->status !== PayrollStatus::UNPAID) { + throw ValidationException::withMessages([ + 'payroll' => 'Gaji ini sudah dibayar.', + ]); + } + + if (! $payroll->payrollPeriod?->isOpen()) { + throw ValidationException::withMessages([ + 'payroll' => 'Periode gaji sudah ditutup.', + ]); + } + + if ($payroll->net_amount <= 0) { + DB::transaction(function () use ($payroll, $user): void { + $payroll->status = PayrollStatus::PAID; + $payroll->paid_at = now(); + $payroll->paid_by_id = $user->id; + $payroll->save(); + + $this->settleKasbonFromPayroll($payroll, $user); + }); + + return; + } + + DB::transaction(function () use ($payroll, $user): void { + $description = sprintf( + 'Pembayaran gaji: %s (%s)', + $payroll->employeeName, + $payroll->payrollPeriod->period_label, + ); + + $cashTransaction = $this->cashService->recordOutgoing( + $payroll, + $payroll->net_amount, + $description, + $user, + ); + + $payroll->cash_transaction_id = $cashTransaction->id; + $payroll->status = PayrollStatus::PAID; + $payroll->paid_at = now(); + $payroll->paid_by_id = $user->id; + $payroll->save(); + + $this->settleKasbonFromPayroll($payroll, $user); + }); + } + + /** + * @return Collection + */ + private function payrollEligibleEmployees(PayrollPeriod $period): Collection + { + $periodStart = Carbon::create($period->year, $period->month, 1)->startOfMonth(); + + return Employee::query() + ->with('user.roles') + ->whereHas('user', function (Builder $query): void { + $query->where('is_active', true) + ->whereDoesntHave('roles', fn (Builder $query) => $query->whereIn('name', [ + Role::DEVELOPER->value, + Role::OWNER->value, + ])); + }) + ->where(function (Builder $query) use ($periodStart): void { + $query->whereNull('resign_date') + ->orWhere('resign_date', '>=', $periodStart); + }) + ->get(); + } + + private function settleKasbonFromPayroll(Payroll $payroll, User $user): void + { + $remaining = (int) $payroll->deduction_amount; + + if ($remaining <= 0) { + return; + } + + $advances = EmployeeAdvance::query() + ->where('employee_id', $payroll->employee_id) + ->where('status', EmployeeAdvanceStatus::APPROVED) + ->orderBy('created_at') + ->get(); + + foreach ($advances as $advance) { + if ($remaining <= 0) { + break; + } + + $advance->paid_at = now(); + $advance->paid_by_id = $user->id; + $advance->status = EmployeeAdvanceStatus::PAID; + $advance->save(); + + $remaining -= $advance->amount; + } + } + + private function applySorting(Builder $query, string $sort, string $direction): void + { + if (in_array($sort, ['base_salary', 'bonus_amount', 'deduction_amount', 'net_amount', 'status', 'created_at'], true)) { + $query->orderBy($sort, $direction); + + return; + } + + $query->orderBy('employee_id'); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index b9b2d94..7adc116 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -2,6 +2,7 @@ use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; +use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -34,4 +35,9 @@ $exceptions->shouldRenderJsonWhen( fn (Request $request) => $request->is('api/*'), ); + }) + ->withSchedule(function (Schedule $schedule): void { + $schedule->command('payroll:open-period') + ->monthlyOn(1, '00:05') + ->timezone('Asia/Jakarta'); })->create(); diff --git a/database/migrations/2026_06_11_100001_create_payroll_periods_table.php b/database/migrations/2026_06_11_100001_create_payroll_periods_table.php new file mode 100644 index 0000000..85c8497 --- /dev/null +++ b/database/migrations/2026_06_11_100001_create_payroll_periods_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedSmallInteger('year'); + $table->unsignedTinyInteger('month'); + $table->enum('status', array_column(PayrollPeriodStatus::cases(), 'value')) + ->default(PayrollPeriodStatus::OPEN->value); + $table->timestamp('closed_at')->nullable(); + $table->foreignId('closed_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['year', 'month']); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('payroll_periods'); + } +}; diff --git a/database/migrations/2026_06_11_100002_create_payrolls_table.php b/database/migrations/2026_06_11_100002_create_payrolls_table.php new file mode 100644 index 0000000..0e807aa --- /dev/null +++ b/database/migrations/2026_06_11_100002_create_payrolls_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('payroll_period_id')->constrained()->cascadeOnDelete(); + $table->foreignId('employee_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('base_salary'); + $table->unsignedBigInteger('bonus_amount')->default(0); + $table->unsignedBigInteger('deduction_amount')->default(0); + $table->unsignedBigInteger('net_amount'); + $table->enum('status', array_column(PayrollStatus::cases(), 'value')) + ->default(PayrollStatus::UNPAID->value); + $table->foreignId('cash_transaction_id')->nullable()->unique()->constrained()->restrictOnDelete(); + $table->timestamp('paid_at')->nullable(); + $table->foreignId('paid_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['payroll_period_id', 'employee_id']); + $table->index(['payroll_period_id', 'status']); + $table->index('employee_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('payrolls'); + } +}; diff --git a/database/migrations/2026_06_11_100003_create_payroll_adjustments_table.php b/database/migrations/2026_06_11_100003_create_payroll_adjustments_table.php new file mode 100644 index 0000000..7e5b6f7 --- /dev/null +++ b/database/migrations/2026_06_11_100003_create_payroll_adjustments_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('payroll_id')->constrained()->cascadeOnDelete(); + $table->enum('type', array_column(PayrollAdjustmentType::cases(), 'value')); + $table->unsignedBigInteger('amount'); + $table->string('description', 500); + $table->foreignId('created_by_id')->constrained('users')->restrictOnDelete(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['payroll_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('payroll_adjustments'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index b604f4f..5c40510 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ + +