feat: implement payroll management module with CRUD operations, including payroll generation, adjustments, and user salary history tracking

This commit is contained in:
Yoga Pangestu 2026-04-18 16:07:31 +07:00
parent 7089188d4d
commit 69131d8f8f
20 changed files with 1182 additions and 1 deletions

View File

@ -0,0 +1,74 @@
<?php
namespace App\Console\Commands;
use App\Models\Payroll;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
class GeneratePayrollCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'payroll:generate {period?}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate payroll for all users for a given period (YYYY-MM)';
/**
* Execute the console command.
*/
public function handle()
{
$periodMonth = $this->argument('period') ?? Carbon::now()->format('Y-m');
$periodMonthFormatted = Carbon::parse($periodMonth)->translatedFormat('F Y');
$users = User::with('profile')->get();
$count = 0;
try {
DB::transaction(function () use ($users, $periodMonth, &$count) {
foreach ($users as $user) {
$exists = Payroll::where('user_id', $user->id)
->where('period_month', $periodMonth)
->exists();
if (! $exists) {
$baseSalary = $user->profile?->base_salary ?? 0;
Payroll::create([
'user_id' => $user->id,
'period_month' => $periodMonth,
'base_salary' => $baseSalary,
'bonus' => 0,
'deduction' => 0,
'total_salary' => $baseSalary,
'is_paid' => false,
]);
$count++;
}
}
});
} catch (Throwable $e) {
Log::error('Payroll generate failed', [
'error' => $e->getMessage(),
]);
$this->info('Gagal generate data penggajian, silakan hubungi pengembang.');
return;
}
$this->info("$count data penggajian berhasil digenerate untuk periode $periodMonthFormatted.");
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum SalaryAdjustmentType: string
{
case BONUS = 'bonus';
case DEDUCTION = 'deduction';
public function label(): string
{
return match ($this) {
self::BONUS => 'Bonus',
self::DEDUCTION => 'Potongan',
};
}
}

View File

@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers\Admin\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\SalaryAdjustmentRequest;
use App\Models\Payroll;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
use Throwable;
class PayrollController extends Controller
{
public function index(): Response
{
$currentMonth = Carbon::now()->format('Y-m');
return Inertia::render('admin/finance/payroll/index', [
'payrolls' => Payroll::with(['user.profile', 'adjustments'])
->where('period_month', '!=', $currentMonth)
->latest()
->get(),
'currentMonthPayrolls' => Payroll::with(['user.profile', 'adjustments'])
->where('period_month', $currentMonth)
->get(),
]);
}
public function generate(): RedirectResponse
{
Artisan::call('payroll:generate');
$output = Artisan::output();
return redirect()->back()->with('success', $output ?: 'Proses generate selesai.');
}
public function update(SalaryAdjustmentRequest $request, Payroll $payroll): RedirectResponse
{
$validated = $request->validated();
try {
DB::transaction(function () use ($payroll, $validated) {
$payroll->adjustments()->delete();
$totalBonus = 0;
$totalDeduction = 0;
if (isset($validated['adjustments'])) {
foreach ($validated['adjustments'] as $adj) {
$payroll->adjustments()->create([
'type' => $adj['type'],
'amount' => $adj['amount'],
'description' => $adj['description'],
]);
if ($adj['type'] === 'bonus') {
$totalBonus += $adj['amount'];
} else {
$totalDeduction += $adj['amount'];
}
}
}
$payroll->update([
'bonus' => $totalBonus,
'deduction' => $totalDeduction,
'total_salary' => $payroll->base_salary + $totalBonus - $totalDeduction,
]);
});
} catch (Throwable $e) {
Log::error('Payroll update failed', [
'payroll_id' => $payroll->id,
'error' => $e->getMessage(),
]);
return back()->withInput()->with('error', 'Gagal memperbarui data. Silakan coba lagi.');
}
return redirect()->back()->with('success', 'Data berhasil diperbarui');
}
public function togglePaid(Payroll $payroll): RedirectResponse
{
$payroll->update([
'is_paid' => ! $payroll->is_paid,
'paid_at' => ! $payroll->is_paid ? Carbon::now() : null,
]);
return redirect()->back()->with('success', 'Status pembayaran berhasil diubah');
}
public function destroy(Payroll $payroll): RedirectResponse
{
$payroll->delete();
return redirect()->back()->with('success', 'Data berhasil dihapus');
}
public function bulkDestroy(Request $request): RedirectResponse
{
$ids = $request->input('ids');
Payroll::whereIn('id', $ids)->delete();
return redirect()->back()->with('success', 'Data terpilih berhasil dihapus');
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Admin\Finance;
use App\Enums\SalaryAdjustmentType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SalaryAdjustmentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'adjustments' => ['nullable', 'array'],
'adjustments.*.amount' => ['required', 'integer', 'min:0'],
'adjustments.*.type' => ['required', 'string', Rule::in(SalaryAdjustmentType::cases())],
'adjustments.*.description' => ['required', 'string', 'max:100'],
];
}
}

82
app/Models/Payroll.php Normal file
View File

@ -0,0 +1,82 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends([
'base_salary_formatted',
'bonus_formatted',
'deduction_formatted',
'total_salary_formatted',
'period_month_formatted',
])]
class Payroll extends Model
{
use HasFactory, SoftDeletes;
protected function casts(): array
{
return [
'base_salary' => 'integer',
'bonus' => 'integer',
'deduction' => 'integer',
'total_salary' => 'integer',
'is_paid' => 'boolean',
];
}
protected function baseSalaryFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->base_salary ? 'Rp '.number_format($this->base_salary, 0, ',', '.') : null,
);
}
protected function bonusFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->bonus ? 'Rp '.number_format($this->bonus, 0, ',', '.') : null,
);
}
protected function deductionFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->deduction ? 'Rp '.number_format($this->deduction, 0, ',', '.') : null,
);
}
protected function totalSalaryFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->total_salary ? 'Rp '.number_format($this->total_salary, 0, ',', '.') : null,
);
}
protected function periodMonthFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->period_month ? Carbon::parse($this->period_month)->translatedFormat('F Y') : null,
);
}
public function adjustments(): HasMany
{
return $this->hasMany(PayrollAdjustment::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Enums\SalaryAdjustmentType;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends(['amount_formatted'])]
class PayrollAdjustment extends Model
{
use HasFactory, SoftDeletes;
protected function casts(): array
{
return [
'type' => SalaryAdjustmentType::class,
'amount' => 'integer',
];
}
protected function amountFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->amount ? 'Rp '.number_format($this->amount, 0, ',', '.') : null,
);
}
public function payroll(): BelongsTo
{
return $this->belongsTo(Payroll::class);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
class SalaryHistory extends Model
{
use HasFactory, SoftDeletes;
protected function casts(): array
{
return [
'old_salary' => 'integer',
'new_salary' => 'integer',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -42,8 +42,18 @@ public function expenses(): HasMany
return $this->hasMany(Expense::class);
}
public function payrolls(): HasMany
{
return $this->hasMany(Payroll::class);
}
public function profile(): HasOne
{
return $this->hasOne(UserProfile::class);
}
public function salaryHistories(): HasMany
{
return $this->hasMany(SalaryHistory::class);
}
}

View File

@ -2,6 +2,7 @@
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@ -22,6 +23,9 @@
AddLinkHeadersForPreloadedAssets::class,
]);
})
->withSchedule(function (Schedule $schedule) {
$schedule->command('payroll:generate')->monthlyOn(1, '00:00');
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('payrolls', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('period_month', 7);
$table->unsignedInteger('base_salary');
$table->unsignedInteger('bonus');
$table->unsignedInteger('deduction');
$table->unsignedInteger('total_salary');
$table->boolean('is_paid')->default(false);
$table->dateTime('paid_at')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('payrolls');
}
};

View File

@ -0,0 +1,34 @@
<?php
use App\Enums\SalaryAdjustmentType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('payroll_adjustments', function (Blueprint $table) {
$table->id();
$table->foreignId('payroll_id')->constrained()->cascadeOnDelete();
$table->enum('type', SalaryAdjustmentType::cases());
$table->string('description', 100);
$table->unsignedInteger('amount');
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('payroll_adjustments');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('salary_histories', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->unsignedInteger('old_salary');
$table->unsignedInteger('new_salary');
$table->date('effective_date');
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('salary_histories');
}
};

View File

@ -1,5 +1,5 @@
import { Link } from '@inertiajs/react';
import { Boxes, LayoutGrid, List, User, Wallet } from 'lucide-react';
import { Boxes, Currency, DollarSign, LayoutGrid, List, User, Wallet, WalletCardsIcon } from 'lucide-react';
import AppLogo from '@/components/app-logo';
import { NavMain } from '@/components/nav-main';
import {
@ -16,6 +16,7 @@ import category from '@/routes/category';
import type { NavItem } from '@/types';
import product from '@/routes/product';
import expense from '@/routes/expense';
import payroll from '@/routes/payroll';
import user from '@/routes/user';
const mainNavItems: NavItem[] = [
@ -50,6 +51,11 @@ const financeNavItems: NavItem[] = [
href: expense.index().url,
icon: Wallet,
},
{
title: 'Penggajian',
href: payroll.index().url,
icon: DollarSign,
},
];
export function AppSidebar() {

View File

@ -0,0 +1,103 @@
import { useState } from 'react';
import { Payroll } from '@/types';
import { router } from '@inertiajs/react';
import payrollRoutes from '@/routes/payroll';
import { toast } from 'sonner';
export function usePayrollIndex() {
const [isFormOpen, setIsFormOpen] = useState(false);
const [selectedPayroll, setSelectedPayroll] = useState<Payroll | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [payrollToDelete, setPayrollToDelete] = useState<Payroll | null>(null);
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
const [rowSelection, setRowSelection] = useState({});
const [isGenerating, setIsGenerating] = useState(false);
const onGenerate = () => {
setIsGenerating(true);
router.post(payrollRoutes.generate().url, {}, {
onSuccess: (response: any) => {
console.log(response)
toast.success(response.props.flash.success);
setIsGenerating(false);
},
onError: (response: any) => {
console.log(response)
toast.error(response.props.flash.error);
setIsGenerating(false);
},
onFinish: () => setIsGenerating(false),
});
};
const onEdit = (payroll: Payroll) => {
setSelectedPayroll(payroll);
setIsFormOpen(true);
};
const onDelete = (payroll: Payroll) => {
setPayrollToDelete(payroll);
setIsDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (payrollToDelete) {
router.delete(payrollRoutes.destroy(payrollToDelete.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsDeleteDialogOpen(false);
setPayrollToDelete(null);
setRowSelection({});
},
});
}
};
const confirmBulkDelete = () => {
router.post(payrollRoutes.bulkDestroy().url, {
ids: rowsToDelete.map((row: any) => row.id),
_method: 'DELETE'
}, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
setIsBulkDeleteDialogOpen(false);
setRowsToDelete([]);
setRowSelection({});
},
});
};
const onTogglePaid = (id: number) => {
router.patch(payrollRoutes.togglePaid(id).url, {}, {
onSuccess: (response: any) => toast.success(response.props.flash.success),
});
};
const closeForm = () => {
setIsFormOpen(false);
setTimeout(() => setSelectedPayroll(null), 200);
};
return {
isFormOpen,
selectedPayroll,
isDeleteDialogOpen,
isBulkDeleteDialogOpen,
payrollToDelete,
rowsToDelete,
rowSelection,
isGenerating,
setRowSelection,
setRowsToDelete,
setIsDeleteDialogOpen,
setIsBulkDeleteDialogOpen,
onGenerate,
onEdit,
onDelete,
confirmDelete,
confirmBulkDelete,
onTogglePaid,
closeForm,
};
}

View File

@ -0,0 +1,200 @@
import { Head } from '@inertiajs/react';
import type { Payroll } from '@/types';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Trash2 } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { usePayrollIndex } from './hooks/use-payroll-index';
import { Badge } from '@/components/ui/badge';
import { getColumns } from './partials/columns';
import { PayrollFormModal } from './partials/payroll-form-modal';
export default function PayrollIndex({
payrolls,
currentMonthPayrolls,
}: {
payrolls: Payroll[],
currentMonthPayrolls: Payroll[],
}) {
const {
isFormOpen,
selectedPayroll,
isDeleteDialogOpen,
isBulkDeleteDialogOpen,
payrollToDelete,
rowsToDelete,
rowSelection,
isGenerating,
setRowSelection,
setRowsToDelete,
setIsDeleteDialogOpen,
setIsBulkDeleteDialogOpen,
onGenerate,
onEdit,
onDelete,
confirmDelete,
confirmBulkDelete,
onTogglePaid,
closeForm,
} = usePayrollIndex();
const columns = getColumns({ onEdit, onDelete, onTogglePaid });
return (
<div className="flex flex-col gap-6 p-6">
<Head title="Penggajian" />
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Penggajian</h1>
</div>
<Button onClick={onGenerate} disabled={isGenerating}>
Generate
</Button>
</div>
<div className="flex flex-col gap-3">
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{currentMonthPayrolls.map((payroll) => (
<Card
key={payroll.id}
className="border-none shadow-lg bg-card/50 backdrop-blur-sm hover:scale-[1.02] transition-transform cursor-pointer"
onClick={() => onEdit(payroll)}
>
<CardContent className="p-4">
<div className="flex justify-between items-start mb-3">
<div className="flex flex-col">
<span className="font-bold text-sm line-clamp-1">{payroll.user?.name}</span>
<span className="text-[10px] text-muted-foreground">{payroll.period_month_formatted}</span>
</div>
<Badge variant={payroll.is_paid ? 'default' : 'outline'} className={payroll.is_paid ? 'bg-green-500 hover:bg-green-600 text-[10px]' : 'text-[10px]'}>
{payroll.is_paid ? 'Lunas' : 'Pending'}
</Badge>
</div>
<div className="space-y-1">
<div className="flex justify-between text-[10px] text-muted-foreground">
<span>Gaji Pokok</span>
<span>{payroll.base_salary_formatted}</span>
</div>
{payroll.adjustments?.map((adj, idx) => (
<div key={idx} className="flex justify-between text-[10px] text-muted-foreground italic">
<span>{adj.description}</span>
<span className={adj.type === 'bonus' ? 'text-green-600' : 'text-red-500'}>
{adj.type === 'bonus' ? '+' : '-'}{adj.amount_formatted}
</span>
</div>
))}
<div className="flex justify-between text-[10px] text-muted-foreground pt-1 border-t">
<span>Gaji Bersih</span>
<span className="font-medium text-foreground">{payroll.total_salary_formatted}</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
<PayrollFormModal
isOpen={isFormOpen}
onClose={closeForm}
payroll={selectedPayroll}
/>
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
<CardContent className="p-0">
<DataTable
columns={columns}
data={payrolls}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
filters={[
{
columnId: 'is_paid',
title: 'Status',
options: [
{ label: 'Dibayar', value: 'true' },
{ label: 'Pending', value: 'false' },
]
}
]}
bulkActions={[
{
label: 'Hapus Terpilih',
onClick: (rows) => {
setRowsToDelete(rows);
setIsBulkDeleteDialogOpen(true);
},
icon: Trash2,
variant: 'destructive'
},
]}
/>
</CardContent>
</Card>
{/* Single Delete Confirmation */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
<Trash2 className="size-5" />
</AlertDialogMedia>
<AlertDialogTitle>Hapus data penggajian?</AlertDialogTitle>
<AlertDialogDescription>
Tindakan ini tidak dapat dibatalkan. Data penggajian untuk <strong>{payrollToDelete?.user?.name}</strong> periode <strong>{payrollToDelete?.period_month}</strong> akan dihapus secara permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} variant="destructive">Hapus</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Bulk Delete Confirmation */}
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
<Trash2 className="size-5" />
</AlertDialogMedia>
<AlertDialogTitle>Hapus {rowsToDelete.length} data penggajian?</AlertDialogTitle>
<AlertDialogDescription>
Tindakan ini tidak dapat dibatalkan. <strong>{rowsToDelete.length}</strong> item yang terpilih akan dihapus secara permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
<AlertDialogAction
onClick={confirmBulkDelete}
variant="destructive"
>
Hapus
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
PayrollIndex.layout = {
breadcrumbs: [
{
title: 'Keuangan',
},
],
};

View File

@ -0,0 +1,133 @@
import { ColumnDef } from '@tanstack/react-table';
import { Payroll } from '@/types';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { PencilRuler, Trash2 } from 'lucide-react';
interface ColumnProps {
onEdit: (payroll: Payroll) => void;
onDelete: (payroll: Payroll) => void;
onTogglePaid: (id: number) => void;
}
export const getColumns = ({ onEdit, onDelete, onTogglePaid }: ColumnProps): ColumnDef<Payroll>[] => [
{
accessorKey: "user.name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Pegawai" />
),
cell: ({ row }) => row.original.user?.name || '-',
meta: { title: "Pegawai" },
},
{
accessorKey: "period_month",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Periode" />
),
cell: ({ row }) => row.original.period_month_formatted,
meta: { title: "Periode" },
},
{
accessorKey: "total_salary",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Rincian Gaji" />
),
cell: ({ row }) => {
const payroll = row.original;
return (
<div className="flex flex-col gap-1 py-1 min-w-[200px]">
<div className="flex justify-between text-[10px] text-muted-foreground uppercase tracking-tighter">
<span>Gaji Pokok</span>
<span className="font-medium text-foreground">{payroll.base_salary_formatted}</span>
</div>
{payroll.adjustments && payroll.adjustments.length > 0 && (
<div className="space-y-0.5">
{payroll.adjustments.map((adj, idx) => (
<div key={idx} className="flex justify-between text-[9px] text-muted-foreground italic">
<span className="line-clamp-1 max-w-[120px]">{adj.description}</span>
<span className={adj.type === 'bonus' ? 'text-green-600' : 'text-red-500'}>
{adj.type === 'bonus' ? '+' : '-'}{adj.amount_formatted}
</span>
</div>
))}
</div>
)}
<div className="flex justify-between text-xs font-bold pt-1 border-t border-dashed mt-1">
<span className="uppercase text-[10px]">Gaji Bersih</span>
<span className="text-primary">{payroll.total_salary_formatted}</span>
</div>
</div>
);
},
meta: { title: "Total Gaji" },
},
{
accessorKey: "is_paid",
header: "Status",
cell: ({ row }) => {
const payroll = row.original;
return (
<div className="flex items-center gap-2">
<Switch
checked={payroll.is_paid}
onCheckedChange={() => onTogglePaid(payroll.id)}
/>
<Badge
variant={payroll.is_paid ? 'default' : 'outline'}
className={payroll.is_paid ? 'bg-green-500/10 text-green-600 border-green-200 dark:bg-green-500/20 dark:text-green-400 dark:border-green-900' : ''}
>
{payroll.is_paid ? 'Dibayar' : 'Pending'}
</Badge>
</div>
);
},
meta: { title: "Status" },
},
{
id: "actions",
header: "Aksi",
cell: ({ row }) => {
const payroll = row.original;
return (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className='text-yellow-600'
onClick={() => onEdit(payroll)}
>
<PencilRuler className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Ubah / Sesuaikan</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className='text-red-600'
onClick={() => onDelete(payroll)}
>
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Hapus</p>
</TooltipContent>
</Tooltip>
</div>
);
},
meta: { title: "Aksi" },
},
];

View File

@ -0,0 +1,191 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldGroup } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Payroll } from '@/types';
import { useForm } from '@inertiajs/react';
import { useEffect } from 'react';
import payrollRoutes from '@/routes/payroll';
import { toast } from 'sonner';
import { Plus, Trash2 } from 'lucide-react';
import { NumericFormat } from 'react-number-format';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
interface PayrollFormModalProps {
isOpen: boolean;
onClose: () => void;
payroll: Payroll | null;
}
export function PayrollFormModal({ isOpen, onClose, payroll }: PayrollFormModalProps) {
const { data, setData, patch, processing, errors, reset, clearErrors } = useForm({
adjustments: [] as { type: 'bonus' | 'deduction', amount: number, description: string }[],
});
useEffect(() => {
if (payroll) {
setData({
adjustments: payroll.adjustments?.map(adj => ({
type: adj.type,
amount: adj.amount,
description: adj.description
})) || [],
});
} else {
reset();
}
}, [payroll]);
const handleClose = () => {
onClose();
setTimeout(() => {
reset();
clearErrors();
}, 200);
};
const addAdjustment = () => {
setData('adjustments', [
...data.adjustments,
{ type: 'bonus', amount: 0, description: '' }
]);
};
const removeAdjustment = (index: number) => {
const newAdjustments = [...data.adjustments];
newAdjustments.splice(index, 1);
setData('adjustments', newAdjustments);
};
const updateAdjustment = (index: number, key: string, value: any) => {
const newAdjustments = [...data.adjustments];
newAdjustments[index] = { ...newAdjustments[index], [key]: value };
setData('adjustments', newAdjustments);
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (payroll) {
patch(payrollRoutes.update(payroll.id).url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
handleClose();
},
});
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Penyesuaian Penggajian - {payroll?.user?.name}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit}>
<div className="max-h-[60vh] overflow-y-auto px-1 py-2">
<FieldGroup>
<div className="flex items-center justify-between border-b pb-2 mb-4">
<h3 className="text-sm font-medium">Bonus & Potongan</h3>
<Button type="button" variant="outline" size="sm" onClick={addAdjustment}>
Tambah
</Button>
</div>
{data.adjustments.length === 0 && (
<Empty>
<EmptyHeader>
<EmptyTitle>Ooops...</EmptyTitle>
<EmptyDescription>
Tidak ada data yang ditemukan.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
{data.adjustments.map((adj, index) => (
<div key={index} className="flex gap-3 items-start border-l-2 border-primary/20 pl-4 py-2 mb-4 group relative">
<div className="flex flex-col gap-3 flex-1">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[10px] uppercase mb-1">Jenis</Label>
<RadioGroup
value={adj.type}
onValueChange={(val) => updateAdjustment(index, 'type', val)}
className="flex items-center gap-6 w-fit"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="bonus" id={`bonus-${index}`} />
<Label htmlFor={`bonus-${index}`}>Bonus</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="deduction" id={`deduction-${index}`} />
<Label htmlFor={`deduction-${index}`}>Potongan</Label>
</div>
</RadioGroup>
</div>
<div>
<Label className="text-[10px] uppercase mb-1">Jumlah</Label>
<NumericFormat
id="amount"
customInput={Input}
thousandSeparator="."
decimalSeparator=","
prefix="Rp "
value={adj.amount}
onValueChange={(values) => {
updateAdjustment(index, 'amount', values.value)
}}
placeholder="Rp 0"
autoComplete='off'
/>
</div>
</div>
<div>
<Label className="text-[10px] uppercase mb-1">Keterangan</Label>
<Input
value={adj.description}
onChange={e =>
updateAdjustment(index, 'description', e.target.value)
}
placeholder="Contoh: Lembur"
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="text-red-500 mt-6"
onClick={() => removeAdjustment(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</FieldGroup>
</div>
<DialogFooter className="mt-6">
<Button type="button" variant="outline" onClick={handleClose}>Batal</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -4,3 +4,4 @@ export type * from './ui';
export type * from './category';
export type * from './product';
export type * from './expense';
export type * from './payroll';

View File

@ -0,0 +1,33 @@
import { User } from './auth';
export interface Adjustment {
id: number;
payroll_id: number;
type: 'bonus' | 'deduction';
amount: number;
amount_formatted: string;
description: string;
created_at: string;
updated_at: string;
}
export interface Payroll {
id: number;
user_id: number;
user?: User;
period_month: string;
period_month_formatted: string;
base_salary: number;
base_salary_formatted: string;
bonus: number;
bonus_formatted: string;
deduction: number;
deduction_formatted: string;
total_salary: number;
total_salary_formatted: string;
is_paid: boolean;
paid_at: string | null;
created_at: string;
updated_at: string;
adjustments?: Adjustment[];
}

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Admin\Finance\ExpenseController;
use App\Http\Controllers\Admin\Finance\PayrollController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth'])->group(function () {
@ -10,5 +11,12 @@
Route::patch('expense/update/{expense}', [ExpenseController::class, 'update'])->name('expense.update');
Route::delete('expense/destroy/{expense}', [ExpenseController::class, 'destroy'])->name('expense.destroy');
Route::delete('expense/bulk-destroy', [ExpenseController::class, 'bulkDestroy'])->name('expense.bulkDestroy');
Route::get('payrolls', [PayrollController::class, 'index'])->name('payroll.index');
Route::post('payroll/generate', [PayrollController::class, 'generate'])->name('payroll.generate');
Route::patch('payroll/update/{payroll}', [PayrollController::class, 'update'])->name('payroll.update');
Route::patch('payroll/toggle-paid/{payroll}', [PayrollController::class, 'togglePaid'])->name('payroll.togglePaid');
Route::delete('payroll/destroy/{payroll}', [PayrollController::class, 'destroy'])->name('payroll.destroy');
Route::delete('payroll/bulk-destroy', [PayrollController::class, 'bulkDestroy'])->name('payroll.bulkDestroy');
});
});