feat: add payroll management features including payroll periods, adjustments, and payment processing
- Implemented routes for managing payroll periods, including current, close, and reopen functionalities. - Added payroll payment and cancellation routes. - Introduced payroll adjustments with store and delete functionalities. - Created comprehensive feature tests for payroll management, covering authentication, CRUD operations, and business logic. - Ensured proper handling of payroll adjustments and their impact on payroll totals. - Developed tests for generating payrolls and managing payroll periods, ensuring accurate status transitions and data integrity.
This commit is contained in:
parent
aa46893885
commit
6dbe9da581
85
app/Console/Commands/GeneratePayrollCommand.php
Normal file
85
app/Console/Commands/GeneratePayrollCommand.php
Normal file
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\PayrollPeriodStatus;
|
||||
use App\Enums\PayrollStatus;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Payroll;
|
||||
use App\Models\PayrollPeriod;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class GeneratePayrollCommand extends Command
|
||||
{
|
||||
protected $signature = 'payroll:generate';
|
||||
|
||||
protected $description = 'Generate payroll for all active employees for the current month';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$now = now();
|
||||
$year = $now->year;
|
||||
$month = $now->month;
|
||||
|
||||
$period = PayrollPeriod::firstOrCreate(
|
||||
['year' => $year, 'month' => $month],
|
||||
['status' => PayrollPeriodStatus::OPEN]
|
||||
);
|
||||
|
||||
if ($period->status !== PayrollPeriodStatus::OPEN) {
|
||||
$this->error("Periode gaji {$this->getMonthName($month)} {$year} sudah ditutup.");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$employees = Employee::whereHas('user', fn($q) => $q->where('is_active', true))
|
||||
->where(fn($q) => $q->whereNull('resign_date')->orWhere('resign_date', '>=', $now->toDateString()))
|
||||
->get();
|
||||
|
||||
$existingPayrollEmployeeIds = Payroll::where('payroll_period_id', $period->id)
|
||||
->pluck('employee_id')
|
||||
->toArray();
|
||||
|
||||
$newPayrolls = 0;
|
||||
|
||||
foreach ($employees as $employee) {
|
||||
if (in_array($employee->id, $existingPayrollEmployeeIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Payroll::create([
|
||||
'payroll_period_id' => $period->id,
|
||||
'employee_id' => $employee->id,
|
||||
'base_salary' => $employee->base_salary,
|
||||
'bonus_amount' => 0,
|
||||
'deduction_amount' => 0,
|
||||
'total_amount' => $employee->base_salary,
|
||||
'status' => PayrollStatus::UNPAID,
|
||||
]);
|
||||
|
||||
$newPayrolls++;
|
||||
}
|
||||
|
||||
$this->info("Berhasil generate {$newPayrolls} gaji untuk periode {$this->getMonthName($month)} {$year}.");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function getMonthName(int $month): string
|
||||
{
|
||||
return [
|
||||
1 => 'Januari',
|
||||
2 => 'Februari',
|
||||
3 => 'Maret',
|
||||
4 => 'April',
|
||||
5 => 'Mei',
|
||||
6 => 'Juni',
|
||||
7 => 'Juli',
|
||||
8 => 'Agustus',
|
||||
9 => 'September',
|
||||
10 => 'Oktober',
|
||||
11 => 'November',
|
||||
12 => 'Desember',
|
||||
][$month] ?? '';
|
||||
}
|
||||
}
|
||||
@ -10,5 +10,4 @@ enum PayrollAdjustmentType: string
|
||||
|
||||
case BONUS = 'bonus';
|
||||
case DEDUCTION = 'deduction';
|
||||
case CORRECTION = 'correction';
|
||||
}
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
<?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\Models\PayrollAdjustment;
|
||||
use App\Services\Admin\Finance\PayrollAdjustmentService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class PayrollAdjustmentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PayrollAdjustmentService $service
|
||||
) {}
|
||||
|
||||
public function store(PayrollAdjustmentRequest $request, Payroll $payroll): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->create($payroll, $request->validated()),
|
||||
'Adjustment gaji berhasil ditambahkan.',
|
||||
'admin.finance.payroll-periods.show',
|
||||
['payroll_period' => $payroll->payroll_period_id]
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(PayrollAdjustment $payrollAdjustment): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->delete($payrollAdjustment),
|
||||
'Adjustment gaji berhasil dihapus.',
|
||||
'admin.finance.payroll-periods.show',
|
||||
['payroll_period' => $payrollAdjustment->payroll->payroll_period_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
35
app/Http/Controllers/Admin/Finance/PayrollController.php
Normal file
35
app/Http/Controllers/Admin/Finance/PayrollController.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Payroll;
|
||||
use App\Services\Admin\Finance\PayrollPeriodService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class PayrollController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PayrollPeriodService $service
|
||||
) {}
|
||||
|
||||
public function pay(Payroll $payroll): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->pay($payroll),
|
||||
'Gaji berhasil dibayar.',
|
||||
'admin.finance.payroll-periods.show',
|
||||
['payroll_period' => $payroll->payroll_period_id]
|
||||
);
|
||||
}
|
||||
|
||||
public function cancel(Payroll $payroll): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->cancel($payroll),
|
||||
'Gaji berhasil dibatalkan.',
|
||||
'admin.finance.payroll-periods.show',
|
||||
['payroll_period' => $payroll->payroll_period_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Finance;
|
||||
|
||||
use App\Enums\PayrollPeriodStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PayrollPeriod;
|
||||
use App\Services\Admin\Finance\PayrollPeriodService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PayrollPeriodController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PayrollPeriodService $service
|
||||
) {}
|
||||
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('admin/finance/payroll-period/index', [
|
||||
'payrollPeriods' => $this->service->getAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function current(): RedirectResponse
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$period = PayrollPeriod::firstOrCreate(
|
||||
['year' => $now->year, 'month' => $now->month],
|
||||
['status' => PayrollPeriodStatus::OPEN]
|
||||
);
|
||||
|
||||
return to_route('admin.finance.payroll-periods.show', ['payroll_period' => $period->id]);
|
||||
}
|
||||
|
||||
public function show(PayrollPeriod $payrollPeriod): Response
|
||||
{
|
||||
$period = $this->service->getDetail($payrollPeriod);
|
||||
|
||||
return Inertia::render('admin/finance/payroll-period/show', [
|
||||
'payrollPeriod' => $period,
|
||||
]);
|
||||
}
|
||||
|
||||
public function close(PayrollPeriod $payrollPeriod): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->close($payrollPeriod),
|
||||
'Periode gaji berhasil ditutup.',
|
||||
'admin.finance.payroll-periods.index'
|
||||
);
|
||||
}
|
||||
|
||||
public function reopen(PayrollPeriod $payrollPeriod): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->reopen($payrollPeriod),
|
||||
'Periode gaji berhasil dibuka kembali.',
|
||||
'admin.finance.payroll-periods.index'
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
protected function handleAction(callable $action, string $successMessage, string $redirectRoute): RedirectResponse
|
||||
protected function handleAction(callable $action, string $successMessage, string $redirectRoute, array $parameters = []): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$action();
|
||||
@ -20,6 +20,6 @@ protected function handleAction(callable $action, string $successMessage, string
|
||||
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return to_route($redirectRoute);
|
||||
return to_route($redirectRoute, $parameters);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,12 +3,10 @@
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\ProfileDeleteRequest;
|
||||
use App\Http\Requests\Settings\ProfileUpdateRequest;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@ -19,8 +17,11 @@ class ProfileController extends Controller
|
||||
*/
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$user->load('userProfile');
|
||||
|
||||
return Inertia::render('settings/profile', [
|
||||
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
||||
'mustVerifyEmail' => $user instanceof MustVerifyEmail,
|
||||
'status' => $request->session()->get('status'),
|
||||
]);
|
||||
}
|
||||
@ -43,28 +44,17 @@ public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
|
||||
$user->userProfile()->updateOrCreate(
|
||||
[],
|
||||
['full_name' => $validated['name']],
|
||||
[
|
||||
'full_name' => $validated['name'],
|
||||
'phone_number' => $validated['phone_number'] ?? null,
|
||||
'gender' => $validated['gender'] ?? null,
|
||||
'birth_date' => $validated['birth_date'] ?? null,
|
||||
'address' => $validated['address'] ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]);
|
||||
|
||||
return to_route('profile.edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's profile.
|
||||
*/
|
||||
public function destroy(ProfileDeleteRequest $request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
|
||||
46
app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php
Normal file
46
app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Finance;
|
||||
|
||||
use App\Enums\PayrollAdjustmentType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Override;
|
||||
|
||||
class PayrollAdjustmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function prepareForValidation()
|
||||
{
|
||||
if ($this->has('amount')) {
|
||||
$this->merge([
|
||||
'amount' => str_replace('.', '', $this->amount),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', Rule::in(PayrollAdjustmentType::values())],
|
||||
'amount' => ['required', 'integer', 'min:1'],
|
||||
'description' => ['required', 'string', 'max:100'],
|
||||
'attendance_id' => ['nullable', 'integer', 'exists:attendances,id'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'jenis',
|
||||
'amount' => 'jumlah',
|
||||
'description' => 'keterangan',
|
||||
'attendance_id' => 'presensi',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -30,12 +30,6 @@ protected function bonus(Builder $query): void
|
||||
$query->where('type', PayrollAdjustmentType::BONUS);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function correction(Builder $query): void
|
||||
{
|
||||
$query->where('type', PayrollAdjustmentType::CORRECTION);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function deduction(Builder $query): void
|
||||
{
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PayrollPeriodStatus;
|
||||
use App\Enums\PayrollStatus;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -37,6 +38,18 @@ protected function open(Builder $query): void
|
||||
$query->where('status', PayrollPeriodStatus::OPEN);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function paid(Builder $query): void
|
||||
{
|
||||
$query->where('status', PayrollStatus::PAID);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function cancelled(Builder $query): void
|
||||
{
|
||||
$query->where('status', PayrollStatus::CANCELLED);
|
||||
}
|
||||
|
||||
public function closedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'closed_by_id');
|
||||
|
||||
77
app/Services/Admin/Finance/PayrollAdjustmentService.php
Normal file
77
app/Services/Admin/Finance/PayrollAdjustmentService.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Finance;
|
||||
|
||||
use App\Enums\PayrollAdjustmentType;
|
||||
use App\Enums\PayrollStatus;
|
||||
use App\Models\Payroll;
|
||||
use App\Models\PayrollAdjustment;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PayrollAdjustmentService
|
||||
{
|
||||
public function create(Payroll $payroll, array $data): PayrollAdjustment
|
||||
{
|
||||
if ($payroll->status !== PayrollStatus::UNPAID) {
|
||||
throw ValidationException::withMessages([
|
||||
'payroll' => 'Hanya gaji berstatus belum dibayar yang bisa diadjust.',
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($payroll, $data) {
|
||||
$adjustment = PayrollAdjustment::create([
|
||||
'payroll_id' => $payroll->id,
|
||||
'created_by_id' => auth()->id(),
|
||||
'attendance_id' => $data['attendance_id'] ?? null,
|
||||
'type' => $data['type'],
|
||||
'amount' => $data['amount'],
|
||||
'description' => $data['description'],
|
||||
]);
|
||||
|
||||
$this->recalculatePayroll($payroll);
|
||||
|
||||
return $adjustment;
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(PayrollAdjustment $adjustment): bool
|
||||
{
|
||||
$payroll = $adjustment->payroll;
|
||||
|
||||
if ($payroll->status !== PayrollStatus::UNPAID) {
|
||||
throw ValidationException::withMessages([
|
||||
'payroll' => 'Hanya gaji berstatus belum dibayar yang bisa diadjust.',
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($adjustment, $payroll) {
|
||||
$result = $adjustment->delete();
|
||||
|
||||
$this->recalculatePayroll($payroll);
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
|
||||
private function recalculatePayroll(Payroll $payroll): void
|
||||
{
|
||||
$payroll->refresh();
|
||||
|
||||
$bonuses = $payroll->payrollAdjustments()
|
||||
->where('type', PayrollAdjustmentType::BONUS)
|
||||
->sum('amount');
|
||||
|
||||
$deductions = $payroll->payrollAdjustments()
|
||||
->where('type', PayrollAdjustmentType::DEDUCTION)
|
||||
->sum('amount');
|
||||
|
||||
$total = $payroll->base_salary + $bonuses - $deductions;
|
||||
|
||||
$payroll->update([
|
||||
'bonus_amount' => $bonuses,
|
||||
'deduction_amount' => $deductions,
|
||||
'total_amount' => $total,
|
||||
]);
|
||||
}
|
||||
}
|
||||
150
app/Services/Admin/Finance/PayrollPeriodService.php
Normal file
150
app/Services/Admin/Finance/PayrollPeriodService.php
Normal file
@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Finance;
|
||||
|
||||
use App\Enums\CashTransactionType;
|
||||
use App\Enums\PayrollPeriodStatus;
|
||||
use App\Enums\PayrollStatus;
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Models\Payroll;
|
||||
use App\Models\PayrollPeriod;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PayrollPeriodService
|
||||
{
|
||||
public function getAll(): Collection
|
||||
{
|
||||
return PayrollPeriod::select('id', 'year', 'month', 'status', 'closed_at', 'created_at')
|
||||
->withCount('payrolls')
|
||||
->withSum('payrolls', 'total_amount')
|
||||
->withSum('payrolls', 'bonus_amount')
|
||||
->withSum('payrolls', 'deduction_amount')
|
||||
->withCount(['payrolls as paid_count' => function ($q) {
|
||||
$q->paid();
|
||||
}])
|
||||
->withCount(['payrolls as cancelled_count' => function ($q) {
|
||||
$q->cancelled();
|
||||
}])
|
||||
->latest('year')
|
||||
->latest('month')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getDetail(PayrollPeriod $period): PayrollPeriod
|
||||
{
|
||||
return $period->load([
|
||||
'payrolls' => function ($query) {
|
||||
$query->with(['employee.user.userProfile', 'payrollAdjustments'])
|
||||
->orderBy('id');
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
public function close(PayrollPeriod $period): PayrollPeriod
|
||||
{
|
||||
if ($period->status === PayrollPeriodStatus::CLOSED) {
|
||||
throw ValidationException::withMessages([
|
||||
'period' => 'Periode sudah ditutup.',
|
||||
]);
|
||||
}
|
||||
|
||||
$hasUnpaid = $period->payrolls()
|
||||
->where('status', PayrollStatus::UNPAID)
|
||||
->exists();
|
||||
|
||||
if ($hasUnpaid) {
|
||||
throw ValidationException::withMessages([
|
||||
'period' => 'Masih ada gaji yang belum dibayar.',
|
||||
]);
|
||||
}
|
||||
|
||||
$period->update([
|
||||
'status' => PayrollPeriodStatus::CLOSED,
|
||||
'closed_by_id' => auth()->id(),
|
||||
'closed_at' => now(),
|
||||
]);
|
||||
|
||||
return $period;
|
||||
}
|
||||
|
||||
public function reopen(PayrollPeriod $period): PayrollPeriod
|
||||
{
|
||||
if ($period->status === PayrollPeriodStatus::OPEN) {
|
||||
throw ValidationException::withMessages([
|
||||
'period' => 'Periode sudah terbuka.',
|
||||
]);
|
||||
}
|
||||
|
||||
$period->update([
|
||||
'status' => PayrollPeriodStatus::OPEN,
|
||||
'closed_by_id' => null,
|
||||
'closed_at' => null,
|
||||
]);
|
||||
|
||||
return $period;
|
||||
}
|
||||
|
||||
public function pay(Payroll $payroll): Payroll
|
||||
{
|
||||
if ($payroll->status === PayrollStatus::PAID) {
|
||||
throw ValidationException::withMessages([
|
||||
'payroll' => 'Gaji sudah dibayar.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($payroll->status === PayrollStatus::CANCELLED) {
|
||||
throw ValidationException::withMessages([
|
||||
'payroll' => 'Gaji sudah dibatalkan.',
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($payroll) {
|
||||
$cashAccount = CashAccount::firstOrFail();
|
||||
|
||||
$newBalance = $cashAccount->balance + $payroll->total_amount;
|
||||
$cashAccount->update(['balance' => $newBalance]);
|
||||
|
||||
$cashTransaction = CashTransaction::create([
|
||||
'cash_account_id' => $cashAccount->id,
|
||||
'created_by_id' => auth()->id(),
|
||||
'amount' => $payroll->total_amount,
|
||||
'balance_after' => $newBalance,
|
||||
'type' => CashTransactionType::DEPOSIT,
|
||||
'description' => 'Pembayaran gaji karyawan',
|
||||
]);
|
||||
|
||||
$payroll->update([
|
||||
'status' => PayrollStatus::PAID,
|
||||
'cash_transaction_id' => $cashTransaction->id,
|
||||
'paid_by_id' => auth()->id(),
|
||||
'paid_at' => now(),
|
||||
]);
|
||||
|
||||
return $payroll;
|
||||
});
|
||||
}
|
||||
|
||||
public function cancel(Payroll $payroll): Payroll
|
||||
{
|
||||
if ($payroll->status === PayrollStatus::PAID) {
|
||||
throw ValidationException::withMessages([
|
||||
'payroll' => 'Gaji yang sudah dibayar tidak dapat dibatalkan.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($payroll->status === PayrollStatus::CANCELLED) {
|
||||
throw ValidationException::withMessages([
|
||||
'payroll' => 'Gaji sudah dibatalkan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$payroll->update([
|
||||
'status' => PayrollStatus::CANCELLED,
|
||||
]);
|
||||
|
||||
return $payroll;
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,7 @@ public function definition(): array
|
||||
'payroll_id' => Payroll::factory(),
|
||||
'attendance_id' => Attendance::factory(),
|
||||
'created_by_id' => User::factory(),
|
||||
'type' => fake()->randomElement(['bonus', 'deduction', 'correction']),
|
||||
'type' => fake()->randomElement(['bonus', 'deduction']),
|
||||
'amount' => fake()->numberBetween(10000, 500000),
|
||||
'description' => fake()->sentence(),
|
||||
];
|
||||
|
||||
@ -13,6 +13,7 @@ import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||
import { dashboard } from '@/routes';
|
||||
import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts';
|
||||
import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances';
|
||||
import { current as payrollCurrent, index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods';
|
||||
import { index as expensesIndex } from '@/routes/admin/finance/expenses';
|
||||
import { index as employeesIndex } from '@/routes/admin/hr/employees';
|
||||
import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
|
||||
@ -77,7 +78,7 @@ const keuanganItems: NavMenuItem[] = [
|
||||
{ title: 'Kas Toko', href: cashAccountsIndex.url(), icon: Wallet },
|
||||
{ title: 'Pengeluaran', href: expensesIndex.url(), icon: ArrowUpFromLine },
|
||||
{ title: 'Kasbon', href: employeeAdvancesIndex.url(), icon: HandCoins },
|
||||
{ title: 'Gaji', href: '#', icon: DollarSign },
|
||||
{ title: 'Gaji', href: payrollCurrent.url(), icon: DollarSign },
|
||||
];
|
||||
|
||||
const hrItems: NavMenuItem[] = [
|
||||
|
||||
286
resources/js/pages/admin/finance/payroll-period/columns.tsx
Normal file
286
resources/js/pages/admin/finance/payroll-period/columns.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { ArrowUpDown, Eye, Lock, Unlock } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { Link } from '@inertiajs/react';
|
||||
|
||||
export type PayrollPeriod = {
|
||||
id: number;
|
||||
year: number;
|
||||
month: number;
|
||||
status: 'open' | 'closed';
|
||||
closed_at: string | null;
|
||||
created_at: string;
|
||||
payrolls_count: number;
|
||||
paid_count: number;
|
||||
cancelled_count: number;
|
||||
payrolls_sum_total_amount: number | null;
|
||||
payrolls_sum_bonus_amount: number | null;
|
||||
payrolls_sum_deduction_amount: number | null;
|
||||
};
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
|
||||
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember',
|
||||
];
|
||||
|
||||
function formatPeriod(period: PayrollPeriod): string {
|
||||
return `${MONTH_NAMES[period.month]} ${period.year}`;
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
open: {
|
||||
label: 'Terbuka',
|
||||
className: 'bg-green-100 text-green-800 hover:bg-green-100',
|
||||
},
|
||||
closed: {
|
||||
label: 'Ditutup',
|
||||
className: 'bg-gray-100 text-gray-800 hover:bg-gray-100',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status] ?? statusConfig.open;
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className={config.className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
type CreateColumnsParams = {
|
||||
showUrl: (id: number) => string;
|
||||
handleClose: (period: PayrollPeriod) => void;
|
||||
handleReopen: (period: PayrollPeriod) => void;
|
||||
};
|
||||
|
||||
export function createPayrollPeriodColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<PayrollPeriod>[] {
|
||||
const { showUrl, handleClose, handleReopen } = params;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
headerClassName: 'w-[50px] text-center',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'year',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>Periode</span>
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{formatPeriod(row.original)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'payrolls_count',
|
||||
header: () => <span>Jumlah Karyawan</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-center">{row.getValue('payrolls_count') as number}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'payrolls_sum_total_amount',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>Total Gaji</span>
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">
|
||||
{formatCurrency((row.getValue('payrolls_sum_total_amount') as number) ?? 0)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'payrolls_sum_bonus_amount',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>Bonus</span>
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const value = (row.getValue('payrolls_sum_bonus_amount') as number) ?? 0;
|
||||
|
||||
return (
|
||||
<span className={value > 0 ? 'text-green-600 font-medium' : 'text-muted-foreground'}>
|
||||
{value > 0 ? '+ ' : ''}{formatCurrency(value)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'payrolls_sum_deduction_amount',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>Potongan</span>
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const value = (row.getValue('payrolls_sum_deduction_amount') as number) ?? 0;
|
||||
|
||||
return (
|
||||
<span className={value > 0 ? 'text-red-600 font-medium' : 'text-muted-foreground'}>
|
||||
{value > 0 ? '- ' : ''}{formatCurrency(value)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'payment_status',
|
||||
header: () => <span>Status Bayar</span>,
|
||||
cell: ({ row }) => {
|
||||
const period = row.original;
|
||||
const paid = period.paid_count ?? 0;
|
||||
const cancelled = period.cancelled_count ?? 0;
|
||||
const total = period.payrolls_count ?? 0;
|
||||
const unpaid = total - paid - cancelled;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
{paid > 0 && (
|
||||
<span className="text-green-600">{paid} dibayar</span>
|
||||
)}
|
||||
{unpaid > 0 && (
|
||||
<span className="text-yellow-600">{unpaid} menunggu</span>
|
||||
)}
|
||||
{cancelled > 0 && (
|
||||
<span className="text-red-600">{cancelled} dibatalkan</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => <span>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<span>{getStatusBadge(row.getValue('status') as string)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
meta: {
|
||||
className: 'w-[100px] text-center',
|
||||
headerClassName: 'w-[100px] text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const period = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
asChild
|
||||
>
|
||||
<Link href={showUrl(period.id)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Lihat Detail
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{period.status === 'open' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleClose(period)}
|
||||
>
|
||||
<Lock className="h-4 w-4 text-orange-600" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Tutup Periode
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{period.status === 'closed' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleReopen(period)}
|
||||
>
|
||||
<Unlock className="h-4 w-4 text-blue-600" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Buka Periode
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
112
resources/js/pages/admin/finance/payroll-period/index.tsx
Normal file
112
resources/js/pages/admin/finance/payroll-period/index.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods';
|
||||
import { show as payrollPeriodShow } from '@/routes/admin/finance/payroll-periods';
|
||||
import { close, reopen } from '@/routes/admin/finance/payroll-periods';
|
||||
import { createPayrollPeriodColumns } from './columns';
|
||||
import type { PayrollPeriod } from './columns';
|
||||
|
||||
type Props = {
|
||||
payrollPeriods: PayrollPeriod[];
|
||||
};
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
|
||||
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember',
|
||||
];
|
||||
|
||||
export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
|
||||
const [closing, setClosing] = useState<PayrollPeriod | null>(null);
|
||||
const [reopening, setReopening] = useState<PayrollPeriod | null>(null);
|
||||
|
||||
function handleClose() {
|
||||
if (!closing) return;
|
||||
|
||||
router.post(close(closing.id), {}, {
|
||||
onSuccess: () => setClosing(null),
|
||||
});
|
||||
}
|
||||
|
||||
function handleReopen() {
|
||||
if (!reopening) return;
|
||||
|
||||
router.post(reopen(reopening.id), {}, {
|
||||
onSuccess: () => setReopening(null),
|
||||
});
|
||||
}
|
||||
|
||||
const columns = createPayrollPeriodColumns({
|
||||
showUrl: (id) => payrollPeriodShow(id).url,
|
||||
handleClose: (period) => setClosing(period),
|
||||
handleReopen: (period) => setReopening(period),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Gaji" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Gaji
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={payrollPeriods}
|
||||
searchKey="year"
|
||||
searchPlaceholder="Cari periode..."
|
||||
emptyText="Belum ada periode gaji."
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={closing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setClosing(null);
|
||||
}}
|
||||
title="Tutup Periode Gaji"
|
||||
description={`Apakah Anda yakin ingin menutup periode gaji ${closing ? `${MONTH_NAMES[closing.month]} ${closing.year}` : ''}? Semua gaji harus sudah dibayar sebelum periode ditutup.`}
|
||||
confirmLabel="Tutup"
|
||||
onConfirm={handleClose}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={reopening !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setReopening(null);
|
||||
}}
|
||||
title="Buka Periode Gaji"
|
||||
description={`Apakah Anda yakin ingin membuka kembali periode gaji ${reopening ? `${MONTH_NAMES[reopening.month]} ${reopening.year}` : ''}?`}
|
||||
confirmLabel="Buka"
|
||||
onConfirm={handleReopen}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
PayrollPeriodIndex.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Keuangan',
|
||||
href: payrollPeriodsIndex(),
|
||||
},
|
||||
{
|
||||
title: 'Gaji',
|
||||
href: payrollPeriodsIndex(),
|
||||
},
|
||||
],
|
||||
};
|
||||
300
resources/js/pages/admin/finance/payroll-period/show-columns.tsx
Normal file
300
resources/js/pages/admin/finance/payroll-period/show-columns.tsx
Normal file
@ -0,0 +1,300 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { ArrowUpDown, CircleDollarSign, Pencil, Trash2, XCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
export type Payroll = {
|
||||
id: number;
|
||||
base_salary: number;
|
||||
bonus_amount: number;
|
||||
deduction_amount: number;
|
||||
total_amount: number;
|
||||
status: 'unpaid' | 'paid' | 'cancelled';
|
||||
paid_at: string | null;
|
||||
employee: {
|
||||
user: {
|
||||
user_profile: {
|
||||
full_name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
payroll_adjustments?: PayrollAdjustment[];
|
||||
};
|
||||
|
||||
export type PayrollAdjustment = {
|
||||
id: number;
|
||||
type: 'bonus' | 'deduction';
|
||||
amount: number;
|
||||
description: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
unpaid: {
|
||||
label: 'Belum Dibayar',
|
||||
className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100',
|
||||
},
|
||||
paid: {
|
||||
label: 'Dibayar',
|
||||
className: 'bg-green-100 text-green-800 hover:bg-green-100',
|
||||
},
|
||||
cancelled: {
|
||||
label: 'Dibatalkan',
|
||||
className: 'bg-red-100 text-red-800 hover:bg-red-100',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status] ?? statusConfig.unpaid;
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className={config.className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
type CreateColumnsParams = {
|
||||
handlePay: (payroll: Payroll) => void;
|
||||
handleCancel: (payroll: Payroll) => void;
|
||||
handleAddAdjustment: (payroll: Payroll) => void;
|
||||
handleDeleteAdjustment: (adjustment: PayrollAdjustment, payrollId: number) => void;
|
||||
};
|
||||
|
||||
export function createPayrollColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<Payroll>[] {
|
||||
const { handlePay, handleCancel, handleAddAdjustment, handleDeleteAdjustment } = params;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
headerClassName: 'w-[50px] text-center',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'employee_name',
|
||||
header: () => <span>Nama Karyawan</span>,
|
||||
cell: ({ row }) => {
|
||||
const employee = row.original.employee;
|
||||
|
||||
return <span className="font-medium">{employee?.user?.user_profile?.full_name ?? '-'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'base_salary',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>Gaji Pokok</span>
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>{formatCurrency(row.getValue('base_salary') as number)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'bonus_amount',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>Bonus</span>
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue('bonus_amount') as number;
|
||||
|
||||
return (
|
||||
<span className={value > 0 ? 'text-green-600 font-medium' : ''}>
|
||||
{value > 0 ? '+ ' : ''}{formatCurrency(value)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'deduction_amount',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>Potongan</span>
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue('deduction_amount') as number;
|
||||
|
||||
return (
|
||||
<span className={value > 0 ? 'text-red-600 font-medium' : ''}>
|
||||
{value > 0 ? '- ' : ''}{formatCurrency(value)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'total_amount',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>Total</span>
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-semibold">{formatCurrency(row.getValue('total_amount') as number)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => <span>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<span>{getStatusBadge(row.getValue('status') as string)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'adjustments',
|
||||
header: () => <span>Penyesuaian</span>,
|
||||
cell: ({ row }) => {
|
||||
const payroll = row.original;
|
||||
const adjustments = payroll.payroll_adjustments ?? [];
|
||||
|
||||
if (adjustments.length === 0) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{adjustments.map((adj: PayrollAdjustment) => (
|
||||
<div key={adj.id} className="flex items-center gap-1 text-xs">
|
||||
<span className={adj.type === 'bonus' ? 'text-green-600' : 'text-red-600'}>
|
||||
{adj.type === 'bonus' ? '+' : '-'} {formatCurrency(adj.amount)}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate max-w-[100px]">
|
||||
{adj.description}
|
||||
</span>
|
||||
{payroll.status === 'unpaid' && (
|
||||
<button
|
||||
onClick={() => handleDeleteAdjustment(adj, payroll.id)}
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
meta: {
|
||||
className: 'w-[120px] text-center',
|
||||
headerClassName: 'w-[120px] text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const payroll = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{payroll.status === 'unpaid' && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleAddAdjustment(payroll)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Tambah Adjustment
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handlePay(payroll)}
|
||||
>
|
||||
<CircleDollarSign className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Tandai Dibayar
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleCancel(payroll)}
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Batalkan
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
281
resources/js/pages/admin/finance/payroll-period/show.tsx
Normal file
281
resources/js/pages/admin/finance/payroll-period/show.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import InputError from '@/components/input-error';
|
||||
import { RupiahInput } from '@/components/rupiah-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments';
|
||||
import {
|
||||
index as payrollPeriodsIndex
|
||||
} from '@/routes/admin/finance/payroll-periods';
|
||||
import {
|
||||
cancel as payrollCancel,
|
||||
pay as payrollPay,
|
||||
} from '@/routes/admin/finance/payrolls';
|
||||
import { store as adjustmentStore } from '@/routes/admin/finance/payrolls/adjustments';
|
||||
import { Form, Head, router } from '@inertiajs/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { Payroll, PayrollAdjustment } from './show-columns';
|
||||
import { createPayrollColumns } from './show-columns';
|
||||
|
||||
type Props = {
|
||||
payrollPeriod: {
|
||||
id: number;
|
||||
year: number;
|
||||
month: number;
|
||||
status: string;
|
||||
payrolls: Payroll[];
|
||||
};
|
||||
};
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
|
||||
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember',
|
||||
];
|
||||
|
||||
export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
||||
const [paying, setPaying] = useState<Payroll | null>(null);
|
||||
const [cancelling, setCancelling] = useState<Payroll | null>(null);
|
||||
const [addingAdjustment, setAddingAdjustment] = useState<Payroll | null>(null);
|
||||
const [deletingAdjustment, setDeletingAdjustment] = useState<{ adjustment: PayrollAdjustment; payrollId: number } | null>(null);
|
||||
const [adjustmentType, setAdjustmentType] = useState<string>('bonus');
|
||||
|
||||
function handlePay() {
|
||||
if (!paying) return;
|
||||
|
||||
router.post(payrollPay(paying.id), {}, {
|
||||
onSuccess: () => setPaying(null),
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (!cancelling) return;
|
||||
|
||||
router.post(payrollCancel(cancelling.id), {}, {
|
||||
onSuccess: () => setCancelling(null),
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleteAdjustment() {
|
||||
if (!deletingAdjustment) return;
|
||||
|
||||
router.delete(adjustmentDestroy(deletingAdjustment.adjustment.id), {
|
||||
onSuccess: () => setDeletingAdjustment(null),
|
||||
});
|
||||
}
|
||||
|
||||
const columns = createPayrollColumns({
|
||||
handlePay: (payroll) => setPaying(payroll),
|
||||
handleCancel: (payroll) => setCancelling(payroll),
|
||||
handleAddAdjustment: (payroll) => {
|
||||
setAddingAdjustment(payroll);
|
||||
setAdjustmentType('bonus');
|
||||
},
|
||||
handleDeleteAdjustment: (adjustment, payrollId) => {
|
||||
setDeletingAdjustment({ adjustment, payrollId });
|
||||
},
|
||||
});
|
||||
|
||||
const totalBaseSalary = payrollPeriod.payrolls.reduce((sum, p) => sum + p.base_salary, 0);
|
||||
const totalBonus = payrollPeriod.payrolls.reduce((sum, p) => sum + p.bonus_amount, 0);
|
||||
const totalDeduction = payrollPeriod.payrolls.reduce((sum, p) => sum + p.deduction_amount, 0);
|
||||
const totalAmount = payrollPeriod.payrolls.reduce((sum, p) => sum + p.total_amount, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={`Gaji - ${MONTH_NAMES[payrollPeriod.month]} ${payrollPeriod.year}`} />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Gaji {MONTH_NAMES[payrollPeriod.month]} {payrollPeriod.year}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{payrollPeriod.payrolls.length} karyawan · Status: {payrollPeriod.status === 'open' ? 'Terbuka' : 'Ditutup'}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant='outline'>
|
||||
<a href={payrollPeriodsIndex.url()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Kembali
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Gaji Pokok</p>
|
||||
<p className="text-lg font-semibold">{formatCurrency(totalBaseSalary)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Bonus</p>
|
||||
<p className="text-lg font-semibold text-green-600">{formatCurrency(totalBonus)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Potongan</p>
|
||||
<p className="text-lg font-semibold text-red-600">{formatCurrency(totalDeduction)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Gaji</p>
|
||||
<p className="text-lg font-semibold">{formatCurrency(totalAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={payrollPeriod.payrolls}
|
||||
searchKey="employee_name"
|
||||
searchPlaceholder="Cari karyawan..."
|
||||
emptyText="Belum ada data gaji."
|
||||
/>
|
||||
|
||||
{/* Dialog Tambah Penyesuaian */}
|
||||
<Dialog open={addingAdjustment !== null} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setAddingAdjustment(null);
|
||||
setAdjustmentType('bonus');
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
{addingAdjustment && (
|
||||
<Form
|
||||
action={adjustmentStore(addingAdjustment.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setAddingAdjustment(null);
|
||||
setAdjustmentType('bonus');
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Penyesuaian</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jenis{' '} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input type="hidden" name="type" value={adjustmentType} />
|
||||
<RadioGroup
|
||||
value={adjustmentType}
|
||||
onValueChange={setAdjustmentType}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="bonus" id="bonus" />
|
||||
<Label htmlFor="bonus" className="font-normal">Bonus</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="deduction" id="deduction" />
|
||||
<Label htmlFor="deduction" className="font-normal">Potongan</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<InputError message={errors.type} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RupiahInput name="amount" min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="adjustment-description">
|
||||
Keterangan{' '} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="adjustment-description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAddingAdjustment(null)}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog Konfirmasi Bayar */}
|
||||
<ConfirmDialog
|
||||
open={paying !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPaying(null);
|
||||
}}
|
||||
title="Tandai Dibayar"
|
||||
description={`Apakah Anda yakin ingin menandai gaji "${paying?.employee?.user?.user_profile?.full_name}" sebesar ${formatCurrency(paying?.total_amount ?? 0)} sebagai sudah dibayar? Penyesuaian tidak dapat ditambahkan setelah dibayar.`}
|
||||
confirmLabel="Bayar"
|
||||
onConfirm={handlePay}
|
||||
/>
|
||||
|
||||
{/* Dialog Konfirmasi Batalkan */}
|
||||
<ConfirmDialog
|
||||
open={cancelling !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCancelling(null);
|
||||
}}
|
||||
title="Batalkan Gaji"
|
||||
description={`Apakah Anda yakin ingin membatalkan gaji "${cancelling?.employee?.user?.user_profile?.full_name}"?`}
|
||||
confirmLabel="Batalkan"
|
||||
onConfirm={handleCancel}
|
||||
/>
|
||||
|
||||
{/* Dialog Konfirmasi Hapus Penyesuaian */}
|
||||
<ConfirmDialog
|
||||
open={deletingAdjustment !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeletingAdjustment(null);
|
||||
}}
|
||||
title="Hapus Penyesuaian"
|
||||
description={`Apakah Anda yakin ingin menghapus penyesuaian "${deletingAdjustment?.adjustment.description}" sebesar ${formatCurrency(deletingAdjustment?.adjustment.amount ?? 0)}?`}
|
||||
confirmLabel="Hapus"
|
||||
onConfirm={handleDeleteAdjustment}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
PayrollPeriodShow.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Keuangan',
|
||||
href: payrollPeriodsIndex(),
|
||||
},
|
||||
{
|
||||
title: 'Gaji',
|
||||
href: payrollPeriodsIndex(),
|
||||
},
|
||||
{
|
||||
title: 'Detail',
|
||||
href: '#',
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -1,8 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use App\Console\Commands\GeneratePayrollCommand;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
Schedule::command(GeneratePayrollCommand::class)->monthlyOn(1, '00:00');
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
use App\Http\Controllers\Admin\Finance\CashAccountController;
|
||||
use App\Http\Controllers\Admin\Finance\EmployeeAdvanceController;
|
||||
use App\Http\Controllers\Admin\Finance\ExpenseController;
|
||||
use App\Http\Controllers\Admin\Finance\PayrollAdjustmentController;
|
||||
use App\Http\Controllers\Admin\Finance\PayrollController;
|
||||
use App\Http\Controllers\Admin\Finance\PayrollPeriodController;
|
||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||
use App\Http\Controllers\Admin\Master\ProductController;
|
||||
@ -34,6 +37,17 @@
|
||||
Route::resource('employee-advances', EmployeeAdvanceController::class)->except(['show', 'create', 'edit']);
|
||||
Route::post('employee-advances/{employeeAdvance}/approve', [EmployeeAdvanceController::class, 'approve'])->name('employee-advances.approve');
|
||||
Route::post('employee-advances/{employeeAdvance}/pay', [EmployeeAdvanceController::class, 'pay'])->name('employee-advances.pay');
|
||||
|
||||
Route::get('payroll-periods/current', [PayrollPeriodController::class, 'current'])->name('payroll-periods.current');
|
||||
Route::resource('payroll-periods', PayrollPeriodController::class)->only(['index', 'show']);
|
||||
Route::post('payroll-periods/{payrollPeriod}/close', [PayrollPeriodController::class, 'close'])->name('payroll-periods.close');
|
||||
Route::post('payroll-periods/{payrollPeriod}/reopen', [PayrollPeriodController::class, 'reopen'])->name('payroll-periods.reopen');
|
||||
|
||||
Route::post('payrolls/{payroll}/pay', [PayrollController::class, 'pay'])->name('payrolls.pay');
|
||||
Route::post('payrolls/{payroll}/cancel', [PayrollController::class, 'cancel'])->name('payrolls.cancel');
|
||||
|
||||
Route::post('payrolls/{payroll}/adjustments', [PayrollAdjustmentController::class, 'store'])->name('payrolls.adjustments.store');
|
||||
Route::delete('payroll-adjustments/{payrollAdjustment}', [PayrollAdjustmentController::class, 'destroy'])->name('payroll-adjustments.destroy');
|
||||
});
|
||||
|
||||
Route::prefix('admin/hr')->name('admin.hr.')->group(function () {
|
||||
|
||||
1192
tests/Feature/Admin/Finance/PayrollTest.php
Normal file
1192
tests/Feature/Admin/Finance/PayrollTest.php
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user