feat: Implement payroll management system with monthly generation, adjustments, and Filament UI.

This commit is contained in:
Yoga Pangestu 2026-03-08 23:22:33 +07:00
parent a499075a5d
commit 515526b478
23 changed files with 956 additions and 0 deletions

View File

@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Enums\IsPaid;
use App\Enums\RoleEnum;
use App\Models\Payroll;
use App\Models\User;
use Illuminate\Console\Command;
class GenerateMonthlyPayroll extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'payroll:generate {month?}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate monthly payroll for administrators';
/**
* Execute the console command.
*/
public function handle()
{
$month = $this->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}.");
}
}

31
app/Enums/IsPaid.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace App\Enums;
use App\Traits\Enum\WithComment;
use App\Traits\Enum\WithValue;
enum IsPaid: string
{
use WithComment, WithValue;
case PAID = 'Sudah Dibayar';
case NOT_PAID = 'Belum Dibayar';
public function getLabel(): string
{
return $this->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();
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Enums;
use App\Traits\Enum\WithComment;
use App\Traits\Enum\WithValue;
enum SalaryAdjustmentType: string
{
use WithComment, WithValue;
case BONUS = 'Bonus';
case DEDUCTION = 'Potongan';
public function getLabel(): string
{
return $this->value;
}
public static function options(): array
{
return collect(self::cases())
->mapWithKeys(fn ($case) => [$case->value => $case->getLabel()])
->toArray();
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Filament\Resources\Finance\Payrolls\Actions;
use App\Enums\SalaryAdjustmentType;
use App\Filament\Support\SystemNotification;
use App\Models\Payroll;
use Filament\Actions\Action;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\TextInput;
use Filament\Support\Enums\Width;
class AddAdjustmentAction extends Action
{
public static function getDefaultName(): ?string
{
return 'addAdjustment';
}
protected function setUp(): void
{
parent::setUp();
$this->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());
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Filament\Resources\Finance\Payrolls\Actions;
use App\Filament\Support\SystemNotification;
use App\Models\PayrollAdjustment;
use Filament\Actions\Action;
class DeleteAdjustmentAction extends Action
{
public static function getDefaultName(): ?string
{
return 'deleteAdjustment';
}
protected function setUp(): void
{
parent::setUp();
$this->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());
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Filament\Resources\Finance\Payrolls\Actions;
use App\Enums\SalaryAdjustmentType;
use App\Filament\Support\SystemNotification;
use App\Models\PayrollAdjustment;
use Filament\Actions\Action;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\TextInput;
use Filament\Support\Enums\Width;
class EditAdjustmentAction extends Action
{
public static function getDefaultName(): ?string
{
return 'editAdjustment';
}
protected function setUp(): void
{
parent::setUp();
$this->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());
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Filament\Resources\Finance\Payrolls\Pages;
use App\Enums\SalaryAdjustmentType;
use App\Filament\Resources\Finance\Payrolls\Actions\AddAdjustmentAction;
use App\Filament\Resources\Finance\Payrolls\Actions\DeleteAdjustmentAction;
use App\Filament\Resources\Finance\Payrolls\Actions\EditAdjustmentAction;
use App\Filament\Resources\Finance\Payrolls\PayrollResource;
use Filament\Actions\Action;
use Filament\Resources\Pages\ManageRecords;
class ManagePayrolls extends ManageRecords
{
protected static string $resource = PayrollResource::class;
protected static ?string $title = 'Penggajian';
protected string $view = 'filament.resources.finance.payrolls.list-payrolls';
protected function getViewData(): array
{
return [
'currentMonthPayrolls' => $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();
}
}

View File

@ -0,0 +1,125 @@
<?php
namespace App\Filament\Resources\Finance\Payrolls;
use App\Enums\IsPaid;
use App\Enums\RoleEnum;
use App\Filament\Actions\Cheerful\DeleteAction;
use App\Filament\Actions\Cheerful\EditAction;
use App\Filament\Actions\Cheerful\ForceDeleteAction;
use App\Filament\Actions\Cheerful\RestoreAction;
use App\Filament\Actions\DefaultBulkActions;
use App\Filament\Columns\TimestampColumns;
use App\Filament\Resources\Finance\Payrolls\Pages\ManagePayrolls;
use App\Models\Payroll;
use BackedEnum;
use Carbon\Carbon;
use Filament\Actions\BulkActionGroup;
use Filament\Resources\Resource;
use Filament\Support\Enums\Width;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use UnitEnum;
class PayrollResource extends Resource
{
protected static ?string $model = Payroll::class;
protected static string|UnitEnum|null $navigationGroup = 'Keuangan';
protected static string|BackedEnum|null $navigationIcon = Heroicon::Banknotes;
protected static ?string $navigationLabel = 'Penggajian';
protected static ?int $navigationSort = 2;
protected static ?string $recordTitleAttribute = 'period_month';
protected static ?string $slug = 'finance/payrolls';
public static function table(Table $table): Table
{
return $table
->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,
]);
}
}

View File

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

101
app/Models/Payroll.php Normal file
View File

@ -0,0 +1,101 @@
<?php
namespace App\Models;
use App\Enums\IsPaid;
use App\Observers\PayrollObserver;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[ObservedBy([PayrollObserver::class])]
class Payroll extends Model
{
/** @use HasFactory<\Database\Factories\PayrollFactory> */
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);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use App\Enums\SalaryAdjustmentType;
use App\Observers\PayrollAdjustmentObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[ObservedBy([PayrollAdjustmentObserver::class])]
class PayrollAdjustment extends Model
{
/** @use HasFactory<\Database\Factories\PayrollAdjustmentFactory> */
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);
}
}

View File

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

View File

@ -0,0 +1,40 @@
<?php
namespace App\Observers;
use App\Enums\SalaryAdjustmentType;
use App\Models\PayrollAdjustment;
class PayrollAdjustmentObserver
{
public function saved(PayrollAdjustment $adjustment): void
{
$this->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,
]);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Observers;
use App\Enums\IsPaid;
use App\Models\Payroll;
class PayrollObserver
{
public function saving(Payroll $payroll): void
{
$payroll->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;
}
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace Database\Factories;
use App\Enums\SalaryAdjustmentType;
use App\Models\Payroll;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PayrollAdjustment>
*/
class PayrollAdjustmentFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'payroll_id' => Payroll::factory(),
'type' => fake()->randomElement(SalaryAdjustmentType::cases()),
'description' => fake()->sentence(3),
'amount' => fake()->numberBetween(10000, 100000),
];
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Enums\IsPaid;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Payroll>
*/
class PayrollFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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(),
];
}
}

View File

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

View File

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

View File

@ -0,0 +1,38 @@
<?php
use App\Enums\IsPaid;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('payrolls', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,34 @@
<?php
use App\Enums\SalaryAdjustmentType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('payroll_adjustments', function (Blueprint $table) {
$table->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');
}
};

View File

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

View File

@ -0,0 +1,107 @@
<x-filament-panels::page>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@forelse ($currentMonthPayrolls as $payroll)
<div x-data="{ showAdjustments: false }"
class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm overflow-hidden">
<div class="p-5 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between">
<div class="flex items-center gap-3">
<div>
<h3 class="text-sm font-bold">{{ $payroll->user->name }}</h3>
</div>
</div>
<div>
{{ $payroll->period_label }}</div>
</div>
<div class="p-5 space-y-4">
<div class="flex justify-between text-xs">
<span class="text-gray-500">Gaji Pokok</span>
<span
class="font-bold text-gray-900 dark:text-gray-100">{{ $payroll->base_salary_formatted }}</span>
</div>
<div class="space-y-2">
<button @click="showAdjustments = !showAdjustments"
class="w-full flex justify-between text-xs focus:outline-none hover:bg-gray-50 dark:hover:bg-gray-800 p-1 rounded">
<span class="text-gray-500 flex items-center gap-1">
<x-heroicon-m-chevron-right class="w-3 h-3 transition"
x-bind:class="showAdjustments ? 'rotate-90' : ''" />
Penyesuaian
</span>
<div class="flex gap-2 font-bold">
<span class="text-emerald-600">+{{ $payroll->bonus_formatted }}</span>
<span class="text-rose-600">-{{ $payroll->deduction_formatted }}</span>
</div>
</button>
<div x-show="showAdjustments"
class="pl-4 space-y-1 mt-2 border-l border-gray-100 dark:border-gray-800">
@forelse($payroll->adjustments as $adj)
<div class="flex justify-between items-center text-[11px]">
<span class="text-gray-600 dark:text-gray-400">
{{ $adj->description }}
</span>
<div class="flex items-center gap-2">
<span @class([
'font-bold',
'text-emerald-600' => $adj->type === \App\Enums\SalaryAdjustmentType::BONUS,
'text-rose-600' =>
$adj->type === \App\Enums\SalaryAdjustmentType::DEDUCTION,
])>
{{ $adj->type === \App\Enums\SalaryAdjustmentType::BONUS ? '+' : '-' }}
{{ $adj->amount_formatted }}
</span>
<div class="flex items-center gap-1">
{{ $this->editAdjustmentAction()->arguments([
'adjustment_id' => $adj->id,
]) }}
{{ $this->deleteAdjustmentAction()->arguments([
'adjustment_id' => $adj->id,
]) }}
</div>
</div>
</div>
@empty
<div class="text-[10px] text-gray-400 italic">Tidak ada rincian</div>
@endforelse
</div>
<div
class="pt-4 border-t border-gray-100 dark:border-gray-800 flex justify-between items-center">
<div class="flex flex-col">
<span class="text-[9px] font-bold text-gray-400 uppercase tracking-widest">Total
Diterima</span>
<span class="text-lg font-black text-gray-900 dark:text-white">
{{ $payroll->total_salary_formatted }}
</span>
</div>
<div
class="px-2 py-0.5 rounded text-red-500 text-[10px] font-bold border border-red-500 dark:border-red-800">
{{ $payroll->is_paid_label }}
</div>
</div>
</div>
<div class="px-5 pb-5">
{{ $this->addAdjustmentAction()->arguments([
'payroll_id' => $payroll->id,
]) }}
</div>
</div>
</div>
@empty
<div
class="col-span-full p-12 text-center border-2 border-dashed border-gray-200 dark:border-gray-800 rounded-xl">
<p class="text-gray-500">Data gaji {{ now()->translatedFormat('F Y') }} belum tersedia.</p>
</div>
@endforelse
</div>
{{ $this->content }}
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@ -14,3 +14,5 @@
Schedule::command('feed:schedule-process', [$formattedTime])->at($formattedTime);
}
}
Schedule::command('payroll:generate')->monthlyOn(1, '00:00');