Add payroll management features including Payroll, PayrollPeriod, and PayrollAdjustment models, along with PayrollService for handling payroll operations. Implement corresponding controllers for payroll and payroll period management, and integrate request validation for payroll adjustments. Enhance UI components for payroll listing and management, including data tables and modals. Update permissions in the Permission and Role enums, and add scheduled command for opening payroll periods.
This commit is contained in:
parent
39a74964fc
commit
3f05e3cdc2
26
app/Console/Commands/OpenPayrollPeriodCommand.php
Normal file
26
app/Console/Commands/OpenPayrollPeriodCommand.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Finance\PayrollService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class OpenPayrollPeriodCommand extends Command
|
||||
{
|
||||
protected $signature = 'payroll:open-period';
|
||||
|
||||
protected $description = 'Tutup periode gaji terbuka, buka periode bulan ini, dan generate gaji pegawai';
|
||||
|
||||
public function handle(PayrollService $payrollService): int
|
||||
{
|
||||
$period = $payrollService->openCurrentPeriod();
|
||||
|
||||
$this->info(sprintf(
|
||||
'Periode gaji %s dibuka. Total %d slip gaji.',
|
||||
$period->period_label,
|
||||
$period->payrolls()->count(),
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
21
app/Enums/PayrollAdjustmentType.php
Normal file
21
app/Enums/PayrollAdjustmentType.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Traits\ProvidesEnumOptions;
|
||||
|
||||
enum PayrollAdjustmentType: string
|
||||
{
|
||||
use ProvidesEnumOptions;
|
||||
|
||||
case BONUS = 'bonus';
|
||||
case DEDUCTION = 'deduction';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::BONUS => 'Tunjangan',
|
||||
self::DEDUCTION => 'Potongan',
|
||||
};
|
||||
}
|
||||
}
|
||||
21
app/Enums/PayrollPeriodStatus.php
Normal file
21
app/Enums/PayrollPeriodStatus.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Traits\ProvidesEnumOptions;
|
||||
|
||||
enum PayrollPeriodStatus: string
|
||||
{
|
||||
use ProvidesEnumOptions;
|
||||
|
||||
case OPEN = 'open';
|
||||
case CLOSED = 'closed';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::OPEN => 'Dibuka',
|
||||
self::CLOSED => 'Ditutup',
|
||||
};
|
||||
}
|
||||
}
|
||||
21
app/Enums/PayrollStatus.php
Normal file
21
app/Enums/PayrollStatus.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Traits\ProvidesEnumOptions;
|
||||
|
||||
enum PayrollStatus: string
|
||||
{
|
||||
use ProvidesEnumOptions;
|
||||
|
||||
case UNPAID = 'unpaid';
|
||||
case PAID = 'paid';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::UNPAID => 'Belum Dibayar',
|
||||
self::PAID => 'Sudah Dibayar',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -61,6 +61,11 @@ enum Permission: string
|
||||
case EMPLOYEE_ADVANCES_VERIFY = 'employee-advances.verify';
|
||||
case EMPLOYEE_ADVANCES_PAY = 'employee-advances.pay';
|
||||
|
||||
case PAYROLL_VIEW = 'payroll.view';
|
||||
case PAYROLL_PAY = 'payroll.pay';
|
||||
case PAYROLL_ADJUST = 'payroll.adjust';
|
||||
case PAYROLL_CLOSE = 'payroll.close';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
@ -116,6 +121,11 @@ public function label(): string
|
||||
self::EMPLOYEE_ADVANCES_DELETE => 'Hapus Kasbon',
|
||||
self::EMPLOYEE_ADVANCES_VERIFY => 'Setujui/Tolak Kasbon',
|
||||
self::EMPLOYEE_ADVANCES_PAY => 'Pelunasi Kasbon',
|
||||
|
||||
self::PAYROLL_VIEW => 'Lihat Gaji',
|
||||
self::PAYROLL_PAY => 'Bayar Gaji',
|
||||
self::PAYROLL_ADJUST => 'Sesuaikan Gaji',
|
||||
self::PAYROLL_CLOSE => 'Tutup Periode Gaji',
|
||||
};
|
||||
}
|
||||
|
||||
@ -141,6 +151,8 @@ public function group(): string
|
||||
self::EMPLOYEE_ADVANCES_VIEW, self::EMPLOYEE_ADVANCES_CREATE, self::EMPLOYEE_ADVANCES_UPDATE,
|
||||
self::EMPLOYEE_ADVANCES_DELETE, self::EMPLOYEE_ADVANCES_VERIFY,
|
||||
self::EMPLOYEE_ADVANCES_PAY => 'Kasbon',
|
||||
self::PAYROLL_VIEW, self::PAYROLL_PAY, self::PAYROLL_ADJUST,
|
||||
self::PAYROLL_CLOSE => 'Gaji',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -79,6 +79,10 @@ public function permissions(): array
|
||||
Permission::EMPLOYEE_ADVANCES_UPDATE,
|
||||
Permission::EMPLOYEE_ADVANCES_DELETE,
|
||||
Permission::EMPLOYEE_ADVANCES_PAY,
|
||||
Permission::PAYROLL_VIEW,
|
||||
Permission::PAYROLL_PAY,
|
||||
Permission::PAYROLL_ADJUST,
|
||||
Permission::PAYROLL_CLOSE,
|
||||
],
|
||||
self::ADMIN_TOKO => [
|
||||
Permission::DASHBOARD_VIEW,
|
||||
@ -117,6 +121,10 @@ public function permissions(): array
|
||||
Permission::EMPLOYEE_ADVANCES_UPDATE,
|
||||
Permission::EMPLOYEE_ADVANCES_DELETE,
|
||||
Permission::EMPLOYEE_ADVANCES_PAY,
|
||||
Permission::PAYROLL_VIEW,
|
||||
Permission::PAYROLL_PAY,
|
||||
Permission::PAYROLL_ADJUST,
|
||||
Permission::PAYROLL_CLOSE,
|
||||
],
|
||||
self::ADMIN_BAHAN_BAKU => [
|
||||
Permission::DASHBOARD_VIEW,
|
||||
|
||||
43
app/Http/Controllers/Admin/Finance/PayrollController.php
Normal file
43
app/Http/Controllers/Admin/Finance/PayrollController.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Finance\PayrollAdjustmentRequest;
|
||||
use App\Models\Payroll;
|
||||
use App\Services\Finance\PayrollService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PayrollController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PayrollService $payrollService,
|
||||
) {}
|
||||
|
||||
public function pay(Payroll $payroll): RedirectResponse
|
||||
{
|
||||
$this->payrollService->pay($payroll, auth()->user());
|
||||
|
||||
Inertia::flash('success', 'Gaji berhasil dibayar.');
|
||||
|
||||
return redirect()->route('admin.finance.payroll.index', [
|
||||
'period_id' => $payroll->payroll_period_id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeAdjustment(PayrollAdjustmentRequest $request, Payroll $payroll): RedirectResponse
|
||||
{
|
||||
$this->payrollService->addAdjustment(
|
||||
$payroll,
|
||||
$request->validated(),
|
||||
auth()->user(),
|
||||
);
|
||||
|
||||
Inertia::flash('success', 'Penyesuaian gaji berhasil ditambahkan.');
|
||||
|
||||
return redirect()->route('admin.finance.payroll.index', [
|
||||
'period_id' => $payroll->payroll_period_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Finance;
|
||||
|
||||
use App\Enums\PayrollAdjustmentType;
|
||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PayrollPeriod;
|
||||
use App\Services\Finance\PayrollService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PayrollPeriodController extends Controller
|
||||
{
|
||||
use ParsesDataTableQuery;
|
||||
|
||||
public function __construct(
|
||||
private readonly PayrollService $payrollService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$tableQuery = $this->parseDataTableQuery($request);
|
||||
$periodId = $request->integer('period_id') ?: null;
|
||||
$period = $this->payrollService->resolvePeriod($periodId);
|
||||
|
||||
return Inertia::render('admin/finance/payroll/Index', [
|
||||
'periods' => $this->payrollService->listPeriods(),
|
||||
'currentPeriod' => $period,
|
||||
'payrolls' => $period
|
||||
? $this->payrollService->paginateForPeriod($period, $tableQuery)
|
||||
: null,
|
||||
'summary' => $period
|
||||
? $this->payrollService->periodSummary($period)
|
||||
: null,
|
||||
'adjustmentTypes' => PayrollAdjustmentType::selectOptions(),
|
||||
'filters' => array_merge(
|
||||
$this->dataTableFilters($tableQuery),
|
||||
['period_id' => $period?->id],
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function close(PayrollPeriod $payrollPeriod): RedirectResponse
|
||||
{
|
||||
$this->payrollService->closePeriod($payrollPeriod, auth()->user());
|
||||
|
||||
Inertia::flash('success', 'Periode gaji berhasil ditutup.');
|
||||
|
||||
return redirect()->route('admin.finance.payroll.index', [
|
||||
'period_id' => $payrollPeriod->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php
Normal file
28
app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Finance;
|
||||
|
||||
use App\Enums\PayrollAdjustmentType;
|
||||
use App\Enums\Permission;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class PayrollAdjustmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can(Permission::PAYROLL_ADJUST->value) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', Rule::enum(PayrollAdjustmentType::class)],
|
||||
'amount' => ['required', 'integer', 'min:1'],
|
||||
'description' => ['required', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -96,6 +96,7 @@ public static function labelForReferenceType(?string $referenceType): string
|
||||
return match ($referenceType) {
|
||||
Expense::class => 'Pengeluaran',
|
||||
EmployeeAdvance::class => 'Kasbon Pegawai',
|
||||
Payroll::class => 'Gaji Pegawai',
|
||||
default => class_basename($referenceType),
|
||||
};
|
||||
}
|
||||
|
||||
@ -97,4 +97,9 @@ public function advances(): HasMany
|
||||
{
|
||||
return $this->hasMany(EmployeeAdvance::class);
|
||||
}
|
||||
|
||||
public function payrolls(): HasMany
|
||||
{
|
||||
return $this->hasMany(Payroll::class);
|
||||
}
|
||||
}
|
||||
|
||||
162
app/Models/Payroll.php
Normal file
162
app/Models/Payroll.php
Normal file
@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\EmployeeAdvanceStatus;
|
||||
use App\Enums\PayrollAdjustmentType;
|
||||
use App\Enums\PayrollStatus;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends([
|
||||
'base_salary_formatted',
|
||||
'bonus_amount_formatted',
|
||||
'deduction_amount_formatted',
|
||||
'net_amount_formatted',
|
||||
'status_label',
|
||||
'employee_name',
|
||||
'paid_at_formatted',
|
||||
'can_pay',
|
||||
'can_adjust',
|
||||
])]
|
||||
class Payroll extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'base_salary' => 'integer',
|
||||
'bonus_amount' => 'integer',
|
||||
'deduction_amount' => 'integer',
|
||||
'net_amount' => 'integer',
|
||||
'status' => PayrollStatus::class,
|
||||
'paid_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function baseSalaryFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->base_salary, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function bonusAmountFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->bonus_amount, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function deductionAmountFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->deduction_amount, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function netAmountFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->net_amount, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function statusLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->status?->label(),
|
||||
);
|
||||
}
|
||||
|
||||
public function employeeName(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->employee?->user?->profile?->full_name
|
||||
?? $this->employee?->user?->username,
|
||||
);
|
||||
}
|
||||
|
||||
public function paidAtFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->paid_at?->translatedFormat('l, d F Y H:i'),
|
||||
);
|
||||
}
|
||||
|
||||
public function canPay(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->status === PayrollStatus::UNPAID
|
||||
&& $this->relationLoaded('payrollPeriod')
|
||||
&& $this->payrollPeriod?->isOpen(),
|
||||
);
|
||||
}
|
||||
|
||||
public function canAdjust(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->status === PayrollStatus::UNPAID
|
||||
&& $this->relationLoaded('payrollPeriod')
|
||||
&& $this->payrollPeriod?->isOpen(),
|
||||
);
|
||||
}
|
||||
|
||||
public function payrollPeriod(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PayrollPeriod::class);
|
||||
}
|
||||
|
||||
public function employee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Employee::class);
|
||||
}
|
||||
|
||||
public function paidBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'paid_by_id');
|
||||
}
|
||||
|
||||
public function cashTransaction(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CashTransaction::class);
|
||||
}
|
||||
|
||||
public function adjustments(): HasMany
|
||||
{
|
||||
return $this->hasMany(PayrollAdjustment::class);
|
||||
}
|
||||
|
||||
public function recalculateAmounts(): void
|
||||
{
|
||||
$bonusAmount = (int) $this->adjustments()
|
||||
->where('type', PayrollAdjustmentType::BONUS)
|
||||
->sum('amount');
|
||||
|
||||
$manualDeduction = (int) $this->adjustments()
|
||||
->where('type', PayrollAdjustmentType::DEDUCTION)
|
||||
->sum('amount');
|
||||
|
||||
$kasbonDeduction = $this->calculateKasbonDeduction();
|
||||
|
||||
$this->bonus_amount = $bonusAmount;
|
||||
$this->deduction_amount = $kasbonDeduction + $manualDeduction;
|
||||
$this->net_amount = max(0, $this->base_salary + $bonusAmount - $this->deduction_amount);
|
||||
}
|
||||
|
||||
public function calculateKasbonDeduction(): int
|
||||
{
|
||||
$outstanding = (int) EmployeeAdvance::query()
|
||||
->where('employee_id', $this->employee_id)
|
||||
->where('status', EmployeeAdvanceStatus::APPROVED)
|
||||
->sum('amount');
|
||||
|
||||
return min($outstanding, $this->base_salary + (int) $this->adjustments()
|
||||
->where('type', PayrollAdjustmentType::BONUS)
|
||||
->sum('amount'));
|
||||
}
|
||||
}
|
||||
64
app/Models/PayrollAdjustment.php
Normal file
64
app/Models/PayrollAdjustment.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PayrollAdjustmentType;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['amount_formatted', 'type_label', 'created_at_formatted', 'created_by_name'])]
|
||||
class PayrollAdjustment extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => PayrollAdjustmentType::class,
|
||||
'amount' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function amountFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function typeLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->type?->label(),
|
||||
);
|
||||
}
|
||||
|
||||
public function createdAtFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'),
|
||||
);
|
||||
}
|
||||
|
||||
public function createdByName(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->createdBy?->profile?->full_name ?? $this->createdBy?->username,
|
||||
);
|
||||
}
|
||||
|
||||
public function payroll(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Payroll::class);
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_id');
|
||||
}
|
||||
}
|
||||
61
app/Models/PayrollPeriod.php
Normal file
61
app/Models/PayrollPeriod.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PayrollPeriodStatus;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['period_label', 'status_label', 'closed_at_formatted'])]
|
||||
class PayrollPeriod extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => PayrollPeriodStatus::class,
|
||||
'closed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function periodLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => Carbon::create($this->year, $this->month, 1)->translatedFormat('F Y'),
|
||||
);
|
||||
}
|
||||
|
||||
public function statusLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->status?->label(),
|
||||
);
|
||||
}
|
||||
|
||||
public function closedAtFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->closed_at?->translatedFormat('l, d F Y H:i'),
|
||||
);
|
||||
}
|
||||
|
||||
public function payrolls(): HasMany
|
||||
{
|
||||
return $this->hasMany(Payroll::class);
|
||||
}
|
||||
|
||||
public function closedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'closed_by_id');
|
||||
}
|
||||
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this->status === PayrollPeriodStatus::OPEN;
|
||||
}
|
||||
}
|
||||
343
app/Services/Finance/PayrollService.php
Normal file
343
app/Services/Finance/PayrollService.php
Normal file
@ -0,0 +1,343 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Enums\EmployeeAdvanceStatus;
|
||||
use App\Enums\PayrollAdjustmentType;
|
||||
use App\Enums\PayrollPeriodStatus;
|
||||
use App\Enums\PayrollStatus;
|
||||
use App\Enums\Role;
|
||||
use App\Models\Employee;
|
||||
use App\Models\EmployeeAdvance;
|
||||
use App\Models\Payroll;
|
||||
use App\Models\PayrollPeriod;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PayrollService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CashService $cashService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return Collection<int, PayrollPeriod>
|
||||
*/
|
||||
public function listPeriods(): Collection
|
||||
{
|
||||
return PayrollPeriod::query()
|
||||
->orderByDesc('year')
|
||||
->orderByDesc('month')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function resolvePeriod(?int $periodId): ?PayrollPeriod
|
||||
{
|
||||
if ($periodId !== null) {
|
||||
return PayrollPeriod::query()->find($periodId);
|
||||
}
|
||||
|
||||
return PayrollPeriod::query()
|
||||
->where('status', PayrollPeriodStatus::OPEN)
|
||||
->orderByDesc('year')
|
||||
->orderByDesc('month')
|
||||
->first()
|
||||
?? PayrollPeriod::query()
|
||||
->orderByDesc('year')
|
||||
->orderByDesc('month')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total_net_amount: int, total_net_amount_formatted: string, unpaid_count: int, paid_count: int}
|
||||
*/
|
||||
public function periodSummary(PayrollPeriod $period): array
|
||||
{
|
||||
$totalNetAmount = (int) Payroll::query()
|
||||
->where('payroll_period_id', $period->id)
|
||||
->where('status', PayrollStatus::UNPAID)
|
||||
->sum('net_amount');
|
||||
|
||||
$unpaidCount = Payroll::query()
|
||||
->where('payroll_period_id', $period->id)
|
||||
->where('status', PayrollStatus::UNPAID)
|
||||
->count();
|
||||
|
||||
$paidCount = Payroll::query()
|
||||
->where('payroll_period_id', $period->id)
|
||||
->where('status', PayrollStatus::PAID)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total_net_amount' => $totalNetAmount,
|
||||
'total_net_amount_formatted' => 'Rp '.number_format($totalNetAmount, 0, ',', '.'),
|
||||
'unpaid_count' => $unpaidCount,
|
||||
'paid_count' => $paidCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
|
||||
*/
|
||||
public function paginateForPeriod(PayrollPeriod $period, array $tableQuery): LengthAwarePaginator
|
||||
{
|
||||
$query = Payroll::query()
|
||||
->with(['employee.user.profile', 'payrollPeriod'])
|
||||
->where('payroll_period_id', $period->id)
|
||||
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
|
||||
$search = $tableQuery['search'];
|
||||
$query->where(function (Builder $query) use ($search): void {
|
||||
$query->whereHas('employee.user.profile', fn (Builder $query) => $query->where('full_name', 'like', "%{$search}%"))
|
||||
->orWhereHas('employee.user', fn (Builder $query) => $query->where('username', 'like', "%{$search}%"));
|
||||
});
|
||||
});
|
||||
|
||||
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
|
||||
|
||||
return $query
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod
|
||||
{
|
||||
return DB::transaction(function () use ($closedBy): PayrollPeriod {
|
||||
$now = now();
|
||||
$year = $now->year;
|
||||
$month = $now->month;
|
||||
|
||||
PayrollPeriod::query()
|
||||
->where('status', PayrollPeriodStatus::OPEN)
|
||||
->update([
|
||||
'status' => PayrollPeriodStatus::CLOSED,
|
||||
'closed_at' => now(),
|
||||
'closed_by_id' => $closedBy?->id,
|
||||
]);
|
||||
|
||||
$period = PayrollPeriod::query()
|
||||
->where('year', $year)
|
||||
->where('month', $month)
|
||||
->first();
|
||||
|
||||
if ($period === null) {
|
||||
$period = PayrollPeriod::create([
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
'status' => PayrollPeriodStatus::OPEN,
|
||||
]);
|
||||
} elseif ($period->status === PayrollPeriodStatus::CLOSED) {
|
||||
$period->status = PayrollPeriodStatus::OPEN;
|
||||
$period->closed_at = null;
|
||||
$period->closed_by_id = null;
|
||||
$period->save();
|
||||
}
|
||||
|
||||
$this->generatePayrollsForPeriod($period);
|
||||
|
||||
return $period->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function closePeriod(PayrollPeriod $period, User $user): void
|
||||
{
|
||||
if (! $period->isOpen()) {
|
||||
throw ValidationException::withMessages([
|
||||
'payroll_period' => 'Periode gaji ini sudah ditutup.',
|
||||
]);
|
||||
}
|
||||
|
||||
$unpaidCount = Payroll::query()
|
||||
->where('payroll_period_id', $period->id)
|
||||
->where('status', PayrollStatus::UNPAID)
|
||||
->where('net_amount', '>', 0)
|
||||
->count();
|
||||
|
||||
if ($unpaidCount > 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'payroll_period' => 'Masih ada gaji yang belum dibayar.',
|
||||
]);
|
||||
}
|
||||
|
||||
$period->status = PayrollPeriodStatus::CLOSED;
|
||||
$period->closed_at = now();
|
||||
$period->closed_by_id = $user->id;
|
||||
$period->save();
|
||||
}
|
||||
|
||||
public function generatePayrollsForPeriod(PayrollPeriod $period): void
|
||||
{
|
||||
$employees = $this->payrollEligibleEmployees($period);
|
||||
|
||||
foreach ($employees as $employee) {
|
||||
$exists = Payroll::query()
|
||||
->where('payroll_period_id', $period->id)
|
||||
->where('employee_id', $employee->id)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payroll = new Payroll([
|
||||
'payroll_period_id' => $period->id,
|
||||
'employee_id' => $employee->id,
|
||||
'base_salary' => $employee->base_salary,
|
||||
'bonus_amount' => 0,
|
||||
'deduction_amount' => 0,
|
||||
'net_amount' => 0,
|
||||
'status' => PayrollStatus::UNPAID,
|
||||
]);
|
||||
|
||||
$payroll->save();
|
||||
$payroll->recalculateAmounts();
|
||||
$payroll->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{type: string, amount: int, description: string} $validated
|
||||
*/
|
||||
public function addAdjustment(Payroll $payroll, array $validated, User $user): void
|
||||
{
|
||||
$payroll->loadMissing('payrollPeriod');
|
||||
|
||||
if (! $payroll->can_adjust) {
|
||||
throw ValidationException::withMessages([
|
||||
'payroll' => 'Penyesuaian hanya dapat ditambahkan pada gaji yang belum dibayar di periode terbuka.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($payroll, $validated, $user): void {
|
||||
$payroll->adjustments()->create([
|
||||
'type' => PayrollAdjustmentType::from($validated['type']),
|
||||
'amount' => (int) $validated['amount'],
|
||||
'description' => $validated['description'],
|
||||
'created_by_id' => $user->id,
|
||||
]);
|
||||
|
||||
$payroll->load('adjustments');
|
||||
$payroll->recalculateAmounts();
|
||||
$payroll->save();
|
||||
});
|
||||
}
|
||||
|
||||
public function pay(Payroll $payroll, User $user): void
|
||||
{
|
||||
$payroll->loadMissing(['payrollPeriod', 'employee.user.profile']);
|
||||
|
||||
if ($payroll->status !== PayrollStatus::UNPAID) {
|
||||
throw ValidationException::withMessages([
|
||||
'payroll' => 'Gaji ini sudah dibayar.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $payroll->payrollPeriod?->isOpen()) {
|
||||
throw ValidationException::withMessages([
|
||||
'payroll' => 'Periode gaji sudah ditutup.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($payroll->net_amount <= 0) {
|
||||
DB::transaction(function () use ($payroll, $user): void {
|
||||
$payroll->status = PayrollStatus::PAID;
|
||||
$payroll->paid_at = now();
|
||||
$payroll->paid_by_id = $user->id;
|
||||
$payroll->save();
|
||||
|
||||
$this->settleKasbonFromPayroll($payroll, $user);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($payroll, $user): void {
|
||||
$description = sprintf(
|
||||
'Pembayaran gaji: %s (%s)',
|
||||
$payroll->employeeName,
|
||||
$payroll->payrollPeriod->period_label,
|
||||
);
|
||||
|
||||
$cashTransaction = $this->cashService->recordOutgoing(
|
||||
$payroll,
|
||||
$payroll->net_amount,
|
||||
$description,
|
||||
$user,
|
||||
);
|
||||
|
||||
$payroll->cash_transaction_id = $cashTransaction->id;
|
||||
$payroll->status = PayrollStatus::PAID;
|
||||
$payroll->paid_at = now();
|
||||
$payroll->paid_by_id = $user->id;
|
||||
$payroll->save();
|
||||
|
||||
$this->settleKasbonFromPayroll($payroll, $user);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Employee>
|
||||
*/
|
||||
private function payrollEligibleEmployees(PayrollPeriod $period): Collection
|
||||
{
|
||||
$periodStart = Carbon::create($period->year, $period->month, 1)->startOfMonth();
|
||||
|
||||
return Employee::query()
|
||||
->with('user.roles')
|
||||
->whereHas('user', function (Builder $query): void {
|
||||
$query->where('is_active', true)
|
||||
->whereDoesntHave('roles', fn (Builder $query) => $query->whereIn('name', [
|
||||
Role::DEVELOPER->value,
|
||||
Role::OWNER->value,
|
||||
]));
|
||||
})
|
||||
->where(function (Builder $query) use ($periodStart): void {
|
||||
$query->whereNull('resign_date')
|
||||
->orWhere('resign_date', '>=', $periodStart);
|
||||
})
|
||||
->get();
|
||||
}
|
||||
|
||||
private function settleKasbonFromPayroll(Payroll $payroll, User $user): void
|
||||
{
|
||||
$remaining = (int) $payroll->deduction_amount;
|
||||
|
||||
if ($remaining <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$advances = EmployeeAdvance::query()
|
||||
->where('employee_id', $payroll->employee_id)
|
||||
->where('status', EmployeeAdvanceStatus::APPROVED)
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
foreach ($advances as $advance) {
|
||||
if ($remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
$advance->paid_at = now();
|
||||
$advance->paid_by_id = $user->id;
|
||||
$advance->status = EmployeeAdvanceStatus::PAID;
|
||||
$advance->save();
|
||||
|
||||
$remaining -= $advance->amount;
|
||||
}
|
||||
}
|
||||
|
||||
private function applySorting(Builder $query, string $sort, string $direction): void
|
||||
{
|
||||
if (in_array($sort, ['base_salary', 'bonus_amount', 'deduction_amount', 'net_amount', 'status', 'created_at'], true)) {
|
||||
$query->orderBy($sort, $direction);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->orderBy('employee_id');
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
@ -34,4 +35,9 @@
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*'),
|
||||
);
|
||||
})
|
||||
->withSchedule(function (Schedule $schedule): void {
|
||||
$schedule->command('payroll:open-period')
|
||||
->monthlyOn(1, '00:05')
|
||||
->timezone('Asia/Jakarta');
|
||||
})->create();
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PayrollPeriodStatus;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('payroll_periods', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedSmallInteger('year');
|
||||
$table->unsignedTinyInteger('month');
|
||||
$table->enum('status', array_column(PayrollPeriodStatus::cases(), 'value'))
|
||||
->default(PayrollPeriodStatus::OPEN->value);
|
||||
$table->timestamp('closed_at')->nullable();
|
||||
$table->foreignId('closed_by_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['year', 'month']);
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('payroll_periods');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PayrollStatus;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('payrolls', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('payroll_period_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('employee_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedInteger('base_salary');
|
||||
$table->unsignedBigInteger('bonus_amount')->default(0);
|
||||
$table->unsignedBigInteger('deduction_amount')->default(0);
|
||||
$table->unsignedBigInteger('net_amount');
|
||||
$table->enum('status', array_column(PayrollStatus::cases(), 'value'))
|
||||
->default(PayrollStatus::UNPAID->value);
|
||||
$table->foreignId('cash_transaction_id')->nullable()->unique()->constrained()->restrictOnDelete();
|
||||
$table->timestamp('paid_at')->nullable();
|
||||
$table->foreignId('paid_by_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['payroll_period_id', 'employee_id']);
|
||||
$table->index(['payroll_period_id', 'status']);
|
||||
$table->index('employee_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('payrolls');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PayrollAdjustmentType;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('payroll_adjustments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('payroll_id')->constrained()->cascadeOnDelete();
|
||||
$table->enum('type', array_column(PayrollAdjustmentType::cases(), 'value'));
|
||||
$table->unsignedBigInteger('amount');
|
||||
$table->string('description', 500);
|
||||
$table->foreignId('created_by_id')->constrained('users')->restrictOnDelete();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['payroll_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('payroll_adjustments');
|
||||
}
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { Banknote, FolderTree, Layers, LayoutDashboard, Package, Receipt, User, UserCheck, Users, Wallet } from '@lucide/vue';
|
||||
import { Banknote, FolderTree, Layers, LayoutDashboard, Package, Receipt, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
Sidebar,
|
||||
@ -29,8 +29,9 @@ const isCustomersActive = computed(() => page.url.startsWith('/admin/master/cust
|
||||
const isCashActive = computed(() => page.url.startsWith('/admin/finance/cash'));
|
||||
const isExpensesActive = computed(() => page.url.startsWith('/admin/finance/expenses'));
|
||||
const isEmployeeAdvancesActive = computed(() => page.url.startsWith('/admin/finance/employee-advances'));
|
||||
const isPayrollActive = computed(() => page.url.startsWith('/admin/finance/payroll'));
|
||||
const showFinanceMenu = computed(() => (
|
||||
can('cash.view') || can('expenses.view') || can('employee-advances.view')
|
||||
can('cash.view') || can('expenses.view') || can('employee-advances.view') || can('payroll.view')
|
||||
));
|
||||
const showMasterMenu = computed(() => (
|
||||
can('categories.view') || can('products.view') || can('raw-materials.view')
|
||||
@ -144,6 +145,14 @@ const showMasterMenu = computed(() => (
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem v-if="can('payroll.view')">
|
||||
<SidebarMenuButton as-child tooltip="Gaji" :is-active="isPayrollActive">
|
||||
<Link href="/admin/finance/payroll">
|
||||
<WalletCards />
|
||||
<span>Gaji</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Save } from '@lucide/vue';
|
||||
import { watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field';
|
||||
import { RupiahInput } from '@/components/ui/rupiah-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import type { PayrollAdjustmentFormData, PayrollListItem, SelectOption } from '@/types/payroll';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
payroll: PayrollListItem | null;
|
||||
adjustmentTypes: SelectOption[];
|
||||
}>();
|
||||
|
||||
const form = useForm<PayrollAdjustmentFormData>({
|
||||
type: 'bonus',
|
||||
amount: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
form.reset();
|
||||
form.type = 'bonus';
|
||||
form.clearErrors();
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
|
||||
function submit() {
|
||||
if (!props.payroll) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
amount: parseRupiah(data.amount),
|
||||
})).post(`/admin/finance/payroll/${props.payroll.id}/adjustments`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menambahkan penyesuaian.');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Penyesuaian Gaji</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<Field v-if="payroll">
|
||||
<FieldLabel>Pegawai</FieldLabel>
|
||||
<p class="text-sm text-muted-foreground">{{ payroll.employee_name }}</p>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="type">Jenis</FieldLabel>
|
||||
<Select v-model="form.type">
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue placeholder="Pilih jenis" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in adjustmentTypes" :key="option.value"
|
||||
:value="option.value">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError :message="form.errors.type" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="amount">Jumlah</FieldLabel>
|
||||
<RupiahInput id="amount" v-model="form.amount" />
|
||||
<FieldError :message="form.errors.amount" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="description">Keterangan</FieldLabel>
|
||||
<Textarea id="description" v-model="form.description" rows="3" />
|
||||
<FieldError :message="form.errors.description" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
|
||||
<DialogFooter class="mt-4">
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
<Save class="size-4" />
|
||||
Simpan
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
61
resources/js/components/admin/finance/payroll/columns.ts
Normal file
61
resources/js/components/admin/finance/payroll/columns.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { h } from 'vue';
|
||||
import DataTableActions from '@/components/admin/finance/payroll/data-table-actions.vue';
|
||||
import { DataTableColumnHeader } from '@/components/data-table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { PayrollListItem } from '@/types/payroll';
|
||||
|
||||
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
return status === 'paid' ? 'secondary' : 'outline';
|
||||
}
|
||||
|
||||
export function createColumns(
|
||||
onAdjust: (payroll: PayrollListItem) => void,
|
||||
): ColumnDef<PayrollListItem>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'employee_name',
|
||||
enableSorting: false,
|
||||
header: () => 'Pegawai',
|
||||
},
|
||||
{
|
||||
accessorKey: 'base_salary_formatted',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Gaji Pokok', column: 'base_salary' }),
|
||||
},
|
||||
{
|
||||
accessorKey: 'bonus_amount_formatted',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Tunjangan', column: 'bonus_amount' }),
|
||||
},
|
||||
{
|
||||
accessorKey: 'deduction_amount_formatted',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Potongan', column: 'deduction_amount' }),
|
||||
},
|
||||
{
|
||||
accessorKey: 'net_amount_formatted',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Gaji Bersih', column: 'net_amount' }),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status_label',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
|
||||
cell: ({ row }) => h(
|
||||
Badge,
|
||||
{ variant: statusVariant(row.original.status) },
|
||||
() => row.original.status_label,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => h(DataTableActions, {
|
||||
payroll: row.original,
|
||||
onAdjust: () => onAdjust(row.original),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { Banknote, SlidersHorizontal } from '@lucide/vue';
|
||||
import { ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { PayrollListItem } from '@/types/payroll';
|
||||
|
||||
const props = defineProps<{
|
||||
payroll: PayrollListItem;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
adjust: [payroll: PayrollListItem];
|
||||
}>();
|
||||
|
||||
const { can } = useCan();
|
||||
|
||||
const payConfirmOpen = ref(false);
|
||||
const payProcessing = ref(false);
|
||||
|
||||
function payPayroll() {
|
||||
payProcessing.value = true;
|
||||
|
||||
router.post(`/admin/finance/payroll/${props.payroll.id}/pay`, {}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
payConfirmOpen.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal membayar gaji.');
|
||||
},
|
||||
onFinish: () => {
|
||||
payProcessing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Tooltip v-if="payroll.can_adjust && can('payroll.adjust')">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-8" @click="emit('adjust', payroll)">
|
||||
<SlidersHorizontal class="size-4" />
|
||||
<span class="sr-only">Sesuaikan</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Sesuaikan Gaji</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="payroll.can_pay && can('payroll.pay')">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-8" @click="payConfirmOpen = true">
|
||||
<Banknote class="size-4" />
|
||||
<span class="sr-only">Bayar</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Bayar Gaji</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog v-if="can('payroll.pay')" v-model:open="payConfirmOpen" title="Bayar gaji?"
|
||||
:description="`Gaji ${payroll.net_amount_formatted} untuk ${payroll.employee_name} akan dibayar dari kas.`"
|
||||
confirm-label="Bayar" cancel-label="Batal" :loading="payProcessing" @confirm="payPayroll" />
|
||||
</template>
|
||||
217
resources/js/pages/admin/finance/payroll/Index.vue
Normal file
217
resources/js/pages/admin/finance/payroll/Index.vue
Normal file
@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { Banknote, Lock } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { createColumns } from '@/components/admin/finance/payroll/columns';
|
||||
import PayrollAdjustmentModal from '@/components/admin/finance/payroll/PayrollAdjustmentModal.vue';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type { DataTableSort } from '@/types/data-table';
|
||||
import type { PayrollListItem, PayrollPageProps } from '@/types/payroll';
|
||||
|
||||
const props = defineProps<PayrollPageProps>();
|
||||
|
||||
const { can } = useCan();
|
||||
const search = ref(props.filters.search ?? '');
|
||||
const selectedPeriodId = ref(props.filters.period_id ? String(props.filters.period_id) : '');
|
||||
const adjustmentModalOpen = ref(false);
|
||||
const adjustingPayroll = ref<PayrollListItem | null>(null);
|
||||
const closeConfirmOpen = ref(false);
|
||||
const closeProcessing = ref(false);
|
||||
|
||||
const { query, setSearch, setSort, setFilter, resetFilters, syncFromServer } = useDataTableQuery({
|
||||
url: '/admin/finance/payroll',
|
||||
initial: { ...props.filters, period_id: props.filters.period_id ? String(props.filters.period_id) : '' },
|
||||
filterKeys: ['period_id'],
|
||||
});
|
||||
|
||||
useDataTableQuerySync(() => props.filters, syncFromServer);
|
||||
|
||||
const columns = computed(() => createColumns(openAdjustModal));
|
||||
|
||||
const currentSort = computed<DataTableSort | null>(() => {
|
||||
if (!query.value.sort || !query.value.direction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
column: query.value.sort,
|
||||
direction: query.value.direction,
|
||||
};
|
||||
});
|
||||
|
||||
const pagination = computed(() => {
|
||||
if (!props.payrolls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage: props.payrolls.current_page,
|
||||
perPage: props.payrolls.per_page,
|
||||
lastPage: props.payrolls.last_page,
|
||||
total: props.payrolls.total,
|
||||
};
|
||||
});
|
||||
|
||||
const canClosePeriod = computed(() => (
|
||||
props.currentPeriod?.status === 'open'
|
||||
&& can('payroll.close')
|
||||
));
|
||||
|
||||
function openAdjustModal(payroll: PayrollListItem) {
|
||||
adjustingPayroll.value = payroll;
|
||||
adjustmentModalOpen.value = true;
|
||||
}
|
||||
|
||||
function closePeriod() {
|
||||
if (!props.currentPeriod) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeProcessing.value = true;
|
||||
|
||||
router.post(`/admin/finance/payroll/periods/${props.currentPeriod.id}/close`, {}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
closeConfirmOpen.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menutup periode gaji.');
|
||||
},
|
||||
onFinish: () => {
|
||||
closeProcessing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch(search, (value) => {
|
||||
setSearch(value);
|
||||
});
|
||||
|
||||
watch(selectedPeriodId, (value) => {
|
||||
setFilter('period_id', value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.filters.search,
|
||||
(value) => {
|
||||
search.value = value ?? '';
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.filters.period_id,
|
||||
(value) => {
|
||||
selectedPeriodId.value = value ? String(value) : '';
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head title="Gaji" />
|
||||
|
||||
<AdminLayout>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-bold tracking-tight">
|
||||
Gaji
|
||||
</h2>
|
||||
<p v-if="currentPeriod" class="text-sm text-muted-foreground">
|
||||
Periode {{ currentPeriod.period_label }}
|
||||
<Badge class="ml-2" :variant="currentPeriod.status === 'open' ? 'default' : 'secondary'">
|
||||
{{ currentPeriod.status_label }}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Select v-if="periods.length > 0" v-model="selectedPeriodId">
|
||||
<SelectTrigger class="w-full sm:w-[200px]">
|
||||
<SelectValue placeholder="Pilih periode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="period in periods" :key="period.id" :value="String(period.id)">
|
||||
{{ period.period_label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button v-if="canClosePeriod" variant="outline" class="shrink-0" @click="closeConfirmOpen = true">
|
||||
<Lock class="size-4" />
|
||||
Tutup Periode
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="summary" class="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">
|
||||
Total Belum Dibayar
|
||||
</CardTitle>
|
||||
<Banknote class="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold tracking-tight">
|
||||
{{ summary.total_net_amount_formatted }}
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ summary.unpaid_count }} slip belum dibayar
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">
|
||||
Sudah Dibayar
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold tracking-tight">
|
||||
{{ summary.paid_count }}
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
slip gaji lunas
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card v-if="payrolls && pagination" class="min-w-0">
|
||||
<CardContent class="min-w-0 pt-6">
|
||||
<DataTable v-model:search="search" :columns="columns" :data="payrolls.data"
|
||||
:pagination="pagination" :pagination-links="payrolls.links" :sort="currentSort"
|
||||
@sort-change="setSort" @filters-reset="resetFilters" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-else>
|
||||
<CardContent class="py-12 text-center text-muted-foreground">
|
||||
Belum ada periode gaji. Periode akan dibuat otomatis setiap awal bulan.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<PayrollAdjustmentModal v-if="can('payroll.adjust')" v-model:open="adjustmentModalOpen"
|
||||
:payroll="adjustingPayroll" :adjustment-types="adjustmentTypes" />
|
||||
|
||||
<ConfirmDialog v-if="canClosePeriod" v-model:open="closeConfirmOpen" title="Tutup periode gaji?"
|
||||
description="Pastikan semua gaji sudah dibayar sebelum menutup periode."
|
||||
confirm-label="Tutup Periode" cancel-label="Batal" :loading="closeProcessing" @confirm="closePeriod" />
|
||||
</AdminLayout>
|
||||
</template>
|
||||
73
resources/js/types/payroll.ts
Normal file
73
resources/js/types/payroll.ts
Normal file
@ -0,0 +1,73 @@
|
||||
export type PayrollPeriodItem = {
|
||||
id: number;
|
||||
year: number;
|
||||
month: number;
|
||||
period_label: string;
|
||||
status: string;
|
||||
status_label: string;
|
||||
closed_at_formatted: string | null;
|
||||
};
|
||||
|
||||
export type PayrollListItem = {
|
||||
id: number;
|
||||
employee_id: number;
|
||||
employee_name: string;
|
||||
base_salary: number;
|
||||
base_salary_formatted: string;
|
||||
bonus_amount: number;
|
||||
bonus_amount_formatted: string;
|
||||
deduction_amount: number;
|
||||
deduction_amount_formatted: string;
|
||||
net_amount: number;
|
||||
net_amount_formatted: string;
|
||||
status: string;
|
||||
status_label: string;
|
||||
paid_at_formatted: string | null;
|
||||
can_pay: boolean;
|
||||
can_adjust: boolean;
|
||||
};
|
||||
|
||||
export type PayrollSummary = {
|
||||
total_net_amount: number;
|
||||
total_net_amount_formatted: string;
|
||||
unpaid_count: number;
|
||||
paid_count: number;
|
||||
};
|
||||
|
||||
export type PayrollAdjustmentFormData = {
|
||||
type: string;
|
||||
amount: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type PaginatedPayrolls = {
|
||||
data: PayrollListItem[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
links: Array<{
|
||||
url: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type PayrollPageProps = {
|
||||
periods: PayrollPeriodItem[];
|
||||
currentPeriod: PayrollPeriodItem | null;
|
||||
payrolls: PaginatedPayrolls | null;
|
||||
summary: PayrollSummary | null;
|
||||
adjustmentTypes: SelectOption[];
|
||||
filters: {
|
||||
search: string;
|
||||
sort?: string;
|
||||
direction?: 'asc' | 'desc';
|
||||
period_id?: number;
|
||||
};
|
||||
};
|
||||
@ -5,6 +5,8 @@
|
||||
use App\Http\Controllers\Admin\Finance\CashController;
|
||||
use App\Http\Controllers\Admin\Finance\EmployeeAdvanceController;
|
||||
use App\Http\Controllers\Admin\Finance\ExpenseController;
|
||||
use App\Http\Controllers\Admin\Finance\PayrollController;
|
||||
use App\Http\Controllers\Admin\Finance\PayrollPeriodController;
|
||||
use App\Http\Controllers\Admin\Hr\EmployeeController;
|
||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||
@ -226,6 +228,24 @@
|
||||
->middleware('permission:'.Permission::EMPLOYEE_ADVANCES_PAY->value)
|
||||
->name('pay');
|
||||
});
|
||||
|
||||
Route::prefix('payroll')->name('payroll.')
|
||||
->middleware('permission:'.Permission::PAYROLL_VIEW->value)
|
||||
->group(function () {
|
||||
Route::get('/', [PayrollPeriodController::class, 'index'])->name('index');
|
||||
|
||||
Route::post('periods/{payrollPeriod}/close', [PayrollPeriodController::class, 'close'])
|
||||
->middleware('permission:'.Permission::PAYROLL_CLOSE->value)
|
||||
->name('periods.close');
|
||||
|
||||
Route::post('{payroll}/pay', [PayrollController::class, 'pay'])
|
||||
->middleware('permission:'.Permission::PAYROLL_PAY->value)
|
||||
->name('pay');
|
||||
|
||||
Route::post('{payroll}/adjustments', [PayrollController::class, 'storeAdjustment'])
|
||||
->middleware('permission:'.Permission::PAYROLL_ADJUST->value)
|
||||
->name('adjustments.store');
|
||||
});
|
||||
});
|
||||
|
||||
Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user