feat: Implement payroll management system with monthly generation, adjustments, and Filament UI.
This commit is contained in:
parent
a499075a5d
commit
515526b478
75
app/Console/Commands/GenerateMonthlyPayroll.php
Normal file
75
app/Console/Commands/GenerateMonthlyPayroll.php
Normal 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
31
app/Enums/IsPaid.php
Normal 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();
|
||||
}
|
||||
}
|
||||
26
app/Enums/SalaryAdjustmentType.php
Normal file
26
app/Enums/SalaryAdjustmentType.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
125
app/Filament/Resources/Finance/Payrolls/PayrollResource.php
Normal file
125
app/Filament/Resources/Finance/Payrolls/PayrollResource.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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
101
app/Models/Payroll.php
Normal 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);
|
||||
}
|
||||
}
|
||||
41
app/Models/PayrollAdjustment.php
Normal file
41
app/Models/PayrollAdjustment.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
40
app/Observers/PayrollAdjustmentObserver.php
Normal file
40
app/Observers/PayrollAdjustmentObserver.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
22
app/Observers/PayrollObserver.php
Normal file
22
app/Observers/PayrollObserver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
database/factories/PayrollAdjustmentFactory.php
Normal file
28
database/factories/PayrollAdjustmentFactory.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
36
database/factories/PayrollFactory.php
Normal file
36
database/factories/PayrollFactory.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -14,3 +14,5 @@
|
||||
Schedule::command('feed:schedule-process', [$formattedTime])->at($formattedTime);
|
||||
}
|
||||
}
|
||||
|
||||
Schedule::command('payroll:generate')->monthlyOn(1, '00:00');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user