feat(payroll): membuat fitur penggajian otomatis
-membuat command untuk generate dan close payroll -membuat enum IsPaid dan SalaryAdjustmentType -membuat skema db
This commit is contained in:
parent
8bfefa4f2d
commit
8d5fdc9801
43
app/Console/Commands/ClosePayrolls.php
Normal file
43
app/Console/Commands/ClosePayrolls.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\IsPaid;
|
||||
use App\Models\Payroll;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ClosePayrolls extends Command
|
||||
{
|
||||
protected $signature = 'payroll:close {--current} {--month= : Target month in Y-m format}';
|
||||
|
||||
protected $description = 'Mark payrolls as paid for the current or specified month. Default: current month.';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$targetMonth = $this->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}");
|
||||
}
|
||||
}
|
||||
53
app/Console/Commands/GeneratePayrolls.php
Normal file
53
app/Console/Commands/GeneratePayrolls.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\IsPaid;
|
||||
use App\Models\Payroll;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class GeneratePayrolls extends Command
|
||||
{
|
||||
protected $signature = 'payroll:generate {--current} {--next}';
|
||||
|
||||
protected $description = 'Generate payroll data for the current or next month. Default: next month.';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$useNextMonth = ! $this->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}");
|
||||
}
|
||||
}
|
||||
30
app/Enums/IsPaid.php
Normal file
30
app/Enums/IsPaid.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Traits\WithCommentEnum;
|
||||
use App\Traits\WithValueEnum;
|
||||
|
||||
enum IsPaid: int
|
||||
{
|
||||
use WithCommentEnum, WithValueEnum;
|
||||
|
||||
case PAID = 1;
|
||||
case NOT_PAID = 2;
|
||||
|
||||
public function label()
|
||||
{
|
||||
return match ($this) {
|
||||
self::PAID => 'Dibayar',
|
||||
self::NOT_PAID => 'Belum Dibayar',
|
||||
};
|
||||
}
|
||||
|
||||
public function color()
|
||||
{
|
||||
return match ($this) {
|
||||
self::PAID => 'emerald',
|
||||
self::NOT_PAID => 'red',
|
||||
};
|
||||
}
|
||||
}
|
||||
30
app/Enums/SalaryAdjustmentType.php
Normal file
30
app/Enums/SalaryAdjustmentType.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Traits\WithCommentEnum;
|
||||
use App\Traits\WithValueEnum;
|
||||
|
||||
enum SalaryAdjustmentType: int
|
||||
{
|
||||
use WithCommentEnum, WithValueEnum;
|
||||
|
||||
case BONUS = 1;
|
||||
case DEDUCTION = 2;
|
||||
|
||||
public function label()
|
||||
{
|
||||
return match ($this) {
|
||||
self::BONUS => 'Bonus',
|
||||
self::DEDUCTION => 'Potongan',
|
||||
};
|
||||
}
|
||||
|
||||
public function color()
|
||||
{
|
||||
return match ($this) {
|
||||
self::BONUS => 'emerald',
|
||||
self::DEDUCTION => 'red',
|
||||
};
|
||||
}
|
||||
}
|
||||
91
app/Livewire/Datatable/Studio/Finance/PayrollsTable.php
Normal file
91
app/Livewire/Datatable/Studio/Finance/PayrollsTable.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Datatable\Studio\Finance;
|
||||
|
||||
use App\Enums\SalaryAdjustmentType;
|
||||
use App\Models\Payroll;
|
||||
use App\Traits\Datatable\WithConfiguration;
|
||||
use App\Traits\Datatable\WithPrependColumn;
|
||||
use App\Traits\WithMediaHandler;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||
use Rappasoft\LaravelLivewireTables\Views\Columns\ArrayColumn;
|
||||
|
||||
class PayrollsTable extends DataTableComponent
|
||||
{
|
||||
use WithConfiguration, WithMediaHandler, WithPrependColumn;
|
||||
|
||||
protected $model = Payroll::class;
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
Column::make('Pegawai', 'user.employee.full_name')->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('
|
||||
<div class="flex justify-between items-start text-[13px] leading-tight mb-1">
|
||||
<div>
|
||||
<div class="font-bold {{ $value[\'color\'] }}">{{ $value[\'amount\'] }}</div>
|
||||
<div class="text-gray-400 text-[12px]">{{ $value[\'description\'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
', ['value' => $value]);
|
||||
})
|
||||
->flexCol(['class' => 'flex-col gap-3']),
|
||||
|
||||
Column::make('Status')
|
||||
->label(fn ($row) => Blade::render('
|
||||
<div class="flex flex-col items-start space-y-1">
|
||||
<flux:badge color="{{ $row->is_paid->color() }}">
|
||||
{{ $row->is_paid->label() }}
|
||||
</flux:badge>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ $row->paid_at ? formatDateTime($row->paid_at) : "-" }}
|
||||
</span>
|
||||
</div>
|
||||
', ['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']);
|
||||
}
|
||||
}
|
||||
87
app/Livewire/Forms/Studio/Finance/PayrollForm.php
Normal file
87
app/Livewire/Forms/Studio/Finance/PayrollForm.php
Normal file
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Forms\Studio\Finance;
|
||||
|
||||
use App\Enums\SalaryAdjustmentType;
|
||||
use App\Models\Payroll;
|
||||
use App\Models\PayrollAdjustment;
|
||||
use App\Rules\UnsignedInteger;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Form;
|
||||
|
||||
class PayrollForm extends Form
|
||||
{
|
||||
public array $user_ids = [];
|
||||
|
||||
public string $type = '';
|
||||
|
||||
public string $amount = '';
|
||||
|
||||
public ?string $description = null;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'user_ids' => ['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();
|
||||
}
|
||||
}
|
||||
115
app/Livewire/Studio/Finance/Payroll.php
Normal file
115
app/Livewire/Studio/Finance/Payroll.php
Normal file
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Studio\Finance;
|
||||
|
||||
use App\Enums\SalaryAdjustmentType;
|
||||
use App\Livewire\Forms\Studio\Finance\PayrollForm;
|
||||
use App\Models\Payroll as PayrollModel;
|
||||
use App\Models\PayrollAdjustment;
|
||||
use App\Models\User;
|
||||
use App\Traits\WithAuthorization;
|
||||
use App\Traits\WithCloseModal;
|
||||
use App\Traits\WithConfirmation;
|
||||
use App\Traits\WithToast;
|
||||
use App\Traits\WithUpdatedData;
|
||||
use App\Traits\WithUserSelector;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Penggajian')]
|
||||
class Payroll extends Component
|
||||
{
|
||||
use WithAuthorization, WithCloseModal, WithConfirmation, WithToast, WithUpdatedData, WithUserSelector;
|
||||
|
||||
public PayrollForm $form;
|
||||
|
||||
public $payrolls;
|
||||
|
||||
public string $method = 'create';
|
||||
|
||||
public string $modalTitle = '';
|
||||
|
||||
public array $users = [];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
app/Models/Payroll.php
Normal file
42
app/Models/Payroll.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\IsPaid;
|
||||
use Dyrynda\Database\Support\CascadeSoftDeletes;
|
||||
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;
|
||||
use Veelasky\LaravelHashId\Eloquent\HashableId;
|
||||
|
||||
class Payroll extends Model
|
||||
{
|
||||
use CascadeSoftDeletes, HasFactory, HashableId, SoftDeletes;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $cascadeDeletes = ['adjustments'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'base_salary' => '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);
|
||||
}
|
||||
}
|
||||
30
app/Models/PayrollAdjustment.php
Normal file
30
app/Models/PayrollAdjustment.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\SalaryAdjustmentType;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Veelasky\LaravelHashId\Eloquent\HashableId;
|
||||
|
||||
class PayrollAdjustment extends Model
|
||||
{
|
||||
use HasFactory, HashableId, SoftDeletes;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => SalaryAdjustmentType::class,
|
||||
'amount' => 'int',
|
||||
];
|
||||
}
|
||||
|
||||
public function payroll(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Payroll::class);
|
||||
}
|
||||
}
|
||||
29
app/Models/SalaryHistory.php
Normal file
29
app/Models/SalaryHistory.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Veelasky\LaravelHashId\Eloquent\HashableId;
|
||||
|
||||
class SalaryHistory extends Model
|
||||
{
|
||||
use HasFactory, HashableId, SoftDeletes;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'old_salary' => 'int',
|
||||
'new_salary' => 'int',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
<?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)->comment(IsPaid::comment());
|
||||
$table->dateTime('paid_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('payrolls');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
<?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->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('payroll_adjustments');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
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('salary_histories', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -84,6 +84,9 @@ public function run(): void
|
||||
'update expense',
|
||||
'delete expense',
|
||||
|
||||
'view payroll',
|
||||
'manage adjustment',
|
||||
|
||||
'view article',
|
||||
'create article',
|
||||
'update article',
|
||||
|
||||
148
resources/views/livewire/studio/finance/payrolls.blade.php
Normal file
148
resources/views/livewire/studio/finance/payrolls.blade.php
Normal file
@ -0,0 +1,148 @@
|
||||
<flux:main>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
|
||||
</div>
|
||||
@if (auth()->user()->can('manage adjustment'))
|
||||
<div>
|
||||
<flux:modal.trigger name="form-modal">
|
||||
<flux:button variant="primary" class="text-sm"
|
||||
wire:click="$dispatch('modal:open', {method: 'create', 'modalTitle': 'Tambah Penyesuaian'})">
|
||||
Tambah
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mb-4 text-sm">
|
||||
@foreach ($payrolls as $payroll)
|
||||
<flux:card class="h-auto">
|
||||
<div class="border rounded-lg p-4 bg-white shadow-sm">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div>
|
||||
<flux:heading>{{ $payroll->user->employee->full_name }}</flux:heading>
|
||||
<flux:text>{{ formatDate($payroll->period_month, 'F Y') }}</flux:text>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<flux:badge color="{{ $payroll->is_paid->color() }}">
|
||||
{{ $payroll->is_paid->label() }}
|
||||
</flux:badge>
|
||||
<div class="text-xs text-gray-400 mt-1">
|
||||
{{ $payroll->paid_at ? formatDateTime($payroll->paid_at) : '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-2 text-sm">
|
||||
<div>
|
||||
<flux:text>Gaji Pokok</flux:text>
|
||||
<div class="font-medium">{{ currency($payroll->base_salary, 'Rp') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<flux:text>Bonus</flux:text>
|
||||
<div class="font-medium text-green-600">{{ currency($payroll->bonus, 'Rp') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<flux:text>Potongan</flux:text>
|
||||
<div class="font-medium text-red-600">{{ currency($payroll->deduction, 'Rp') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<flux:separator class="my-2 !bg-black" />
|
||||
|
||||
<flux:text class="mb-1">Rincian</flux:text>
|
||||
@foreach ($payroll->adjustments->where('payroll_id', $payroll->id) as $item)
|
||||
<div class="flex justify-between items-start text-[13px] leading-tight mb-2">
|
||||
<div>
|
||||
<div
|
||||
class="font-bold {{ $item->type->value == \App\Enums\SalaryAdjustmentType::DEDUCTION->value ? 'text-red-500' : 'text-green-500' }}">
|
||||
{{ currency($item->amount, 'Rp') }}
|
||||
</div>
|
||||
<div class="text-gray-400 text-[12px]">{{ $item->description }}</div>
|
||||
</div>
|
||||
<flux:modal.trigger name="delete">
|
||||
<flux:button variant="danger" icon="trash" size="sm"
|
||||
wire:click="$dispatch('fn:confirmDelete', {id: '{{ $item->id }}'})">
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<flux:separator class="my-2 !bg-black" />
|
||||
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<flux:text>Total Gaji</flux:text>
|
||||
<div class="text-gray-900 font-bold">{{ currency($payroll->total_salary, 'Rp') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:separator class="mb-4" />
|
||||
|
||||
<livewire:datatable.studio.finance.payrolls-table />
|
||||
</div>
|
||||
|
||||
@include('components.confirmation.delete')
|
||||
|
||||
<flux:modal name="form-modal" class="w-[95%] max-w-sm md:max-w-xl mx-auto" @close="closeModal('form-modal')">
|
||||
<div class="p-4 space-y-6">
|
||||
<flux:heading size="lg">{{ $modalTitle }}</flux:heading>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Pegawai <span class="text-red-500 ms-1">*</span></flux:label>
|
||||
<flux:select variant="listbox" multiple searchable placeholder="Pilih Pegawai"
|
||||
wire:model.live.debounce.500ms="form.user_ids">
|
||||
<flux:select.option wire:click="selectAllUsers" wire:ignore>Pilih Semua
|
||||
</flux:select.option>
|
||||
<flux:select.option wire:click="deselectAllUsers" wire:ignore>Hapus Semua
|
||||
</flux:select.option>
|
||||
@foreach ($users as $key => $value)
|
||||
<flux:select.option value="{{ $key }}">{{ $value }}
|
||||
</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="form.user_ids" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Tipe <span class="text-red-500 ms-1">*</span></flux:label>
|
||||
<flux:radio.group wire:model.live="form.type" variant="buttons" class="w-full *:flex-1">
|
||||
@foreach (\App\Enums\SalaryAdjustmentType::cases() as $type)
|
||||
<flux:radio value="{{ $type->value }}">
|
||||
{{ $type->label() }}
|
||||
</flux:radio>
|
||||
@endforeach
|
||||
</flux:radio.group>
|
||||
<flux:error name="form.type" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Jumlah <span class="text-red-500 ms-1">*</span>
|
||||
</flux:label>
|
||||
<flux:input.group>
|
||||
<flux:input.group.prefix>Rp</flux:input.group.prefix>
|
||||
<flux:input placeholder="Masukkan jumlah" x-mask:dynamic="$money($input, ',')"
|
||||
wire:model.live.debounce.500ms="form.amount" autocomplete="off" autofocus />
|
||||
</flux:input.group>
|
||||
<flux:error name="form.amount" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Keterangan <span class="text-red-500 ms-1">*</span></flux:label>
|
||||
<flux:input placeholder="Masukkan keterangan" wire:model.live.debounce.500ms="form.description"
|
||||
autocomplete="off" />
|
||||
<flux:error name="form.description" />
|
||||
</flux:field>
|
||||
|
||||
<div class="flex">
|
||||
<flux:spacer />
|
||||
<flux:button variant="primary" class="sm:w-auto cursor-pointer" wire:click="{{ $method }}">
|
||||
Simpan
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</flux:main>
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user