From 8d5fdc9801b3d9ade4503e3e4cc87ac4b69cf901 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Mon, 6 Oct 2025 19:14:49 +0700 Subject: [PATCH] feat(payroll): membuat fitur penggajian otomatis -membuat command untuk generate dan close payroll -membuat enum IsPaid dan SalaryAdjustmentType -membuat skema db --- app/Console/Commands/ClosePayrolls.php | 43 +++++ app/Console/Commands/GeneratePayrolls.php | 53 +++++++ app/Enums/IsPaid.php | 30 ++++ app/Enums/SalaryAdjustmentType.php | 30 ++++ .../Studio/Finance/PayrollsTable.php | 91 +++++++++++ .../Forms/Studio/Finance/PayrollForm.php | 87 ++++++++++ app/Livewire/Studio/Finance/Payroll.php | 115 ++++++++++++++ app/Models/Payroll.php | 42 +++++ app/Models/PayrollAdjustment.php | 30 ++++ app/Models/SalaryHistory.php | 29 ++++ app/Models/User.php | 15 +- app/Providers/ViewServiceProvider.php | 7 + ...025_10_04_143515_create_payrolls_table.php | 37 +++++ ...43521_create_payroll_adjustments_table.php | 33 ++++ ...4_143527_create_salary_histories_table.php | 32 ++++ database/seeders/RolePermissionSeeder.php | 3 + .../studio/finance/payrolls.blade.php | 148 ++++++++++++++++++ routes/pages/studio.php | 10 +- 18 files changed, 833 insertions(+), 2 deletions(-) create mode 100644 app/Console/Commands/ClosePayrolls.php create mode 100644 app/Console/Commands/GeneratePayrolls.php create mode 100644 app/Enums/IsPaid.php create mode 100644 app/Enums/SalaryAdjustmentType.php create mode 100644 app/Livewire/Datatable/Studio/Finance/PayrollsTable.php create mode 100644 app/Livewire/Forms/Studio/Finance/PayrollForm.php create mode 100644 app/Livewire/Studio/Finance/Payroll.php create mode 100644 app/Models/Payroll.php create mode 100644 app/Models/PayrollAdjustment.php create mode 100644 app/Models/SalaryHistory.php create mode 100644 database/migrations/2025_10_04_143515_create_payrolls_table.php create mode 100644 database/migrations/2025_10_04_143521_create_payroll_adjustments_table.php create mode 100644 database/migrations/2025_10_04_143527_create_salary_histories_table.php create mode 100644 resources/views/livewire/studio/finance/payrolls.blade.php diff --git a/app/Console/Commands/ClosePayrolls.php b/app/Console/Commands/ClosePayrolls.php new file mode 100644 index 0000000..7bcc659 --- /dev/null +++ b/app/Console/Commands/ClosePayrolls.php @@ -0,0 +1,43 @@ +option('month') ?: Carbon::now()->format('Y-m'); + + if ($this->option('current')) { + $targetMonth = Carbon::now()->format('Y-m'); + } + + $payrolls = Payroll::where('period_month', $targetMonth) + ->where('is_paid', IsPaid::NOT_PAID) + ->get(); + + if ($payrolls->isEmpty()) { + $this->warn("Tidak ada payroll yang perlu ditutup untuk bulan {$targetMonth}."); + + return; + } + + $updatedCount = 0; + foreach ($payrolls as $payroll) { + $payroll->is_paid = IsPaid::PAID; + $payroll->save(); + $updatedCount++; + } + + $this->info("Payroll untuk bulan {$targetMonth} berhasil ditandai selesai. Total: {$updatedCount}"); + } +} diff --git a/app/Console/Commands/GeneratePayrolls.php b/app/Console/Commands/GeneratePayrolls.php new file mode 100644 index 0000000..4272b5b --- /dev/null +++ b/app/Console/Commands/GeneratePayrolls.php @@ -0,0 +1,53 @@ +option('current'); + + $targetDate = $useNextMonth ? Carbon::now()->addMonth() : Carbon::now(); + $periodMonth = $targetDate->format('Y-m'); + $periodLabel = $targetDate->translatedFormat('F Y'); + + $users = User::whereHas('employee')->active()->get(); + + if ($users->isEmpty()) { + $this->warn('Tidak ada pegawai.'); + + return; + } + + $created = 0; + foreach ($users as $user) { + $payroll = Payroll::firstOrCreate([ + 'user_id' => $user->id, + 'period_month' => $periodMonth, + ], [ + 'base_salary' => $user->employee->base_salary, + 'bonus' => 0, + 'deduction' => 0, + 'total_salary' => $user->employee->base_salary, + 'is_paid' => IsPaid::NOT_PAID, + ]); + + if ($payroll->wasRecentlyCreated) { + $created++; + } + } + + $this->info("Payroll untuk bulan {$periodLabel} berhasil digenerate. Total data baru: {$created}"); + } +} diff --git a/app/Enums/IsPaid.php b/app/Enums/IsPaid.php new file mode 100644 index 0000000..1ea0662 --- /dev/null +++ b/app/Enums/IsPaid.php @@ -0,0 +1,30 @@ + 'Dibayar', + self::NOT_PAID => 'Belum Dibayar', + }; + } + + public function color() + { + return match ($this) { + self::PAID => 'emerald', + self::NOT_PAID => 'red', + }; + } +} diff --git a/app/Enums/SalaryAdjustmentType.php b/app/Enums/SalaryAdjustmentType.php new file mode 100644 index 0000000..b8be00c --- /dev/null +++ b/app/Enums/SalaryAdjustmentType.php @@ -0,0 +1,30 @@ + 'Bonus', + self::DEDUCTION => 'Potongan', + }; + } + + public function color() + { + return match ($this) { + self::BONUS => 'emerald', + self::DEDUCTION => 'red', + }; + } +} diff --git a/app/Livewire/Datatable/Studio/Finance/PayrollsTable.php b/app/Livewire/Datatable/Studio/Finance/PayrollsTable.php new file mode 100644 index 0000000..00199a5 --- /dev/null +++ b/app/Livewire/Datatable/Studio/Finance/PayrollsTable.php @@ -0,0 +1,91 @@ +searchable(), + + Column::make('Bulan', 'period_month') + ->format(fn ($value) => formatDate($value, 'F Y')) + ->searchable(), + + Column::make('Gaji Pokok', 'base_salary') + ->format(fn ($value) => currency($value, 'Rp')) + ->searchable(), + + Column::make('Bonus', 'bonus') + ->format(fn ($value) => currency($value, 'Rp')) + ->searchable(), + + Column::make('Potongan', 'deduction') + ->format(fn ($value) => currency($value, 'Rp')) + ->searchable(), + + Column::make('Total Gaji', 'total_salary') + ->format(fn ($value) => currency($value, 'Rp')) + ->searchable(), + + ArrayColumn::make('Rincian') + ->data( + fn ($value, $row) => $row->adjustments + ->where('payroll_id', $row->id) + ->map(fn ($item) => [ + 'color' => $item->type->value == SalaryAdjustmentType::DEDUCTION->value ? 'text-red-500' : 'text-green-500', + 'amount' => currency($item->amount, 'Rp'), + 'description' => $item->description, + 'id' => $item->id, + ])->toArray() + ) + ->outputFormat(function ($index, $value) { + return Blade::render(' +
+
+
{{ $value[\'amount\'] }}
+
{{ $value[\'description\'] }}
+
+
+ ', ['value' => $value]); + }) + ->flexCol(['class' => 'flex-col gap-3']), + + Column::make('Status') + ->label(fn ($row) => Blade::render(' +
+ + {{ $row->is_paid->label() }} + + + {{ $row->paid_at ? formatDateTime($row->paid_at) : "-" }} + +
+ ', ['row' => $row])) + ->html(), + ]; + } + + public function builder(): Builder + { + return Payroll::select('payrolls.id', 'period_month', 'payrolls.base_salary', 'bonus', 'deduction', 'total_salary', 'is_paid', 'paid_at') + ->where('period_month', '!=', now()->format('Y-m')) + ->with(['user', 'user.employee']); + } +} diff --git a/app/Livewire/Forms/Studio/Finance/PayrollForm.php b/app/Livewire/Forms/Studio/Finance/PayrollForm.php new file mode 100644 index 0000000..defe26c --- /dev/null +++ b/app/Livewire/Forms/Studio/Finance/PayrollForm.php @@ -0,0 +1,87 @@ + ['required', 'array', 'min:1'], + 'user_ids.*' => ['required', Rule::exists('users', 'id')], + 'type' => ['required', Rule::in(SalaryAdjustmentType::values())], + 'amount' => ['required', new UnsignedInteger], + 'description' => ['required', 'string', 'max:100'], + ]; + } + + public function validationAttributes(): array + { + return [ + 'user_ids' => 'pegawai', + 'type' => 'tipe', + 'description' => 'keterangan', + 'amount' => 'jumlah', + ]; + } + + public function store() + { + $this->validate(); + + $amount = (int) replaceCurrency($this->amount); + + DB::transaction(function () use ($amount) { + foreach ($this->user_ids as $user_id) { + $periodMonth = Carbon::now()->format('Y-m'); + + $payroll = Payroll::where('user_id', $user_id) + ->where('period_month', $periodMonth) + ->first(); + + if (! $payroll) { + throw new \Exception('Penggajian tidak ditemukan.'); + } + + PayrollAdjustment::create([ + 'payroll_id' => $payroll->id, + 'type' => $this->type, + 'description' => $this->description, + 'amount' => $amount, + ]); + + if ($this->type == SalaryAdjustmentType::BONUS->value) { + $payroll->bonus += $amount; + } elseif ($this->type == SalaryAdjustmentType::DEDUCTION->value) { + $payroll->deduction += $amount; + } + + $payroll->total_salary = $payroll->base_salary + $payroll->bonus - $payroll->deduction; + + $payroll->save(); + } + }); + } + + public function update() + { + $this->validate(); + } +} diff --git a/app/Livewire/Studio/Finance/Payroll.php b/app/Livewire/Studio/Finance/Payroll.php new file mode 100644 index 0000000..1d26820 --- /dev/null +++ b/app/Livewire/Studio/Finance/Payroll.php @@ -0,0 +1,115 @@ +loadPayrolls(); + + $this->users = User::whereHas('employee') + ->latest() + ->get() + ->mapWithKeys(fn ($user) => [ + $user->id => $user->employee->full_name, + ]) + ->toArray(); + } + + private function loadPayrolls() + { + $this->payrolls = PayrollModel::with(['user', 'user.employee']) + ->where('period_month', now()->format('Y-m')) + ->get(); + } + + #[On('modal:open')] + public function openModal(string $method, string $modalTitle, ?string $id = null) + { + $this->resetValidation(); + $this->resetErrorBag(); + + $this->method = $method; + $this->modalTitle = $modalTitle; + } + + public function create() + { + $this->canOrAbort('manage adjustment'); + + $this->form->store(); + + $this->loadPayrolls(); + + $this->dispatch('refreshDatatable'); + + $this->toast('Penyesuaian berhasil ditambahkan.'); + + Flux::modals()->close(); + } + + public function delete(PayrollAdjustment $adjustment) + { + $this->canOrAbort('manage adjustment'); + + DB::transaction(function () use ($adjustment) { + $payroll = PayrollModel::find($adjustment->payroll_id); + + if ($adjustment->type->value == SalaryAdjustmentType::BONUS->value) { + $payroll->bonus -= $adjustment->amount; + } elseif ($adjustment->type->value == SalaryAdjustmentType::DEDUCTION->value) { + $payroll->deduction -= $adjustment->amount; + } + + $payroll->total_salary = $payroll->base_salary + $payroll->bonus - $payroll->deduction; + + $payroll->save(); + + $adjustment->delete(); + }); + + $this->loadPayrolls(); + + $this->dispatch('refreshDatatable'); + $this->toast('Penggajian berhasil dihapus.'); + Flux::modals()->close(); + } + + public function render() + { + return view('livewire.studio.finance.payrolls', [ + 'pageTitle' => 'Penggajian', + ]); + } +} diff --git a/app/Models/Payroll.php b/app/Models/Payroll.php new file mode 100644 index 0000000..4df2e9c --- /dev/null +++ b/app/Models/Payroll.php @@ -0,0 +1,42 @@ + 'int', + 'bonus' => 'int', + 'deduction' => 'int', + 'total_salary' => 'int', + 'is_paid' => IsPaid::class, + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function adjustments(): HasMany + { + return $this->hasMany(PayrollAdjustment::class); + } +} diff --git a/app/Models/PayrollAdjustment.php b/app/Models/PayrollAdjustment.php new file mode 100644 index 0000000..2637aec --- /dev/null +++ b/app/Models/PayrollAdjustment.php @@ -0,0 +1,30 @@ + SalaryAdjustmentType::class, + 'amount' => 'int', + ]; + } + + public function payroll(): BelongsTo + { + return $this->belongsTo(Payroll::class); + } +} diff --git a/app/Models/SalaryHistory.php b/app/Models/SalaryHistory.php new file mode 100644 index 0000000..0123065 --- /dev/null +++ b/app/Models/SalaryHistory.php @@ -0,0 +1,29 @@ + 'int', + 'new_salary' => 'int', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index b48ac00..a7c6b56 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,8 @@ use App\Enums\UserStatus; use Dyrynda\Database\Support\CascadeSoftDeletes; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\Attributes\Scope; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -23,7 +25,7 @@ class User extends Authenticatable implements MustVerifyEmail protected $guarded = ['id']; - protected $cascadeDeletes = ['employee', 'membership', 'referralCode', 'outlets']; + protected $cascadeDeletes = ['employee', 'membership', 'referralCode', 'outlets', 'payrolls']; protected function casts(): array { @@ -34,6 +36,12 @@ protected function casts(): array ]; } + #[Scope] + protected function active(Builder $query): void + { + $query->where('status', UserStatus::ACTIVE); + } + public function employee(): HasOne { return $this->hasOne(Employee::class); @@ -68,4 +76,9 @@ public function articles(): HasMany { return $this->hasMany(Article::class); } + + public function payrolls(): HasMany + { + return $this->hasMany(Payroll::class); + } } diff --git a/app/Providers/ViewServiceProvider.php b/app/Providers/ViewServiceProvider.php index c04c620..17f3e34 100644 --- a/app/Providers/ViewServiceProvider.php +++ b/app/Providers/ViewServiceProvider.php @@ -118,6 +118,13 @@ public function boot(): void 'match' => 'studio.finance.expense.*', 'can' => 'view expense', ], + [ + 'label' => 'Penggajian', + 'icon' => 'hand-coins', + 'route' => 'studio.finance.payroll.index', + 'match' => 'studio.finance.payroll.*', + 'can' => 'view payroll', + ], ], ], [ diff --git a/database/migrations/2025_10_04_143515_create_payrolls_table.php b/database/migrations/2025_10_04_143515_create_payrolls_table.php new file mode 100644 index 0000000..2e34fd5 --- /dev/null +++ b/database/migrations/2025_10_04_143515_create_payrolls_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('period_month', 7); + $table->unsignedInteger('base_salary'); + $table->unsignedInteger('bonus'); + $table->unsignedInteger('deduction'); + $table->unsignedInteger('total_salary'); + $table->enum('is_paid', [IsPaid::values()])->default(IsPaid::NOT_PAID)->comment(IsPaid::comment()); + $table->dateTime('paid_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payrolls'); + } +}; diff --git a/database/migrations/2025_10_04_143521_create_payroll_adjustments_table.php b/database/migrations/2025_10_04_143521_create_payroll_adjustments_table.php new file mode 100644 index 0000000..1f71bbb --- /dev/null +++ b/database/migrations/2025_10_04_143521_create_payroll_adjustments_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('payroll_id')->constrained()->cascadeOnDelete(); + $table->enum('type', [SalaryAdjustmentType::values()])->comment(SalaryAdjustmentType::comment()); + $table->string('description', 100); + $table->unsignedInteger('amount'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payroll_adjustments'); + } +}; diff --git a/database/migrations/2025_10_04_143527_create_salary_histories_table.php b/database/migrations/2025_10_04_143527_create_salary_histories_table.php new file mode 100644 index 0000000..7626143 --- /dev/null +++ b/database/migrations/2025_10_04_143527_create_salary_histories_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('old_salary'); + $table->unsignedInteger('new_salary'); + $table->date('effective_date'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('salary_histories'); + } +}; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 6cda09d..4a09e36 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -84,6 +84,9 @@ public function run(): void 'update expense', 'delete expense', + 'view payroll', + 'manage adjustment', + 'view article', 'create article', 'update article', diff --git a/resources/views/livewire/studio/finance/payrolls.blade.php b/resources/views/livewire/studio/finance/payrolls.blade.php new file mode 100644 index 0000000..57609dd --- /dev/null +++ b/resources/views/livewire/studio/finance/payrolls.blade.php @@ -0,0 +1,148 @@ + +
+
+ {{ $pageTitle }} +
+ @if (auth()->user()->can('manage adjustment')) +
+ + + Tambah + + +
+ @endif +
+ +
+
+ @foreach ($payrolls as $payroll) + +
+
+
+ {{ $payroll->user->employee->full_name }} + {{ formatDate($payroll->period_month, 'F Y') }} +
+
+ + {{ $payroll->is_paid->label() }} + +
+ {{ $payroll->paid_at ? formatDateTime($payroll->paid_at) : '-' }} +
+
+
+ +
+
+ Gaji Pokok +
{{ currency($payroll->base_salary, 'Rp') }}
+
+
+ Bonus +
{{ currency($payroll->bonus, 'Rp') }}
+
+
+ Potongan +
{{ currency($payroll->deduction, 'Rp') }}
+
+
+ + + + Rincian + @foreach ($payroll->adjustments->where('payroll_id', $payroll->id) as $item) +
+
+
+ {{ currency($item->amount, 'Rp') }} +
+
{{ $item->description }}
+
+ + + + +
+ @endforeach + + + +
+ Total Gaji +
{{ currency($payroll->total_salary, 'Rp') }}
+
+
+
+ @endforeach +
+ + + +
+ + @include('components.confirmation.delete') + + +
+ {{ $modalTitle }} + + + Pegawai * + + Pilih Semua + + Hapus Semua + + @foreach ($users as $key => $value) + {{ $value }} + + @endforeach + + + + + + Tipe * + + @foreach (\App\Enums\SalaryAdjustmentType::cases() as $type) + + {{ $type->label() }} + + @endforeach + + + + + + Jumlah * + + + Rp + + + + + + + Keterangan * + + + + +
+ + + Simpan + +
+
+
+
diff --git a/routes/pages/studio.php b/routes/pages/studio.php index 633104b..61e859c 100644 --- a/routes/pages/studio.php +++ b/routes/pages/studio.php @@ -13,6 +13,7 @@ use App\Livewire\Studio\Catalog\Product\Index as ProductIndex; use App\Livewire\Studio\Dashboard\Overview as OverviewComponent; use App\Livewire\Studio\Finance\Expense as ExpenseComponent; +use App\Livewire\Studio\Finance\Payroll as PayrollComponent; use App\Livewire\Studio\Loyalty\Customer as CustomerComponent; use App\Livewire\Studio\Loyalty\Tier as TierComponent; use App\Livewire\Studio\Loyalty\Voucher\Create as VoucherCreate; @@ -116,7 +117,14 @@ Route::prefix('finance')->name('studio.finance.')->group(function () { Route::prefix('expenses')->name('expense.')->group(function () { Route::get('/', ExpenseComponent::class)->name('index')->middleware('can:view expense'); - Route::delete('/{expense}/delete', ExpenseComponent::class)->name('delete')->middleware(); + Route::delete('/{expense}/delete', ExpenseComponent::class)->name('delete')->middleware('can:delete expense'); + }); + }); + + Route::prefix('finance')->name('studio.finance.')->group(function () { + Route::prefix('payrolls')->name('payroll.')->group(function () { + Route::get('/', PayrollComponent::class)->name('index')->middleware('can:view payroll'); + Route::delete('/{payroll}/delete', PayrollComponent::class)->name('delete')->middleware('can:delete payroll'); }); });