diff --git a/app/Console/Commands/GenerateMonthlyPayroll.php b/app/Console/Commands/GenerateMonthlyPayroll.php new file mode 100644 index 0000000..02fbbf1 --- /dev/null +++ b/app/Console/Commands/GenerateMonthlyPayroll.php @@ -0,0 +1,75 @@ +argument('month') ?? now()->format('Y-m'); + $prevMonth = now()->parse($month.'-01')->subMonth()->format('Y-m'); + + $payrollsToPay = Payroll::where('period_month', $prevMonth) + ->paid() + ->get(); + + foreach ($payrollsToPay as $payroll) { + $payroll->update([ + 'is_paid' => IsPaid::PAID, + ]); + } + + $users = User::role(RoleEnum::ADMINISTRATOR->value)->get(); + + $count = 0; + foreach ($users as $user) { + $exists = Payroll::where('user_id', $user->id) + ->where('period_month', $month) + ->exists(); + + if (! $exists) { + $baseSalary = $user->base_salary; + + Payroll::create([ + 'user_id' => $user->id, + 'period_month' => $month, + 'base_salary' => $baseSalary, + 'bonus' => 0, + 'deduction' => 0, + 'total_salary' => $baseSalary, + 'is_paid' => IsPaid::NOT_PAID, + ]); + $count++; + } + } + + if ($payrollsToPay->count() > 0) { + $this->info("Berhasil memproses pembayaran untuk {$payrollsToPay->count()} data payroll bulan {$prevMonth}."); + } + + $this->info("Berhasil membuat {$count} data payroll baru untuk bulan {$month}."); + } +} diff --git a/app/Enums/IsPaid.php b/app/Enums/IsPaid.php new file mode 100644 index 0000000..c4e15e2 --- /dev/null +++ b/app/Enums/IsPaid.php @@ -0,0 +1,31 @@ +value; + } + + public function isPaid(self $status): bool + { + return $this === $status; + } + + public static function options(): array + { + return collect(self::cases()) + ->mapWithKeys(fn ($case) => [$case->value => $case->getLabel()]) + ->toArray(); + } +} diff --git a/app/Enums/SalaryAdjustmentType.php b/app/Enums/SalaryAdjustmentType.php new file mode 100644 index 0000000..fcd1b8c --- /dev/null +++ b/app/Enums/SalaryAdjustmentType.php @@ -0,0 +1,26 @@ +value; + } + + public static function options(): array + { + return collect(self::cases()) + ->mapWithKeys(fn ($case) => [$case->value => $case->getLabel()]) + ->toArray(); + } +} diff --git a/app/Filament/Resources/Finance/Payrolls/Actions/AddAdjustmentAction.php b/app/Filament/Resources/Finance/Payrolls/Actions/AddAdjustmentAction.php new file mode 100644 index 0000000..d749317 --- /dev/null +++ b/app/Filament/Resources/Finance/Payrolls/Actions/AddAdjustmentAction.php @@ -0,0 +1,72 @@ +label('Tambah Penyesuaian') + ->color('gray') + ->button() + ->outlined() + ->modalWidth(Width::Large) + ->extraAttributes([ + 'class' => 'w-full flex justify-center', + ]) + ->schema([ + Hidden::make('payroll_id'), + + Radio::make('type') + ->label('Tipe') + ->options(SalaryAdjustmentType::options()) + ->required() + ->inline(), + + TextInput::make('description') + ->label('Keterangan') + ->placeholder('Bonus lembur / Potongan kasbon') + ->required() + ->maxLength(100) + ->autofocus() + ->autocomplete(false), + + TextInput::make('amount') + ->label('Nominal') + ->placeholder('0') + ->required() + ->autocomplete(false) + ->currencyMask(thousandSeparator: '.', decimalSeparator: ',', precision: 0) + ->prefix('Rp') + ->dehydrateStateUsing(fn ($state) => (int) str()->replace('.', '', (string) ($state ?? 0))), + ]) + ->action(function (array $data) { + $payroll = Payroll::find($data['payroll_id']); + + if ($payroll) { + $payroll->adjustments()->create([ + 'type' => $data['type'], + 'description' => $data['description'], + 'amount' => $data['amount'], + ]); + } + }) + ->successNotification(SystemNotification::create()); + } +} diff --git a/app/Filament/Resources/Finance/Payrolls/Actions/DeleteAdjustmentAction.php b/app/Filament/Resources/Finance/Payrolls/Actions/DeleteAdjustmentAction.php new file mode 100644 index 0000000..82288b8 --- /dev/null +++ b/app/Filament/Resources/Finance/Payrolls/Actions/DeleteAdjustmentAction.php @@ -0,0 +1,31 @@ +label('Hapus') + ->icon('heroicon-m-trash') + ->iconButton() + ->color('danger') + ->requiresConfirmation() + ->action(function (array $arguments) { + $adjustment = PayrollAdjustment::findOrFail($arguments['adjustment_id']); + $adjustment->delete(); + }) + ->successNotification(SystemNotification::delete()); + } +} diff --git a/app/Filament/Resources/Finance/Payrolls/Actions/EditAdjustmentAction.php b/app/Filament/Resources/Finance/Payrolls/Actions/EditAdjustmentAction.php new file mode 100644 index 0000000..49e0f12 --- /dev/null +++ b/app/Filament/Resources/Finance/Payrolls/Actions/EditAdjustmentAction.php @@ -0,0 +1,73 @@ +label('Ubah') + ->icon('heroicon-m-pencil-square') + ->iconButton() + ->color('warning') + ->modalWidth(Width::Large) + ->schema([ + Radio::make('type') + ->label('Tipe') + ->options(SalaryAdjustmentType::options()) + ->required() + ->inline(), + + TextInput::make('description') + ->label('Keterangan') + ->placeholder('Bonus lembur / Potongan kasbon') + ->required() + ->maxLength(100) + ->autofocus() + ->autocomplete(false), + + TextInput::make('amount') + ->label('Nominal') + ->placeholder('0') + ->required() + ->autocomplete(false) + ->currencyMask(thousandSeparator: '.', decimalSeparator: ',', precision: 0) + ->prefix('Rp') + ->dehydrateStateUsing(fn ($state) => (int) str()->replace('.', '', (string) ($state ?? 0))), + ]) + ->mountUsing(function ($form, $arguments) { + $adjustment = PayrollAdjustment::findOrFail($arguments['adjustment_id']); + + $form->fill([ + 'type' => $adjustment->type->value, + 'description' => $adjustment->description, + 'amount' => $adjustment->amount, + ]); + }) + ->action(function (array $data, array $arguments) { + $adjustment = PayrollAdjustment::findOrFail($arguments['adjustment_id']); + + $adjustment->update([ + 'type' => $data['type'], + 'description' => $data['description'], + 'amount' => $data['amount'], + ]); + }) + ->successNotification(SystemNotification::update()); + } +} diff --git a/app/Filament/Resources/Finance/Payrolls/Pages/ManagePayrolls.php b/app/Filament/Resources/Finance/Payrolls/Pages/ManagePayrolls.php new file mode 100644 index 0000000..aa6fe40 --- /dev/null +++ b/app/Filament/Resources/Finance/Payrolls/Pages/ManagePayrolls.php @@ -0,0 +1,52 @@ + $this->getResource()::getModel()::with(['user', 'adjustments']) + ->where('period_month', now()->format('Y-m')) + ->notPaid() + ->get(), + ]; + } + + public function addAdjustmentAction(): Action + { + return AddAdjustmentAction::make() + ->mountUsing(function ($form, $arguments) { + + $form->fill([ + 'payroll_id' => $arguments['payroll_id'], + 'type' => SalaryAdjustmentType::BONUS->value, + ]); + }); + } + + public function editAdjustmentAction(): Action + { + return EditAdjustmentAction::make(); + } + + public function deleteAdjustmentAction(): Action + { + return DeleteAdjustmentAction::make(); + } +} diff --git a/app/Filament/Resources/Finance/Payrolls/PayrollResource.php b/app/Filament/Resources/Finance/Payrolls/PayrollResource.php new file mode 100644 index 0000000..0703b65 --- /dev/null +++ b/app/Filament/Resources/Finance/Payrolls/PayrollResource.php @@ -0,0 +1,125 @@ +columns([ + TextColumn::make('user.name') + ->label('Karyawan') + ->searchable() + ->sortable(), + + TextColumn::make('period_month') + ->label('Periode') + ->sortable() + ->formatStateUsing(fn ($state) => Carbon::parse($state)->translatedFormat('F Y')), + + TextColumn::make('total_salary') + ->label('Total Gaji') + ->money('IDR', decimalPlaces: 0) + ->sortable(), + + TextColumn::make('is_paid') + ->label('Status') + ->badge() + ->color(fn (IsPaid $state): string => match ($state) { + IsPaid::PAID => 'success', + IsPaid::NOT_PAID => 'danger', + default => 'gray', + }), + + TextColumn::make('paid_at') + ->label('Tanggal Bayar') + ->dateTime('l, d F Y') + ->sortable() + ->toggleable(), + + ...TimestampColumns::make(), + ]) + ->filters([ + TrashedFilter::make() + ->native(false) + ->visible(fn (): bool => auth()->user() && auth()->user()->hasRole(RoleEnum::DEVELOPER)), + ]) + ->recordActions([ + EditAction::make() + ->modalWidth(Width::Large), + + DeleteAction::make(), + + ForceDeleteAction::make(), + + RestoreAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + ...DefaultBulkActions::make('Penggajian'), + ]), + ]) + ->emptyStateIcon(Heroicon::Banknotes) + ->emptyStateDescription('Setelah Anda membuat data pertama, maka akan muncul disini.') + ->defaultSort('created_at', 'desc') + ->deferFilters(false); + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery()->paid(); + } + + public static function getPages(): array + { + return [ + 'index' => ManagePayrolls::route('/'), + ]; + } + + public static function getRecordRouteBindingEloquentQuery(): Builder + { + return parent::getRecordRouteBindingEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } +} diff --git a/app/Filament/Resources/Master/Users/UserResource.php b/app/Filament/Resources/Master/Users/UserResource.php index 96598e2..e9f0904 100644 --- a/app/Filament/Resources/Master/Users/UserResource.php +++ b/app/Filament/Resources/Master/Users/UserResource.php @@ -86,6 +86,13 @@ public static function form(Schema $schema): Schema ->preload() ->searchable(), + TextInput::make('base_salary') + ->label('Gaji Pokok') + ->numeric() + ->prefix('Rp') + ->currencyMask(thousandSeparator: '.', decimalSeparator: ',', precision: 0) + ->required(), + Hidden::make('password') ->default(fn (): string => config('auth.password_default')) ->visibleOn('create'), @@ -130,6 +137,11 @@ public static function table(Table $table): Table ->badge() ->getStateUsing(fn (User $record): array => $record->roles->pluck('name', 'id')->toArray()), + TextColumn::make('base_salary') + ->label('Gaji Pokok') + ->money('IDR', decimalPlaces: 0) + ->sortable(), + ...TimestampColumns::make(), ]) ->filters([ diff --git a/app/Models/Payroll.php b/app/Models/Payroll.php new file mode 100644 index 0000000..bade292 --- /dev/null +++ b/app/Models/Payroll.php @@ -0,0 +1,101 @@ + */ + use HasFactory, SoftDeletes; + + protected $guarded = ['id']; + + protected function casts(): array + { + return [ + 'is_paid' => IsPaid::class, + 'paid_at' => 'datetime', + 'base_salary' => 'integer', + 'bonus' => 'integer', + 'deduction' => 'integer', + 'total_salary' => 'integer', + ]; + } + + #[Scope] + protected function paid(Builder $query): void + { + $query->where('is_paid', IsPaid::PAID->value); + } + + #[Scope] + protected function notPaid(Builder $query): void + { + $query->where('is_paid', IsPaid::NOT_PAID->value); + } + + protected function periodLabel(): Attribute + { + return Attribute::make( + get: fn () => Carbon::parse($this->period_month)->translatedFormat('F Y'), + ); + } + + protected function isPaidLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->is_paid->value === IsPaid::PAID->value ? IsPaid::PAID->value : IsPaid::NOT_PAID->value, + ); + } + + protected function baseSalaryFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->base_salary, 0, ',', '.'), + ); + } + + protected function bonusFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->bonus, 0, ',', '.'), + ); + } + + protected function deductionFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->deduction, 0, ',', '.'), + ); + } + + protected function totalSalaryFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->total_salary, 0, ',', '.'), + ); + } + + 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..136c0d1 --- /dev/null +++ b/app/Models/PayrollAdjustment.php @@ -0,0 +1,41 @@ + */ + use HasFactory, SoftDeletes; + + protected $guarded = ['id']; + + protected function casts(): array + { + return [ + 'type' => SalaryAdjustmentType::class, + 'amount' => 'integer', + ]; + } + + protected function amountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'), + ); + } + + public function payroll(): BelongsTo + { + return $this->belongsTo(Payroll::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 3874072..dc45469 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -28,6 +29,7 @@ protected function casts(): array { return [ 'password' => 'hashed', + 'base_salary' => 'integer', ]; } @@ -43,4 +45,9 @@ public function canAccessPanel(Panel $panel): bool { return $this->hasRole([RoleEnum::DEVELOPER, RoleEnum::OWNER, RoleEnum::ADMINISTRATOR]); } + + public function payrolls(): HasMany + { + return $this->hasMany(Payroll::class); + } } diff --git a/app/Observers/PayrollAdjustmentObserver.php b/app/Observers/PayrollAdjustmentObserver.php new file mode 100644 index 0000000..f4905cc --- /dev/null +++ b/app/Observers/PayrollAdjustmentObserver.php @@ -0,0 +1,40 @@ +updatePayroll($adjustment); + } + + public function deleted(PayrollAdjustment $adjustment): void + { + $this->updatePayroll($adjustment); + } + + public function restored(PayrollAdjustment $adjustment): void + { + $this->updatePayroll($adjustment); + } + + protected function updatePayroll(PayrollAdjustment $adjustment): void + { + $payroll = $adjustment->payroll; + if (! $payroll) { + return; + } + + $bonus = $payroll->adjustments()->where('type', SalaryAdjustmentType::BONUS)->sum('amount'); + $deduction = $payroll->adjustments()->where('type', SalaryAdjustmentType::DEDUCTION)->sum('amount'); + + $payroll->update([ + 'bonus' => $bonus, + 'deduction' => $deduction, + ]); + } +} diff --git a/app/Observers/PayrollObserver.php b/app/Observers/PayrollObserver.php new file mode 100644 index 0000000..5710c28 --- /dev/null +++ b/app/Observers/PayrollObserver.php @@ -0,0 +1,22 @@ +total_salary = $payroll->base_salary + $payroll->bonus - $payroll->deduction; + + if ($payroll->is_paid?->isPaid(IsPaid::PAID) && ! $payroll->paid_at) { + $payroll->paid_at = now(); + } + + if ($payroll->is_paid?->isPaid(IsPaid::NOT_PAID)) { + $payroll->paid_at = null; + } + } +} diff --git a/database/factories/PayrollAdjustmentFactory.php b/database/factories/PayrollAdjustmentFactory.php new file mode 100644 index 0000000..5ffa1e9 --- /dev/null +++ b/database/factories/PayrollAdjustmentFactory.php @@ -0,0 +1,28 @@ + + */ +class PayrollAdjustmentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'payroll_id' => Payroll::factory(), + 'type' => fake()->randomElement(SalaryAdjustmentType::cases()), + 'description' => fake()->sentence(3), + 'amount' => fake()->numberBetween(10000, 100000), + ]; + } +} diff --git a/database/factories/PayrollFactory.php b/database/factories/PayrollFactory.php new file mode 100644 index 0000000..f300e2a --- /dev/null +++ b/database/factories/PayrollFactory.php @@ -0,0 +1,36 @@ + + */ +class PayrollFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $base = fake()->numberBetween(1000000, 5000000); + $bonus = fake()->numberBetween(0, 500000); + $deduction = fake()->numberBetween(0, 200000); + + return [ + 'user_id' => User::factory(), + 'period_month' => now()->format('Y-m'), + 'base_salary' => $base, + 'bonus' => $bonus, + 'deduction' => $deduction, + 'total_salary' => $base + $bonus - $deduction, + 'is_paid' => fake()->randomElement(IsPaid::cases()), + 'paid_at' => fake()->optional()->dateTime(), + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 584104c..6a5ff24 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -28,6 +28,7 @@ public function definition(): array 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), + 'base_salary' => fake()->numberBetween(10, 50) * 100000, 'remember_token' => Str::random(10), ]; } diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 00a7cf4..f072b23 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -17,6 +17,7 @@ public function up(): void $table->string('email', 254)->unique(); $table->string('username', 10)->unique(); $table->string('password', 60); + $table->unsignedInteger('base_salary')->default(0); $table->timestamp('created_at')->useCurrent(); $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); $table->softDeletes(); diff --git a/database/migrations/2026_03_06_095314_create_payrolls_table.php b/database/migrations/2026_03_06_095314_create_payrolls_table.php new file mode 100644 index 0000000..2f30585 --- /dev/null +++ b/database/migrations/2026_03_06_095314_create_payrolls_table.php @@ -0,0 +1,38 @@ +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->value)->comment(IsPaid::comment()); + $table->dateTime('paid_at')->nullable(); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payrolls'); + } +}; diff --git a/database/migrations/2026_03_06_095315_create_payroll_adjustments_table.php b/database/migrations/2026_03_06_095315_create_payroll_adjustments_table.php new file mode 100644 index 0000000..e44a85f --- /dev/null +++ b/database/migrations/2026_03_06_095315_create_payroll_adjustments_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('payroll_id')->constrained()->cascadeOnDelete(); + $table->enum('type', SalaryAdjustmentType::values())->comment(SalaryAdjustmentType::comment()); + $table->string('description', 100); + $table->unsignedInteger('amount'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payroll_adjustments'); + } +}; diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index f96fc93..c89a458 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -38,6 +38,7 @@ public function run(): void 'email' => 'admin@gmail.com', 'username' => 'admin', 'password' => Hash::make(config('auth.password_default')), + 'base_salary' => 2000000, ]); $administrator->assignRole(RoleEnum::ADMINISTRATOR); } diff --git a/resources/views/filament/resources/finance/payrolls/list-payrolls.blade.php b/resources/views/filament/resources/finance/payrolls/list-payrolls.blade.php new file mode 100644 index 0000000..d808c15 --- /dev/null +++ b/resources/views/filament/resources/finance/payrolls/list-payrolls.blade.php @@ -0,0 +1,107 @@ + + +
+ @forelse ($currentMonthPayrolls as $payroll) +
+
+
+
+

{{ $payroll->user->name }}

+
+
+
+ {{ $payroll->period_label }}
+
+ +
+
+ Gaji Pokok + {{ $payroll->base_salary_formatted }} +
+ +
+ + +
+ @forelse($payroll->adjustments as $adj) +
+ + {{ $adj->description }} + + +
+ $adj->type === \App\Enums\SalaryAdjustmentType::BONUS, + 'text-rose-600' => + $adj->type === \App\Enums\SalaryAdjustmentType::DEDUCTION, + ])> + {{ $adj->type === \App\Enums\SalaryAdjustmentType::BONUS ? '+' : '-' }} + {{ $adj->amount_formatted }} + + +
+ {{ $this->editAdjustmentAction()->arguments([ + 'adjustment_id' => $adj->id, + ]) }} + + {{ $this->deleteAdjustmentAction()->arguments([ + 'adjustment_id' => $adj->id, + ]) }} +
+
+
+ @empty +
Tidak ada rincian
+ @endforelse +
+ +
+
+ Total + Diterima + + {{ $payroll->total_salary_formatted }} + +
+
+ {{ $payroll->is_paid_label }} +
+
+
+ +
+ {{ $this->addAdjustmentAction()->arguments([ + 'payroll_id' => $payroll->id, + ]) }} +
+
+
+ @empty +
+

Data gaji {{ now()->translatedFormat('F Y') }} belum tersedia.

+
+ @endforelse +
+ + {{ $this->content }} + + +
diff --git a/routes/console.php b/routes/console.php index d17ffec..aac68d0 100644 --- a/routes/console.php +++ b/routes/console.php @@ -14,3 +14,5 @@ Schedule::command('feed:schedule-process', [$formattedTime])->at($formattedTime); } } + +Schedule::command('payroll:generate')->monthlyOn(1, '00:00');