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