refactor: consolidate payroll management by merging PayrollPeriodController into PayrollController, enhancing data handling and UI components for improved functionality

This commit is contained in:
Yoga Pangestu 2026-06-21 01:02:12 +07:00
parent 50eaced126
commit 0803cbeb96
8 changed files with 127 additions and 217 deletions

View File

@ -2,33 +2,55 @@
namespace App\Http\Controllers\Admin\Finance;
use App\Enums\PayrollAdjustmentType;
use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\PayrollAdjustmentRequest;
use App\Models\Payroll;
use App\Models\PayrollAdjustment;
use App\Services\Finance\PayrollService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PayrollController extends Controller
{
use FlashesEntityMessage;
use FlashesEntityMessage,ParsesDataTableQuery;
public function __construct(
private readonly PayrollService $payrollService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$periodId = $request->integer('period_id') ?: null;
$period = $this->payrollService->resolvePeriod($periodId);
return Inertia::render('admin/finance/payroll/Index', [
'periods' => $this->payrollService->listPeriods(),
'currentPeriod' => $period,
'payrolls' => $period
? $this->payrollService->paginateForPeriod($period, $tableQuery, auth()->user())
: null,
'summary' => $period
? $this->payrollService->periodSummary($period, auth()->user())
: null,
'adjustmentTypes' => PayrollAdjustmentType::selectOptions(),
'filters' => $this->dataTableFilters($tableQuery, [
'period_id' => $period?->id,
]),
]);
}
public function storeAdjustment(PayrollAdjustmentRequest $request, Payroll $payroll): RedirectResponse
{
$user = $request->user();
if ($user && ! $user->hasAnyRole(['owner', 'developer', 'direktur'])) {
abort(403, 'Anda tidak memiliki akses untuk menambah penyesuaian gaji.');
}
$this->payrollService->addAdjustment(
$payroll,
$request->validated(),
$user,
auth()->user(),
);
$this->flashSuccess('Penyesuaian gaji berhasil ditambahkan.');
@ -40,15 +62,10 @@ public function storeAdjustment(PayrollAdjustmentRequest $request, Payroll $payr
public function updateAdjustment(PayrollAdjustmentRequest $request, PayrollAdjustment $payrollAdjustment): RedirectResponse
{
$user = $request->user();
if ($user && ! $user->hasAnyRole(['owner', 'developer', 'direktur'])) {
abort(403, 'Anda tidak memiliki akses untuk mengubah penyesuaian gaji.');
}
$this->payrollService->updateAdjustment(
$payrollAdjustment,
$request->validated(),
$user,
auth()->user(),
);
$this->flashSuccess('Penyesuaian gaji berhasil diperbarui.');
@ -60,11 +77,6 @@ public function updateAdjustment(PayrollAdjustmentRequest $request, PayrollAdjus
public function destroyAdjustment(PayrollAdjustment $payrollAdjustment): RedirectResponse
{
$user = auth()->user();
if ($user && ! $user->hasAnyRole(['owner', 'developer', 'direktur'])) {
abort(403, 'Anda tidak memiliki akses untuk menghapus penyesuaian gaji.');
}
$payroll = $payrollAdjustment->payroll;
$this->payrollService->deleteAdjustment($payrollAdjustment);

View File

@ -1,45 +0,0 @@
<?php
namespace App\Http\Controllers\Admin\Finance;
use App\Enums\PayrollAdjustmentType;
use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Services\Finance\PayrollService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PayrollPeriodController extends Controller
{
use FlashesEntityMessage, ParsesDataTableQuery;
public function __construct(
private readonly PayrollService $payrollService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$periodId = $request->integer('period_id') ?: null;
$period = $this->payrollService->resolvePeriod($periodId);
$user = $request->user();
return Inertia::render('admin/finance/payroll/Index', [
'periods' => $this->payrollService->listPeriods(),
'currentPeriod' => $period,
'payrolls' => $period
? $this->payrollService->paginateForPeriod($period, $tableQuery, $user)
: null,
'summary' => $period
? $this->payrollService->periodSummary($period, $user)
: null,
'adjustmentTypes' => PayrollAdjustmentType::selectOptions(),
'filters' => array_merge(
$this->dataTableFilters($tableQuery),
['period_id' => $period?->id],
),
]);
}
}

View File

@ -19,7 +19,6 @@
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class PayrollService
{
@ -90,7 +89,7 @@ public function paginateForPeriod(PayrollPeriod $period, array $tableQuery, User
$query = Payroll::query()
->with(['employee.user.profile', 'payrollPeriod', 'adjustments.createdBy.profile'])
->where('payroll_period_id', $period->id)
->when(! $user->hasAnyRole(['owner', 'developer', 'direktur']), function (Builder $query) use ($user): void {
->when(! $user->hasAnyRole([Role::OWNER->value, Role::DEVELOPER->value, Role::DIREKTUR->value]), function (Builder $query) use ($user): void {
$employeeId = $user->employee?->id ?? -1;
$query->where('employee_id', $employeeId);
})
@ -204,13 +203,6 @@ public function generatePayrollsForPeriod(PayrollPeriod $period): void
*/
public function addAdjustment(Payroll $payroll, array $validated, User $user): void
{
$payroll->loadMissing(['payrollPeriod', 'employee.user']);
if (! $payroll->can_adjust) {
throw ValidationException::withMessages([
'payroll' => 'Penyesuaian hanya dapat ditambahkan pada gaji yang belum dibayar di periode terbuka.',
]);
}
DB::transaction(function () use ($payroll, $validated, $user): void {
$payroll->adjustments()->create([
@ -242,18 +234,11 @@ public function updateAdjustment(PayrollAdjustment $adjustment, array $validated
$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'],
]);
$adjustment->type = PayrollAdjustmentType::from($validated['type']);
$adjustment->amount = (int) $validated['amount'];
$adjustment->description = $validated['description'];
$adjustment->save();
$payroll->load('adjustments');
$payroll->recalculateAmounts();
@ -277,12 +262,6 @@ 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();
@ -296,18 +275,6 @@ public function pay(Payroll $payroll, User $user): void
{
$payroll->loadMissing(['payrollPeriod', 'employee.user.profile']);
if ($payroll->status !== PayrollStatus::UNPAID) {
throw ValidationException::withMessages([
'payroll' => 'Gaji ini sudah dibayar.',
]);
}
if (! $payroll->payrollPeriod?->isOpen()) {
throw ValidationException::withMessages([
'payroll' => 'Periode gaji sudah ditutup.',
]);
}
if ($payroll->total_amount <= 0) {
DB::transaction(function () use ($payroll, $user): void {
$payroll->status = PayrollStatus::PAID;

View File

@ -1,10 +1,9 @@
<script setup lang="ts">
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import PayrollAdjustmentModal from '@/components/admin/finance/payroll/PayrollAdjustmentModal.vue';
import { createColumns } from '@/components/admin/finance/payroll/columns';
import { Head } from '@inertiajs/vue3';
import { Banknote } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { DataTable } from '@/components/data-table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import Empty from '@/components/ui/empty/Empty.vue';
import EmptyDescription from '@/components/ui/empty/EmptyDescription.vue';
@ -25,10 +24,8 @@ import {
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableSort } from '@/types/data-table';
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';
import PayrollAdjustmentModal from './form/PayrollAdjustmentModal.vue';
import { createColumns } from './table/columns';
const props = defineProps<PayrollPageProps>();
@ -41,9 +38,11 @@ const adjustmentModalOpen = ref(false);
const adjustingPayroll = ref<PayrollListItem | null>(null);
const currentAdjustingPayroll = computed(() => {
const current = adjustingPayroll.value;
if (!current || !props.payrolls?.data) {
return current;
}
return props.payrolls.data.find((p) => p.id === current.id) ?? current;
});
@ -116,42 +115,31 @@ watch(
</script>
<template>
<Head title="Gaji" />
<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">
<h2 class="text-2xl font-bold tracking-tight">Gaji</h2>
<p v-if="currentPeriod" class="text-sm text-muted-foreground">
Periode {{ currentPeriod.period_label }}
<Badge
class="ml-2"
:variant="
currentPeriod.status === 'open'
? 'default'
: 'secondary'
"
>
<Badge class="ml-2" :variant="currentPeriod.status === 'open'
? 'default'
: 'secondary'
">
{{ currentPeriod.status_label }}
</Badge>
</p>
</div>
<div
class="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center"
>
<div class="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center">
<Select v-if="periods.length > 0" v-model="selectedPeriodId">
<SelectTrigger class="w-full sm:w-[200px]">
<SelectValue placeholder="Pilih periode" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="period in periods"
:key="period.id"
:value="String(period.id)"
>
<SelectItem v-for="period in periods" :key="period.id" :value="String(period.id)">
{{ period.period_label }}
</SelectItem>
</SelectContent>
@ -159,70 +147,28 @@ watch(
</div>
</div>
<div v-if="summary" class="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader
class="flex flex-row items-center justify-between space-y-0 pb-2"
>
<CardTitle
class="text-sm font-medium text-muted-foreground"
>
Total Gaji Periode
</CardTitle>
<Banknote class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-3xl font-bold tracking-tight">
{{ summary.total_amount_formatted }}
</div>
<p class="mt-1 text-sm text-muted-foreground">
{{ summary.total_count }} slip gaji
</p>
</CardContent>
</Card>
<Card>
<CardHeader
class="flex flex-row items-center justify-between space-y-0 pb-2"
>
<CardTitle
class="text-sm font-medium text-muted-foreground"
>
Status Pembayaran
</CardTitle>
</CardHeader>
<CardContent>
<div class="flex items-center">
<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>
<p class="mt-2 text-sm text-muted-foreground">
{{ summary.status === 'open' ? 'Akan dibayar otomatis saat periode ditutup' : 'Telah dibayarkan oleh sistem' }}
</p>
</CardContent>
</Card>
</div>
<Card v-if="summary">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">
Total Gaji Periode
</CardTitle>
<Banknote class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-3xl font-bold tracking-tight">
{{ summary.total_amount_formatted }}
</div>
<p class="mt-1 text-sm text-muted-foreground">
{{ summary.total_count }} slip gaji
</p>
</CardContent>
</Card>
<Card v-if="payrolls && pagination" class="min-w-0">
<CardContent class="min-w-0 pt-6">
<DataTable
v-model:search="search"
:columns="columns"
:data="payrolls.data"
:pagination="pagination"
:pagination-links="payrolls.links"
:sort="currentSort"
@sort-change="setSort"
@filters-reset="resetFilters"
/>
<DataTable v-model:search="search" :columns="columns" :data="payrolls.data" :pagination="pagination"
:pagination-links="payrolls.links" :sort="currentSort" @sort-change="setSort"
@filters-reset="resetFilters" />
</CardContent>
</Card>
@ -238,10 +184,7 @@ watch(
</Empty>
</div>
<PayrollAdjustmentModal
v-if="can('payroll.adjust')"
v-model:open="adjustmentModalOpen"
:payroll="currentAdjustingPayroll"
:adjustment-types="adjustmentTypes"
/> </AdminLayout>
<PayrollAdjustmentModal v-if="can('payroll.adjust')" v-model:open="adjustmentModalOpen"
:payroll="currentAdjustingPayroll" :adjustment-types="adjustmentTypes" />
</AdminLayout>
</template>

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import { useForm, router } from '@inertiajs/vue3';
import { Save, Pencil, Trash } from '@lucide/vue';
import { Pencil, Save, Trash2 } from '@lucide/vue';
import { ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { RupiahInput } from '@/components/form/rupiah-input';
import { Button } from '@/components/ui/button';
import {
@ -21,6 +22,11 @@ import {
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import { parseRupiah } from '@/lib/rupiah';
@ -40,6 +46,8 @@ const form = useForm<PayrollAdjustmentFormData>({
});
const editingAdjustment = ref<PayrollAdjustmentItem | null>(null);
const deleteConfirmOpen = ref(false);
const deleteTarget = ref<PayrollAdjustmentItem | null>(null);
function resetForm() {
form.reset();
@ -94,7 +102,6 @@ function submit() {
preserveScroll: true,
onSuccess: () => {
cancelEdit();
toast.success('Penyesuaian berhasil diperbarui.');
},
onError: () => {
toast.error('Gagal memperbarui penyesuaian.');
@ -105,7 +112,6 @@ function submit() {
preserveScroll: true,
onSuccess: () => {
resetForm();
toast.success('Penyesuaian berhasil ditambahkan.');
},
onError: () => {
toast.error('Gagal menambahkan penyesuaian.');
@ -126,26 +132,47 @@ function submit() {
<div class="space-y-4">
<h3 class="text-sm font-semibold">Daftar Penyesuaian Saat Ini</h3>
<div class="max-h-[300px] overflow-y-auto space-y-2 pr-1">
<div v-if="!payroll?.adjustments || payroll.adjustments.length === 0" class="text-sm text-muted-foreground py-4 text-center">
<div v-if="!payroll?.adjustments || payroll.adjustments.length === 0"
class="text-sm text-muted-foreground py-4 text-center">
Belum ada penyesuaian.
</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">
<div v-else v-for="adj in payroll.adjustments" :key="adj.id"
class="flex items-center justify-between p-3 rounded-lg border"
:class="adj.type === 'bonus' ? 'border-primary/20 bg-primary/5' : 'border-destructive/20 bg-destructive/5'">
<div class="space-y-1">
<div class="flex items-center gap-2">
<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'">
{{ adj.type_label }}
<span class="text-sm font-bold"
:class="adj.type === 'bonus' ? 'text-primary' : 'text-destructive'">
{{ adj.amount_formatted }}
</span>
<span class="text-sm font-bold">{{ adj.amount_formatted }}</span>
</div>
<p class="text-xs text-muted-foreground">{{ adj.description }}</p>
</div>
<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>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon"
class="size-8 text-muted-foreground hover:text-foreground"
@click="startEdit(adj)">
<Pencil class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent class="z-210">
<p>Ubah</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon"
class="size-8 text-destructive hover:bg-destructive/10"
@click="deleteTarget = adj; deleteConfirmOpen = true">
<Trash2 class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent class="z-210">
<p>Hapus</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
@ -161,8 +188,10 @@ function submit() {
<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/>
<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>
@ -173,14 +202,15 @@ function submit() {
<Field>
<FieldLabel for="amount" required>Jumlah</FieldLabel>
<RupiahInput id="amount" v-model="form.amount" required/>
<RupiahInput id="amount" v-model="form.amount" required />
<FieldError :errors="formErrors(form, 'amount')" />
</Field>
<Field>
<FieldLabel for="description" required>Keterangan</FieldLabel>
<Textarea id="description" v-model="form.description" rows="3"
:maxlength="FIELD_LIMITS.description" placeholder="Contoh: Kinerja Bagus" required/>
:maxlength="FIELD_LIMITS.description" placeholder="Contoh: Kinerja Bagus"
required />
<FieldError :errors="formErrors(form, 'description')" />
</Field>
</FieldGroup>
@ -200,4 +230,8 @@ function submit() {
</div>
</DialogContent>
</Dialog>
<ConfirmDialog v-model:open="deleteConfirmOpen" title="Hapus Penyesuaian?"
:description="`Penyesuaian ${deleteTarget?.type_label} ${deleteTarget?.amount_formatted} akan dihapus.`"
confirm-label="Hapus" cancel-label="Batal" destructive @confirm="deleteAdj(deleteTarget!)" />
</template>

View File

@ -1,9 +1,9 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import DataTableActions from '@/components/admin/finance/payroll/data-table-actions.vue';
import { DataTableColumnHeader } from '@/components/data-table';
import { Badge } from '@/components/ui/badge';
import type { PayrollListItem } from '@/types/payroll';
import DataTableActions from './data-table-actions.vue';
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
return status === 'paid' ? 'secondary' : 'outline';

View File

@ -5,7 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useCan } from '@/composables/useCan';
import type { PayrollListItem } from '@/types/payroll';
const props = defineProps<{
defineProps<{
payroll: PayrollListItem;
}>();

View File

@ -9,7 +9,6 @@
use App\Http\Controllers\Admin\Finance\EmployeeAdvanceController;
use App\Http\Controllers\Admin\Finance\ExpenseController;
use App\Http\Controllers\Admin\Finance\PayrollController;
use App\Http\Controllers\Admin\Finance\PayrollPeriodController;
use App\Http\Controllers\Admin\Hr\AttendanceController;
use App\Http\Controllers\Admin\Hr\EmployeeController;
use App\Http\Controllers\Admin\Hr\LeaveRequestController;
@ -421,7 +420,7 @@
Route::prefix('payroll')->name('payroll.')
->middleware('permission:'.Permission::PAYROLL_VIEW->value)
->group(function () {
Route::get('/', [PayrollPeriodController::class, 'index'])->name('index');
Route::get('/', [PayrollController::class, 'index'])->name('index');
Route::post('{payroll}/adjustments', [PayrollController::class, 'storeAdjustment'])
->middleware('permission:'.Permission::PAYROLL_ADJUST->value)