feat: implement CRUD functionality for payroll adjustments including update and delete operations
This commit is contained in:
parent
ac373240e7
commit
d076df9d72
@ -94,9 +94,7 @@ enum Permission: string
|
|||||||
case EMPLOYEE_ADVANCES_PAY = 'employee-advances.pay';
|
case EMPLOYEE_ADVANCES_PAY = 'employee-advances.pay';
|
||||||
|
|
||||||
case PAYROLL_VIEW = 'payroll.view';
|
case PAYROLL_VIEW = 'payroll.view';
|
||||||
case PAYROLL_PAY = 'payroll.pay';
|
|
||||||
case PAYROLL_ADJUST = 'payroll.adjust';
|
case PAYROLL_ADJUST = 'payroll.adjust';
|
||||||
case PAYROLL_CLOSE = 'payroll.close';
|
|
||||||
|
|
||||||
case SETTINGS_VIEW = 'settings.view';
|
case SETTINGS_VIEW = 'settings.view';
|
||||||
case SETTINGS_UPDATE = 'settings.update';
|
case SETTINGS_UPDATE = 'settings.update';
|
||||||
@ -197,9 +195,7 @@ public function label(): string
|
|||||||
self::EMPLOYEE_ADVANCES_PAY => 'Pelunasi Kasbon',
|
self::EMPLOYEE_ADVANCES_PAY => 'Pelunasi Kasbon',
|
||||||
|
|
||||||
self::PAYROLL_VIEW => 'Lihat Gaji',
|
self::PAYROLL_VIEW => 'Lihat Gaji',
|
||||||
self::PAYROLL_PAY => 'Bayar Gaji',
|
|
||||||
self::PAYROLL_ADJUST => 'Sesuaikan Gaji',
|
self::PAYROLL_ADJUST => 'Sesuaikan Gaji',
|
||||||
self::PAYROLL_CLOSE => 'Tutup Periode Gaji',
|
|
||||||
|
|
||||||
self::SETTINGS_VIEW => 'Lihat Pengaturan Aplikasi',
|
self::SETTINGS_VIEW => 'Lihat Pengaturan Aplikasi',
|
||||||
self::SETTINGS_UPDATE => 'Ubah Pengaturan Aplikasi',
|
self::SETTINGS_UPDATE => 'Ubah Pengaturan Aplikasi',
|
||||||
@ -247,8 +243,7 @@ public function group(): string
|
|||||||
self::EMPLOYEE_ADVANCES_VIEW, self::EMPLOYEE_ADVANCES_CREATE, self::EMPLOYEE_ADVANCES_UPDATE,
|
self::EMPLOYEE_ADVANCES_VIEW, self::EMPLOYEE_ADVANCES_CREATE, self::EMPLOYEE_ADVANCES_UPDATE,
|
||||||
self::EMPLOYEE_ADVANCES_DELETE, self::EMPLOYEE_ADVANCES_VERIFY,
|
self::EMPLOYEE_ADVANCES_DELETE, self::EMPLOYEE_ADVANCES_VERIFY,
|
||||||
self::EMPLOYEE_ADVANCES_PAY => 'Kasbon',
|
self::EMPLOYEE_ADVANCES_PAY => 'Kasbon',
|
||||||
self::PAYROLL_VIEW, self::PAYROLL_PAY, self::PAYROLL_ADJUST,
|
self::PAYROLL_VIEW, self::PAYROLL_ADJUST => 'Gaji',
|
||||||
self::PAYROLL_CLOSE => 'Gaji',
|
|
||||||
self::SETTINGS_VIEW, self::SETTINGS_UPDATE => 'Pengaturan Aplikasi',
|
self::SETTINGS_VIEW, self::SETTINGS_UPDATE => 'Pengaturan Aplikasi',
|
||||||
self::ACTIVITY_LOGS_VIEW => 'Log Aktivitas',
|
self::ACTIVITY_LOGS_VIEW => 'Log Aktivitas',
|
||||||
self::ROLES_VIEW, self::ROLES_CREATE, self::ROLES_UPDATE,
|
self::ROLES_VIEW, self::ROLES_CREATE, self::ROLES_UPDATE,
|
||||||
|
|||||||
@ -142,9 +142,7 @@ public function permissions(): array
|
|||||||
Permission::EMPLOYEE_ADVANCES_PAY,
|
Permission::EMPLOYEE_ADVANCES_PAY,
|
||||||
|
|
||||||
Permission::PAYROLL_VIEW,
|
Permission::PAYROLL_VIEW,
|
||||||
Permission::PAYROLL_PAY,
|
|
||||||
Permission::PAYROLL_ADJUST,
|
Permission::PAYROLL_ADJUST,
|
||||||
Permission::PAYROLL_CLOSE,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
self::ADMIN_BAHAN_BAKU => [
|
self::ADMIN_BAHAN_BAKU => [
|
||||||
@ -208,9 +206,7 @@ public function permissions(): array
|
|||||||
Permission::EMPLOYEE_ADVANCES_PAY,
|
Permission::EMPLOYEE_ADVANCES_PAY,
|
||||||
|
|
||||||
Permission::PAYROLL_VIEW,
|
Permission::PAYROLL_VIEW,
|
||||||
Permission::PAYROLL_PAY,
|
|
||||||
Permission::PAYROLL_ADJUST,
|
Permission::PAYROLL_ADJUST,
|
||||||
Permission::PAYROLL_CLOSE,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
self::MARKETING => [
|
self::MARKETING => [
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Finance\PayrollAdjustmentRequest;
|
use App\Http\Requests\Admin\Finance\PayrollAdjustmentRequest;
|
||||||
use App\Models\Payroll;
|
use App\Models\Payroll;
|
||||||
|
use App\Models\PayrollAdjustment;
|
||||||
use App\Services\Finance\PayrollService;
|
use App\Services\Finance\PayrollService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
@ -17,23 +18,12 @@ public function __construct(
|
|||||||
private readonly PayrollService $payrollService,
|
private readonly PayrollService $payrollService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function pay(Payroll $payroll): RedirectResponse
|
|
||||||
{
|
|
||||||
$this->payrollService->pay($payroll, auth()->user());
|
|
||||||
|
|
||||||
$this->flashSuccess('Gaji berhasil dibayar.');
|
|
||||||
|
|
||||||
return redirect()->route('admin.finance.payroll.index', [
|
|
||||||
'period_id' => $payroll->payroll_period_id,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function storeAdjustment(PayrollAdjustmentRequest $request, Payroll $payroll): RedirectResponse
|
public function storeAdjustment(PayrollAdjustmentRequest $request, Payroll $payroll): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->payrollService->addAdjustment(
|
$this->payrollService->addAdjustment(
|
||||||
$payroll,
|
$payroll,
|
||||||
$request->validated(),
|
$request->validated(),
|
||||||
auth()->user(),
|
$request->user(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->flashSuccess('Penyesuaian gaji berhasil ditambahkan.');
|
$this->flashSuccess('Penyesuaian gaji berhasil ditambahkan.');
|
||||||
@ -42,4 +32,32 @@ public function storeAdjustment(PayrollAdjustmentRequest $request, Payroll $payr
|
|||||||
'period_id' => $payroll->payroll_period_id,
|
'period_id' => $payroll->payroll_period_id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateAdjustment(PayrollAdjustmentRequest $request, PayrollAdjustment $payrollAdjustment): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->payrollService->updateAdjustment(
|
||||||
|
$payrollAdjustment,
|
||||||
|
$request->validated(),
|
||||||
|
$request->user(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->flashSuccess('Penyesuaian gaji berhasil diperbarui.');
|
||||||
|
|
||||||
|
return redirect()->route('admin.finance.payroll.index', [
|
||||||
|
'period_id' => $payrollAdjustment->payroll->payroll_period_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyAdjustment(PayrollAdjustment $payrollAdjustment): RedirectResponse
|
||||||
|
{
|
||||||
|
$payroll = $payrollAdjustment->payroll;
|
||||||
|
|
||||||
|
$this->payrollService->deleteAdjustment($payrollAdjustment);
|
||||||
|
|
||||||
|
$this->flashSuccess('Penyesuaian gaji berhasil dihapus.');
|
||||||
|
|
||||||
|
return redirect()->route('admin.finance.payroll.index', [
|
||||||
|
'period_id' => $payroll->payroll_period_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,7 @@
|
|||||||
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
||||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\PayrollPeriod;
|
|
||||||
use App\Services\Finance\PayrollService;
|
use App\Services\Finance\PayrollService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@ -44,15 +42,4 @@ public function index(Request $request): Response
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function close(PayrollPeriod $payrollPeriod): RedirectResponse
|
|
||||||
{
|
|
||||||
$this->payrollService->closePeriod($payrollPeriod, auth()->user());
|
|
||||||
|
|
||||||
$this->flashSuccess('Periode gaji berhasil ditutup.');
|
|
||||||
|
|
||||||
return redirect()->route('admin.finance.payroll.index', [
|
|
||||||
'period_id' => $payrollPeriod->id,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,6 @@
|
|||||||
'status_label',
|
'status_label',
|
||||||
'employee_name',
|
'employee_name',
|
||||||
'paid_at_formatted',
|
'paid_at_formatted',
|
||||||
'can_pay',
|
|
||||||
'can_adjust',
|
'can_adjust',
|
||||||
])]
|
])]
|
||||||
class Payroll extends Model
|
class Payroll extends Model
|
||||||
@ -91,15 +90,6 @@ public function canAdjust(): Attribute
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canPay(): Attribute
|
|
||||||
{
|
|
||||||
return Attribute::make(
|
|
||||||
get: fn () => $this->status === PayrollStatus::UNPAID
|
|
||||||
&& $this->relationLoaded('payrollPeriod')
|
|
||||||
&& $this->payrollPeriod?->isOpen(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deductionAmountFormatted(): Attribute
|
public function deductionAmountFormatted(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Models\Employee;
|
use App\Models\Employee;
|
||||||
use App\Models\EmployeeAdvance;
|
use App\Models\EmployeeAdvance;
|
||||||
use App\Models\Payroll;
|
use App\Models\Payroll;
|
||||||
|
use App\Models\PayrollAdjustment;
|
||||||
use App\Models\PayrollPeriod;
|
use App\Models\PayrollPeriod;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\System\PushNotificationService;
|
use App\Services\System\PushNotificationService;
|
||||||
@ -56,30 +57,24 @@ public function resolvePeriod(?int $periodId): ?PayrollPeriod
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{total_total_amount: int, total_total_amount_formatted: string, unpaid_count: int, paid_count: int}
|
* @return array{total_amount: int, total_amount_formatted: string, total_count: int, status: string, status_label: string}
|
||||||
*/
|
*/
|
||||||
public function periodSummary(PayrollPeriod $period): array
|
public function periodSummary(PayrollPeriod $period): array
|
||||||
{
|
{
|
||||||
$totalTotalAmount = (int) Payroll::query()
|
$totalAmount = (int) Payroll::query()
|
||||||
->where('payroll_period_id', $period->id)
|
->where('payroll_period_id', $period->id)
|
||||||
->where('status', PayrollStatus::UNPAID)
|
|
||||||
->sum('total_amount');
|
->sum('total_amount');
|
||||||
|
|
||||||
$unpaidCount = Payroll::query()
|
$totalCount = Payroll::query()
|
||||||
->where('payroll_period_id', $period->id)
|
->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();
|
->count();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'total_total_amount' => $totalTotalAmount,
|
'total_amount' => $totalAmount,
|
||||||
'total_total_amount_formatted' => 'Rp '.number_format($totalTotalAmount, 0, ',', '.'),
|
'total_amount_formatted' => 'Rp '.number_format($totalAmount, 0, ',', '.'),
|
||||||
'unpaid_count' => $unpaidCount,
|
'total_count' => $totalCount,
|
||||||
'paid_count' => $paidCount,
|
'status' => $period->status->value,
|
||||||
|
'status_label' => $period->status->label(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,7 +84,7 @@ public function periodSummary(PayrollPeriod $period): array
|
|||||||
public function paginateForPeriod(PayrollPeriod $period, array $tableQuery): LengthAwarePaginator
|
public function paginateForPeriod(PayrollPeriod $period, array $tableQuery): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
$query = Payroll::query()
|
$query = Payroll::query()
|
||||||
->with(['employee.user.profile', 'payrollPeriod'])
|
->with(['employee.user.profile', 'payrollPeriod', 'adjustments.createdBy.profile'])
|
||||||
->where('payroll_period_id', $period->id)
|
->where('payroll_period_id', $period->id)
|
||||||
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
|
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
|
||||||
$search = $tableQuery['search'];
|
$search = $tableQuery['search'];
|
||||||
@ -108,18 +103,37 @@ public function paginateForPeriod(PayrollPeriod $period, array $tableQuery): Len
|
|||||||
|
|
||||||
public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod
|
public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod
|
||||||
{
|
{
|
||||||
$period = DB::transaction(function () use ($closedBy): PayrollPeriod {
|
$user = $closedBy
|
||||||
|
?? auth()->user()
|
||||||
|
?? User::query()->whereHas('roles', fn ($q) => $q->whereIn('name', [Role::DEVELOPER->value, Role::OWNER->value]))->first()
|
||||||
|
?? User::query()->first();
|
||||||
|
|
||||||
|
$period = DB::transaction(function () use ($user): PayrollPeriod {
|
||||||
$now = now();
|
$now = now();
|
||||||
$year = $now->year;
|
$year = $now->year;
|
||||||
$month = $now->month;
|
$month = $now->month;
|
||||||
|
|
||||||
PayrollPeriod::query()
|
$openPeriods = PayrollPeriod::query()
|
||||||
->where('status', PayrollPeriodStatus::OPEN)
|
->where('status', PayrollPeriodStatus::OPEN)
|
||||||
->update([
|
->get();
|
||||||
'status' => PayrollPeriodStatus::CLOSED,
|
|
||||||
'closed_at' => now(),
|
foreach ($openPeriods as $oldPeriod) {
|
||||||
'closed_by_id' => $closedBy?->id,
|
$unpaidPayrolls = Payroll::query()
|
||||||
]);
|
->where('payroll_period_id', $oldPeriod->id)
|
||||||
|
->where('status', PayrollStatus::UNPAID)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($unpaidPayrolls as $payroll) {
|
||||||
|
if ($user) {
|
||||||
|
$this->pay($payroll, $user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldPeriod->status = PayrollPeriodStatus::CLOSED;
|
||||||
|
$oldPeriod->closed_at = now();
|
||||||
|
$oldPeriod->closed_by_id = $user?->id;
|
||||||
|
$oldPeriod->save();
|
||||||
|
}
|
||||||
|
|
||||||
$period = PayrollPeriod::query()
|
$period = PayrollPeriod::query()
|
||||||
->where('year', $year)
|
->where('year', $year)
|
||||||
@ -144,47 +158,9 @@ public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod
|
|||||||
return $period->fresh();
|
return $period->fresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->pushNotificationService->sendToAll(
|
|
||||||
'📊 Periode Gaji Baru Dibuka',
|
|
||||||
"Periode gaji untuk {$period->period_label} telah dibuka.",
|
|
||||||
'/admin/finance/payrolls',
|
|
||||||
);
|
|
||||||
|
|
||||||
return $period;
|
return $period;
|
||||||
}
|
}
|
||||||
|
|
||||||
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('total_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();
|
|
||||||
|
|
||||||
$this->pushNotificationService->sendToAll(
|
|
||||||
'📊 Periode Gaji Ditutup',
|
|
||||||
"Periode gaji untuk {$period->period_label} telah ditutup.",
|
|
||||||
'/admin/finance/payrolls',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function generatePayrollsForPeriod(PayrollPeriod $period): void
|
public function generatePayrollsForPeriod(PayrollPeriod $period): void
|
||||||
{
|
{
|
||||||
$employees = $this->payrollEligibleEmployees($period);
|
$employees = $this->payrollEligibleEmployees($period);
|
||||||
@ -220,7 +196,7 @@ public function generatePayrollsForPeriod(PayrollPeriod $period): void
|
|||||||
*/
|
*/
|
||||||
public function addAdjustment(Payroll $payroll, array $validated, User $user): void
|
public function addAdjustment(Payroll $payroll, array $validated, User $user): void
|
||||||
{
|
{
|
||||||
$payroll->loadMissing('payrollPeriod');
|
$payroll->loadMissing(['payrollPeriod', 'employee.user']);
|
||||||
|
|
||||||
if (! $payroll->can_adjust) {
|
if (! $payroll->can_adjust) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@ -240,6 +216,72 @@ public function addAdjustment(Payroll $payroll, array $validated, User $user): v
|
|||||||
$payroll->recalculateAmounts();
|
$payroll->recalculateAmounts();
|
||||||
$payroll->save();
|
$payroll->save();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if ($payroll->employee?->user_id) {
|
||||||
|
$typeLabel = PayrollAdjustmentType::from($validated['type'])->label();
|
||||||
|
$formattedAmount = 'Rp '.number_format($validated['amount'], 0, ',', '.');
|
||||||
|
$this->pushNotificationService->sendToUser(
|
||||||
|
"📊 Penyesuaian Gaji: {$typeLabel}",
|
||||||
|
"Gaji periode {$payroll->payrollPeriod->period_label} disesuaikan: {$typeLabel} sebesar {$formattedAmount} ({$validated['description']}).",
|
||||||
|
$payroll->employee->user_id,
|
||||||
|
'/admin/finance/payrolls',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateAdjustment(PayrollAdjustment $adjustment, array $validated, User $user): void
|
||||||
|
{
|
||||||
|
$payroll = $adjustment->payroll;
|
||||||
|
$payroll->loadMissing(['payrollPeriod', 'employee.user']);
|
||||||
|
|
||||||
|
if (! $payroll->can_adjust) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'payroll' => 'Penyesuaian hanya dapat diubah pada gaji yang belum dibayar di periode terbuka.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($payroll, $adjustment, $validated): void {
|
||||||
|
$adjustment->update([
|
||||||
|
'type' => PayrollAdjustmentType::from($validated['type']),
|
||||||
|
'amount' => (int) $validated['amount'],
|
||||||
|
'description' => $validated['description'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payroll->load('adjustments');
|
||||||
|
$payroll->recalculateAmounts();
|
||||||
|
$payroll->save();
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($payroll->employee?->user_id) {
|
||||||
|
$typeLabel = PayrollAdjustmentType::from($validated['type'])->label();
|
||||||
|
$formattedAmount = 'Rp '.number_format($validated['amount'], 0, ',', '.');
|
||||||
|
$this->pushNotificationService->sendToUser(
|
||||||
|
"📊 Penyesuaian Gaji Diperbarui: {$typeLabel}",
|
||||||
|
"Penyesuaian gaji Anda untuk periode {$payroll->payrollPeriod->period_label} diperbarui: {$typeLabel} menjadi {$formattedAmount} ({$validated['description']}).",
|
||||||
|
$payroll->employee->user_id,
|
||||||
|
'/admin/finance/payrolls',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteAdjustment(PayrollAdjustment $adjustment): void
|
||||||
|
{
|
||||||
|
$payroll = $adjustment->payroll;
|
||||||
|
$payroll->loadMissing(['payrollPeriod', 'employee.user']);
|
||||||
|
|
||||||
|
if (! $payroll->can_adjust) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'payroll' => 'Penyesuaian hanya dapat dihapus pada gaji yang belum dibayar di periode terbuka.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($payroll, $adjustment): void {
|
||||||
|
$adjustment->delete();
|
||||||
|
|
||||||
|
$payroll->load('adjustments');
|
||||||
|
$payroll->recalculateAmounts();
|
||||||
|
$payroll->save();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function pay(Payroll $payroll, User $user): void
|
public function pay(Payroll $payroll, User $user): void
|
||||||
|
|||||||
@ -1,14 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm, router } from '@inertiajs/vue3';
|
||||||
import { Save } from '@lucide/vue';
|
import { Save, Pencil, Trash } from '@lucide/vue';
|
||||||
import { watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
@ -23,8 +22,9 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||||
|
import { formErrors } from '@/lib/form';
|
||||||
import { parseRupiah } from '@/lib/rupiah';
|
import { parseRupiah } from '@/lib/rupiah';
|
||||||
import type { PayrollAdjustmentFormData, PayrollListItem, SelectOption } from '@/types/payroll';
|
import type { PayrollAdjustmentFormData, PayrollAdjustmentItem, PayrollListItem, SelectOption } from '@/types/payroll';
|
||||||
|
|
||||||
const open = defineModel<boolean>('open', { default: false });
|
const open = defineModel<boolean>('open', { default: false });
|
||||||
|
|
||||||
@ -39,6 +39,8 @@ const form = useForm<PayrollAdjustmentFormData>({
|
|||||||
description: '',
|
description: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const editingAdjustment = ref<PayrollAdjustmentItem | null>(null);
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
form.reset();
|
form.reset();
|
||||||
form.type = 'bonus';
|
form.type = 'bonus';
|
||||||
@ -47,81 +49,155 @@ function resetForm() {
|
|||||||
|
|
||||||
watch(open, (isOpen) => {
|
watch(open, (isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
editingAdjustment.value = null;
|
||||||
resetForm();
|
resetForm();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function startEdit(adjustment: PayrollAdjustmentItem) {
|
||||||
|
editingAdjustment.value = adjustment;
|
||||||
|
form.type = adjustment.type;
|
||||||
|
form.amount = String(adjustment.amount);
|
||||||
|
form.description = adjustment.description;
|
||||||
|
form.clearErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingAdjustment.value = null;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAdj(adjustment: PayrollAdjustmentItem) {
|
||||||
|
router.delete(`/admin/finance/payroll/adjustments/${adjustment.id}`, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Penyesuaian berhasil dihapus.');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Gagal menghapus penyesuaian.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
if (!props.payroll) {
|
if (!props.payroll) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.transform((data) => ({
|
const payload = form.transform((data) => ({
|
||||||
...data,
|
...data,
|
||||||
amount: parseRupiah(data.amount),
|
amount: parseRupiah(data.amount),
|
||||||
})).post(`/admin/finance/payroll/${props.payroll.id}/adjustments`, {
|
}));
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => {
|
if (editingAdjustment.value) {
|
||||||
open.value = false;
|
payload.put(`/admin/finance/payroll/adjustments/${editingAdjustment.value.id}`, {
|
||||||
},
|
preserveScroll: true,
|
||||||
onError: () => {
|
onSuccess: () => {
|
||||||
toast.error('Gagal menambahkan penyesuaian.');
|
cancelEdit();
|
||||||
},
|
toast.success('Penyesuaian berhasil diperbarui.');
|
||||||
});
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Gagal memperbarui penyesuaian.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
payload.post(`/admin/finance/payroll/${props.payroll.id}/adjustments`, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
resetForm();
|
||||||
|
toast.success('Penyesuaian berhasil ditambahkan.');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Gagal menambahkan penyesuaian.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog v-model:open="open">
|
<Dialog v-model:open="open">
|
||||||
<DialogContent class="sm:max-w-md">
|
<DialogContent class="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Penyesuaian Gaji</DialogTitle>
|
<DialogTitle>Penyesuaian Gaji - {{ payroll?.employee_name }}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form @submit.prevent="submit">
|
<div class="grid gap-6 md:grid-cols-2 mt-4">
|
||||||
<FieldSet>
|
<div class="space-y-4">
|
||||||
<FieldGroup>
|
<h3 class="text-sm font-semibold">Daftar Penyesuaian Saat Ini</h3>
|
||||||
<Field v-if="payroll">
|
<div class="max-h-[300px] overflow-y-auto space-y-2 pr-1">
|
||||||
<FieldLabel>Pegawai</FieldLabel>
|
<div v-if="!payroll?.adjustments || payroll.adjustments.length === 0" class="text-sm text-muted-foreground py-4 text-center">
|
||||||
<p class="text-sm text-muted-foreground">{{ payroll.employee_name }}</p>
|
Belum ada penyesuaian.
|
||||||
</Field>
|
</div>
|
||||||
|
<div v-else v-for="adj in payroll.adjustments" :key="adj.id" class="flex items-center justify-between p-3 rounded-lg border bg-muted/40">
|
||||||
<Field>
|
<div class="space-y-1">
|
||||||
<FieldLabel>Jenis</FieldLabel>
|
<div class="flex items-center gap-2">
|
||||||
<RadioGroup v-model="form.type" class="flex flex-wrap gap-4 pt-1">
|
<span class="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded" :class="adj.type === 'bonus' ? 'bg-primary/10 text-primary' : 'bg-destructive/10 text-destructive'">
|
||||||
<div v-for="option in adjustmentTypes" :key="option.value"
|
{{ adj.type_label }}
|
||||||
class="flex items-center gap-2">
|
</span>
|
||||||
<RadioGroupItem :id="`type-${option.value}`" :value="option.value" />
|
<span class="text-sm font-bold">{{ adj.amount_formatted }}</span>
|
||||||
<Label :for="`type-${option.value}`" class="font-normal">
|
|
||||||
{{ option.label }}
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
<p class="text-xs text-muted-foreground">{{ adj.description }}</p>
|
||||||
<FieldError :message="form.errors.type" />
|
</div>
|
||||||
</Field>
|
<div class="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" class="size-8 text-muted-foreground hover:text-foreground" @click="startEdit(adj)">
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" class="size-8 text-destructive hover:bg-destructive/10" @click="deleteAdj(adj)">
|
||||||
|
<Trash class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Field>
|
<div class="space-y-4 border-t pt-4 md:border-t-0 md:pt-0 md:border-l md:pl-6">
|
||||||
<FieldLabel for="amount">Jumlah</FieldLabel>
|
<h3 class="text-sm font-semibold">
|
||||||
<RupiahInput id="amount" v-model="form.amount" />
|
{{ editingAdjustment ? 'Ubah Penyesuaian' : 'Tambah Penyesuaian Baru' }}
|
||||||
<FieldError :message="form.errors.amount" />
|
</h3>
|
||||||
</Field>
|
<form @submit.prevent="submit">
|
||||||
|
<FieldSet>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel required>Jenis</FieldLabel>
|
||||||
|
<RadioGroup v-model="form.type" class="flex gap-4 pt-1">
|
||||||
|
<div v-for="option in adjustmentTypes" :key="option.value" class="flex items-center gap-2">
|
||||||
|
<RadioGroupItem :id="`type-${option.value}`" :value="option.value" required/>
|
||||||
|
<Label :for="`type-${option.value}`" class="font-normal">
|
||||||
|
{{ option.label }}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
<FieldError :errors="formErrors(form, 'type')" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="description">Keterangan</FieldLabel>
|
<FieldLabel for="amount" required>Jumlah</FieldLabel>
|
||||||
<Textarea id="description" v-model="form.description" rows="3"
|
<RupiahInput id="amount" v-model="form.amount" required/>
|
||||||
:maxlength="FIELD_LIMITS.description" />
|
<FieldError :errors="formErrors(form, 'amount')" />
|
||||||
<FieldError :message="form.errors.description" />
|
</Field>
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</FieldSet>
|
|
||||||
|
|
||||||
<DialogFooter class="mt-4">
|
<Field>
|
||||||
<Button type="submit" :disabled="form.processing">
|
<FieldLabel for="description" required>Keterangan</FieldLabel>
|
||||||
<Save class="size-4" />
|
<Textarea id="description" v-model="form.description" rows="3"
|
||||||
Simpan
|
:maxlength="FIELD_LIMITS.description" placeholder="Contoh: Kinerja Bagus" required/>
|
||||||
</Button>
|
<FieldError :errors="formErrors(form, 'description')" />
|
||||||
</DialogFooter>
|
</Field>
|
||||||
</form>
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 mt-4">
|
||||||
|
<Button v-if="editingAdjustment" type="button" variant="outline" @click="cancelEdit">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" :disabled="form.processing">
|
||||||
|
<Save class="size-4" />
|
||||||
|
{{ editingAdjustment ? 'Perbarui' : 'Simpan' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { router } from '@inertiajs/vue3';
|
import { SlidersHorizontal } from '@lucide/vue';
|
||||||
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 { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useCan } from '@/composables/useCan';
|
import { useCan } from '@/composables/useCan';
|
||||||
@ -18,26 +14,6 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { can } = useCan();
|
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: (errors) => {
|
|
||||||
toast.error(errors.amount || errors.payroll || 'Gagal membayar gaji.');
|
|
||||||
},
|
|
||||||
onFinish: () => {
|
|
||||||
payProcessing.value = false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -51,19 +27,5 @@ function payPayroll() {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Sesuaikan Gaji</TooltipContent>
|
<TooltipContent>Sesuaikan Gaji</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog v-if="can('payroll.pay')" v-model:open="payConfirmOpen" title="Bayar gaji?"
|
|
||||||
:description="`Gaji ${payroll.total_amount_formatted} untuk ${payroll.employee_name} akan dibayar dari kas.`"
|
|
||||||
confirm-label="Bayar" cancel-label="Batal" :loading="payProcessing" @confirm="payPayroll" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<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 ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||||
|
import PayrollAdjustmentModal from '@/components/admin/finance/payroll/PayrollAdjustmentModal.vue';
|
||||||
|
import { createColumns } from '@/components/admin/finance/payroll/columns';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import Empty from '@/components/ui/empty/Empty.vue';
|
||||||
|
import EmptyDescription from '@/components/ui/empty/EmptyDescription.vue';
|
||||||
|
import EmptyHeader from '@/components/ui/empty/EmptyHeader.vue';
|
||||||
|
import EmptyTitle from '@/components/ui/empty/EmptyTitle.vue';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -18,27 +18,47 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useCan } from '@/composables/useCan';
|
import { useCan } from '@/composables/useCan';
|
||||||
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
|
import {
|
||||||
|
useDataTableQuery,
|
||||||
|
useDataTableQuerySync,
|
||||||
|
} from '@/composables/useDataTableQuery';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
import type { DataTableSort } from '@/types/data-table';
|
import type { DataTableSort } from '@/types/data-table';
|
||||||
import type { PayrollListItem, PayrollPageProps } from '@/types/payroll';
|
import type { PayrollListItem, PayrollPageProps } from '@/types/payroll';
|
||||||
|
import { Head, router } from '@inertiajs/vue3';
|
||||||
|
import { Banknote } from '@lucide/vue';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
|
||||||
const props = defineProps<PayrollPageProps>();
|
const props = defineProps<PayrollPageProps>();
|
||||||
|
|
||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
const search = ref(props.filters.search ?? '');
|
const search = ref(props.filters.search ?? '');
|
||||||
const selectedPeriodId = ref(props.filters.period_id ? String(props.filters.period_id) : '');
|
const selectedPeriodId = ref(
|
||||||
|
props.filters.period_id ? String(props.filters.period_id) : '',
|
||||||
|
);
|
||||||
const adjustmentModalOpen = ref(false);
|
const adjustmentModalOpen = ref(false);
|
||||||
const adjustingPayroll = ref<PayrollListItem | null>(null);
|
const adjustingPayroll = ref<PayrollListItem | null>(null);
|
||||||
const closeConfirmOpen = ref(false);
|
const currentAdjustingPayroll = computed(() => {
|
||||||
const closeProcessing = ref(false);
|
const current = adjustingPayroll.value;
|
||||||
|
if (!current || !props.payrolls?.data) {
|
||||||
const { query, setSearch, setSort, setFilter, resetFilters, syncFromServer } = useDataTableQuery({
|
return current;
|
||||||
url: '/admin/finance/payroll',
|
}
|
||||||
initial: { ...props.filters, period_id: props.filters.period_id ? String(props.filters.period_id) : '' },
|
return props.payrolls.data.find((p) => p.id === current.id) ?? current;
|
||||||
filterKeys: ['period_id'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
useDataTableQuerySync(() => props.filters, syncFromServer);
|
||||||
|
|
||||||
const columns = computed(() => createColumns(openAdjustModal));
|
const columns = computed(() => createColumns(openAdjustModal));
|
||||||
@ -67,37 +87,11 @@ const pagination = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const canClosePeriod = computed(() => (
|
|
||||||
props.currentPeriod?.status === 'open'
|
|
||||||
&& can('payroll.close')
|
|
||||||
));
|
|
||||||
|
|
||||||
function openAdjustModal(payroll: PayrollListItem) {
|
function openAdjustModal(payroll: PayrollListItem) {
|
||||||
adjustingPayroll.value = payroll;
|
adjustingPayroll.value = payroll;
|
||||||
adjustmentModalOpen.value = true;
|
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) => {
|
watch(search, (value) => {
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
});
|
});
|
||||||
@ -122,72 +116,96 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<Head title="Gaji" />
|
<Head title="Gaji" />
|
||||||
|
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div
|
||||||
|
class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<h2 class="text-2xl font-bold tracking-tight">
|
<h2 class="text-2xl font-bold tracking-tight">Gaji</h2>
|
||||||
Gaji
|
|
||||||
</h2>
|
|
||||||
<p v-if="currentPeriod" class="text-sm text-muted-foreground">
|
<p v-if="currentPeriod" class="text-sm text-muted-foreground">
|
||||||
Periode {{ currentPeriod.period_label }}
|
Periode {{ currentPeriod.period_label }}
|
||||||
<Badge class="ml-2" :variant="currentPeriod.status === 'open' ? 'default' : 'secondary'">
|
<Badge
|
||||||
|
class="ml-2"
|
||||||
|
:variant="
|
||||||
|
currentPeriod.status === 'open'
|
||||||
|
? 'default'
|
||||||
|
: 'secondary'
|
||||||
|
"
|
||||||
|
>
|
||||||
{{ currentPeriod.status_label }}
|
{{ currentPeriod.status_label }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center">
|
<div
|
||||||
|
class="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
<Select v-if="periods.length > 0" v-model="selectedPeriodId">
|
<Select v-if="periods.length > 0" v-model="selectedPeriodId">
|
||||||
<SelectTrigger class="w-full sm:w-[200px]">
|
<SelectTrigger class="w-full sm:w-[200px]">
|
||||||
<SelectValue placeholder="Pilih periode" />
|
<SelectValue placeholder="Pilih periode" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem v-for="period in periods" :key="period.id" :value="String(period.id)">
|
<SelectItem
|
||||||
|
v-for="period in periods"
|
||||||
|
:key="period.id"
|
||||||
|
:value="String(period.id)"
|
||||||
|
>
|
||||||
{{ period.period_label }}
|
{{ period.period_label }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Button v-if="canClosePeriod" variant="outline" class="shrink-0" @click="closeConfirmOpen = true">
|
|
||||||
<Lock class="size-4" />
|
|
||||||
Tutup Periode
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="summary" class="grid gap-4 md:grid-cols-2">
|
<div v-if="summary" class="grid gap-4 md:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader
|
||||||
<CardTitle class="text-sm font-medium text-muted-foreground">
|
class="flex flex-row items-center justify-between space-y-0 pb-2"
|
||||||
Total Belum Dibayar
|
>
|
||||||
|
<CardTitle
|
||||||
|
class="text-sm font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
Total Gaji Periode
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Banknote class="size-4 text-muted-foreground" />
|
<Banknote class="size-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="text-3xl font-bold tracking-tight">
|
<div class="text-3xl font-bold tracking-tight">
|
||||||
{{ summary.total_total_amount_formatted }}
|
{{ summary.total_amount_formatted }}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
{{ summary.unpaid_count }} slip belum dibayar
|
{{ summary.total_count }} slip gaji
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader
|
||||||
<CardTitle class="text-sm font-medium text-muted-foreground">
|
class="flex flex-row items-center justify-between space-y-0 pb-2"
|
||||||
Sudah Dibayar
|
>
|
||||||
|
<CardTitle
|
||||||
|
class="text-sm font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
Status Pembayaran
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="text-3xl font-bold tracking-tight">
|
<div class="flex items-center">
|
||||||
{{ summary.paid_count }}
|
<Badge
|
||||||
|
class="text-sm px-2.5 py-0.5 font-semibold"
|
||||||
|
:variant="
|
||||||
|
summary.status === 'open'
|
||||||
|
? 'outline'
|
||||||
|
: 'default'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ summary.status === 'open' ? 'Belum Dibayar' : 'Sudah Dibayar' }}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
slip gaji lunas
|
{{ summary.status === 'open' ? 'Akan dibayar otomatis saat periode ditutup' : 'Telah dibayarkan oleh sistem' }}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -195,23 +213,35 @@ watch(
|
|||||||
|
|
||||||
<Card v-if="payrolls && pagination" class="min-w-0">
|
<Card v-if="payrolls && pagination" class="min-w-0">
|
||||||
<CardContent class="min-w-0 pt-6">
|
<CardContent class="min-w-0 pt-6">
|
||||||
<DataTable v-model:search="search" :columns="columns" :data="payrolls.data" :pagination="pagination"
|
<DataTable
|
||||||
:pagination-links="payrolls.links" :sort="currentSort" @sort-change="setSort"
|
v-model:search="search"
|
||||||
@filters-reset="resetFilters" />
|
:columns="columns"
|
||||||
|
:data="payrolls.data"
|
||||||
|
:pagination="pagination"
|
||||||
|
:pagination-links="payrolls.links"
|
||||||
|
:sort="currentSort"
|
||||||
|
@sort-change="setSort"
|
||||||
|
@filters-reset="resetFilters"
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card v-else>
|
<div v-else class="rounded-md border px-6 py-10">
|
||||||
<CardContent class="py-12 text-center text-muted-foreground">
|
<Empty>
|
||||||
Belum ada periode gaji. Periode akan dibuat otomatis setiap awal bulan.
|
<EmptyHeader>
|
||||||
</CardContent>
|
<EmptyTitle>Data tidak ditemukan</EmptyTitle>
|
||||||
</Card>
|
<EmptyDescription>
|
||||||
|
Silakan lakukan pencarian untuk menemukan data yang Anda
|
||||||
|
cari.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
<PayrollAdjustmentModal v-if="can('payroll.adjust')" v-model:open="adjustmentModalOpen"
|
<PayrollAdjustmentModal
|
||||||
:payroll="adjustingPayroll" :adjustment-types="adjustmentTypes" />
|
v-if="can('payroll.adjust')"
|
||||||
|
v-model:open="adjustmentModalOpen"
|
||||||
<ConfirmDialog v-if="canClosePeriod" v-model:open="closeConfirmOpen" title="Tutup periode gaji?"
|
:payroll="currentAdjustingPayroll"
|
||||||
description="Pastikan semua gaji sudah dibayar sebelum menutup periode." confirm-label="Tutup Periode"
|
:adjustment-types="adjustmentTypes"
|
||||||
cancel-label="Batal" :loading="closeProcessing" @confirm="closePeriod" />
|
/> </AdminLayout>
|
||||||
</AdminLayout>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -8,6 +8,18 @@ export type PayrollPeriodItem = {
|
|||||||
closed_at_formatted: string | null;
|
closed_at_formatted: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PayrollAdjustmentItem = {
|
||||||
|
id: number;
|
||||||
|
payroll_id: number;
|
||||||
|
type: string;
|
||||||
|
type_label: string;
|
||||||
|
amount: number;
|
||||||
|
amount_formatted: string;
|
||||||
|
description: string;
|
||||||
|
created_at_formatted: string;
|
||||||
|
created_by_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PayrollListItem = {
|
export type PayrollListItem = {
|
||||||
id: number;
|
id: number;
|
||||||
employee_id: number;
|
employee_id: number;
|
||||||
@ -25,13 +37,15 @@ export type PayrollListItem = {
|
|||||||
paid_at_formatted: string | null;
|
paid_at_formatted: string | null;
|
||||||
can_pay: boolean;
|
can_pay: boolean;
|
||||||
can_adjust: boolean;
|
can_adjust: boolean;
|
||||||
|
adjustments?: PayrollAdjustmentItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PayrollSummary = {
|
export type PayrollSummary = {
|
||||||
total_total_amount: number;
|
total_amount: number;
|
||||||
total_total_amount_formatted: string;
|
total_amount_formatted: string;
|
||||||
unpaid_count: number;
|
total_count: number;
|
||||||
paid_count: number;
|
status: string;
|
||||||
|
status_label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PayrollAdjustmentFormData = {
|
export type PayrollAdjustmentFormData = {
|
||||||
|
|||||||
@ -403,17 +403,17 @@
|
|||||||
->group(function () {
|
->group(function () {
|
||||||
Route::get('/', [PayrollPeriodController::class, 'index'])->name('index');
|
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'])
|
Route::post('{payroll}/adjustments', [PayrollController::class, 'storeAdjustment'])
|
||||||
->middleware('permission:'.Permission::PAYROLL_ADJUST->value)
|
->middleware('permission:'.Permission::PAYROLL_ADJUST->value)
|
||||||
->name('adjustments.store');
|
->name('adjustments.store');
|
||||||
|
|
||||||
|
Route::put('adjustments/{payrollAdjustment}', [PayrollController::class, 'updateAdjustment'])
|
||||||
|
->middleware('permission:'.Permission::PAYROLL_ADJUST->value)
|
||||||
|
->name('adjustments.update');
|
||||||
|
|
||||||
|
Route::delete('adjustments/{payrollAdjustment}', [PayrollController::class, 'destroyAdjustment'])
|
||||||
|
->middleware('permission:'.Permission::PAYROLL_ADJUST->value)
|
||||||
|
->name('adjustments.destroy');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user