From 69131d8f8ffe425468a41a143ce1e99d667101c7 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sat, 18 Apr 2026 16:07:31 +0700 Subject: [PATCH] feat: implement payroll management module with CRUD operations, including payroll generation, adjustments, and user salary history tracking --- .../Commands/GeneratePayrollCommand.php | 74 +++++++ app/Enums/SalaryAdjustmentType.php | 17 ++ .../Admin/Finance/PayrollController.php | 114 ++++++++++ .../Admin/Finance/SalaryAdjustmentRequest.php | 34 +++ app/Models/Payroll.php | 82 +++++++ app/Models/PayrollAdjustment.php | 39 ++++ app/Models/SalaryHistory.php | 28 +++ app/Models/User.php | 10 + bootstrap/app.php | 4 + ...026_04_18_132845_create_payrolls_table.php | 37 ++++ ...32853_create_payroll_adjustments_table.php | 34 +++ ...8_132859_create_salary_histories_table.php | 33 +++ resources/js/components/app-sidebar.tsx | 8 +- .../payroll/hooks/use-payroll-index.ts | 103 +++++++++ .../js/pages/admin/finance/payroll/index.tsx | 200 ++++++++++++++++++ .../finance/payroll/partials/columns.tsx | 133 ++++++++++++ .../payroll/partials/payroll-form-modal.tsx | 191 +++++++++++++++++ resources/js/types/index.ts | 1 + resources/js/types/payroll.ts | 33 +++ routes/finance.php | 8 + 20 files changed, 1182 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/GeneratePayrollCommand.php create mode 100644 app/Enums/SalaryAdjustmentType.php create mode 100644 app/Http/Controllers/Admin/Finance/PayrollController.php create mode 100644 app/Http/Requests/Admin/Finance/SalaryAdjustmentRequest.php create mode 100644 app/Models/Payroll.php create mode 100644 app/Models/PayrollAdjustment.php create mode 100644 app/Models/SalaryHistory.php create mode 100644 database/migrations/2026_04_18_132845_create_payrolls_table.php create mode 100644 database/migrations/2026_04_18_132853_create_payroll_adjustments_table.php create mode 100644 database/migrations/2026_04_18_132859_create_salary_histories_table.php create mode 100644 resources/js/pages/admin/finance/payroll/hooks/use-payroll-index.ts create mode 100644 resources/js/pages/admin/finance/payroll/index.tsx create mode 100644 resources/js/pages/admin/finance/payroll/partials/columns.tsx create mode 100644 resources/js/pages/admin/finance/payroll/partials/payroll-form-modal.tsx create mode 100644 resources/js/types/payroll.ts diff --git a/app/Console/Commands/GeneratePayrollCommand.php b/app/Console/Commands/GeneratePayrollCommand.php new file mode 100644 index 0000000..11a1477 --- /dev/null +++ b/app/Console/Commands/GeneratePayrollCommand.php @@ -0,0 +1,74 @@ +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."); + } +} diff --git a/app/Enums/SalaryAdjustmentType.php b/app/Enums/SalaryAdjustmentType.php new file mode 100644 index 0000000..2f4fa3f --- /dev/null +++ b/app/Enums/SalaryAdjustmentType.php @@ -0,0 +1,17 @@ + 'Bonus', + self::DEDUCTION => 'Potongan', + }; + } +} diff --git a/app/Http/Controllers/Admin/Finance/PayrollController.php b/app/Http/Controllers/Admin/Finance/PayrollController.php new file mode 100644 index 0000000..829ac96 --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/PayrollController.php @@ -0,0 +1,114 @@ +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'); + } +} diff --git a/app/Http/Requests/Admin/Finance/SalaryAdjustmentRequest.php b/app/Http/Requests/Admin/Finance/SalaryAdjustmentRequest.php new file mode 100644 index 0000000..6ba4f29 --- /dev/null +++ b/app/Http/Requests/Admin/Finance/SalaryAdjustmentRequest.php @@ -0,0 +1,34 @@ +|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'], + ]; + } +} diff --git a/app/Models/Payroll.php b/app/Models/Payroll.php new file mode 100644 index 0000000..cf04dd9 --- /dev/null +++ b/app/Models/Payroll.php @@ -0,0 +1,82 @@ + '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); + } +} diff --git a/app/Models/PayrollAdjustment.php b/app/Models/PayrollAdjustment.php new file mode 100644 index 0000000..e5594ec --- /dev/null +++ b/app/Models/PayrollAdjustment.php @@ -0,0 +1,39 @@ + 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); + } +} diff --git a/app/Models/SalaryHistory.php b/app/Models/SalaryHistory.php new file mode 100644 index 0000000..54c7c8e --- /dev/null +++ b/app/Models/SalaryHistory.php @@ -0,0 +1,28 @@ + 'integer', + 'new_salary' => 'integer', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 708f6ce..7b07ff2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index c4f1cc5..aa3b6de 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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(); diff --git a/database/migrations/2026_04_18_132845_create_payrolls_table.php b/database/migrations/2026_04_18_132845_create_payrolls_table.php new file mode 100644 index 0000000..b97e938 --- /dev/null +++ b/database/migrations/2026_04_18_132845_create_payrolls_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/database/migrations/2026_04_18_132853_create_payroll_adjustments_table.php b/database/migrations/2026_04_18_132853_create_payroll_adjustments_table.php new file mode 100644 index 0000000..99d0a50 --- /dev/null +++ b/database/migrations/2026_04_18_132853_create_payroll_adjustments_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/database/migrations/2026_04_18_132859_create_salary_histories_table.php b/database/migrations/2026_04_18_132859_create_salary_histories_table.php new file mode 100644 index 0000000..f62bab2 --- /dev/null +++ b/database/migrations/2026_04_18_132859_create_salary_histories_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index f5f3bf8..03da84e 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -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() { diff --git a/resources/js/pages/admin/finance/payroll/hooks/use-payroll-index.ts b/resources/js/pages/admin/finance/payroll/hooks/use-payroll-index.ts new file mode 100644 index 0000000..331145e --- /dev/null +++ b/resources/js/pages/admin/finance/payroll/hooks/use-payroll-index.ts @@ -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(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + const [payrollToDelete, setPayrollToDelete] = useState(null); + const [rowsToDelete, setRowsToDelete] = useState([]); + 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, + }; +} diff --git a/resources/js/pages/admin/finance/payroll/index.tsx b/resources/js/pages/admin/finance/payroll/index.tsx new file mode 100644 index 0000000..4d05d4b --- /dev/null +++ b/resources/js/pages/admin/finance/payroll/index.tsx @@ -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 ( +
+ + +
+
+

Penggajian

+
+ +
+ +
+
+ {currentMonthPayrolls.map((payroll) => ( + onEdit(payroll)} + > + +
+
+ {payroll.user?.name} + {payroll.period_month_formatted} +
+ + {payroll.is_paid ? 'Lunas' : 'Pending'} + +
+
+
+ Gaji Pokok + {payroll.base_salary_formatted} +
+ {payroll.adjustments?.map((adj, idx) => ( +
+ {adj.description} + + {adj.type === 'bonus' ? '+' : '-'}{adj.amount_formatted} + +
+ ))} +
+ Gaji Bersih + {payroll.total_salary_formatted} +
+
+
+
+ ))} +
+
+ + + + + + { + setRowsToDelete(rows); + setIsBulkDeleteDialogOpen(true); + }, + icon: Trash2, + variant: 'destructive' + }, + ]} + /> + + + + {/* Single Delete Confirmation */} + + + + + + + Hapus data penggajian? + + Tindakan ini tidak dapat dibatalkan. Data penggajian untuk {payrollToDelete?.user?.name} periode {payrollToDelete?.period_month} akan dihapus secara permanen. + + + + Batal + Hapus + + + + + {/* Bulk Delete Confirmation */} + + + + + + + Hapus {rowsToDelete.length} data penggajian? + + Tindakan ini tidak dapat dibatalkan. {rowsToDelete.length} item yang terpilih akan dihapus secara permanen. + + + + Batal + + Hapus + + + + +
+ ); +} + +PayrollIndex.layout = { + breadcrumbs: [ + { + title: 'Keuangan', + }, + ], +}; diff --git a/resources/js/pages/admin/finance/payroll/partials/columns.tsx b/resources/js/pages/admin/finance/payroll/partials/columns.tsx new file mode 100644 index 0000000..54b2776 --- /dev/null +++ b/resources/js/pages/admin/finance/payroll/partials/columns.tsx @@ -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[] => [ + { + accessorKey: "user.name", + header: ({ column }) => ( + + ), + cell: ({ row }) => row.original.user?.name || '-', + meta: { title: "Pegawai" }, + }, + { + accessorKey: "period_month", + header: ({ column }) => ( + + ), + cell: ({ row }) => row.original.period_month_formatted, + meta: { title: "Periode" }, + }, + { + accessorKey: "total_salary", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const payroll = row.original; + return ( +
+
+ Gaji Pokok + {payroll.base_salary_formatted} +
+ + {payroll.adjustments && payroll.adjustments.length > 0 && ( +
+ {payroll.adjustments.map((adj, idx) => ( +
+ {adj.description} + + {adj.type === 'bonus' ? '+' : '-'}{adj.amount_formatted} + +
+ ))} +
+ )} + +
+ Gaji Bersih + {payroll.total_salary_formatted} +
+
+ ); + }, + meta: { title: "Total Gaji" }, + }, + { + accessorKey: "is_paid", + header: "Status", + cell: ({ row }) => { + const payroll = row.original; + return ( +
+ onTogglePaid(payroll.id)} + /> + + {payroll.is_paid ? 'Dibayar' : 'Pending'} + +
+ ); + }, + meta: { title: "Status" }, + }, + { + id: "actions", + header: "Aksi", + cell: ({ row }) => { + const payroll = row.original; + return ( +
+ + + + + +

Ubah / Sesuaikan

+
+
+ + + + + +

Hapus

+
+
+
+ ); + }, + meta: { title: "Aksi" }, + }, +]; diff --git a/resources/js/pages/admin/finance/payroll/partials/payroll-form-modal.tsx b/resources/js/pages/admin/finance/payroll/partials/payroll-form-modal.tsx new file mode 100644 index 0000000..cba398e --- /dev/null +++ b/resources/js/pages/admin/finance/payroll/partials/payroll-form-modal.tsx @@ -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 ( + !open && handleClose()}> + + + Penyesuaian Penggajian - {payroll?.user?.name} + +
+
+ +
+

Bonus & Potongan

+ +
+ + {data.adjustments.length === 0 && ( + + + Ooops... + + Tidak ada data yang ditemukan. + + + + )} + + {data.adjustments.map((adj, index) => ( +
+
+
+
+ + updateAdjustment(index, 'type', val)} + className="flex items-center gap-6 w-fit" + > +
+ + +
+ +
+ + +
+
+
+ +
+ + { + updateAdjustment(index, 'amount', values.value) + }} + placeholder="Rp 0" + autoComplete='off' + /> +
+
+ +
+ + + updateAdjustment(index, 'description', e.target.value) + } + placeholder="Contoh: Lembur" + /> +
+
+ +
+ ))} +
+
+ + + + + +
+
+
+ ); +} diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index 00169cf..8e8a9f1 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -4,3 +4,4 @@ export type * from './ui'; export type * from './category'; export type * from './product'; export type * from './expense'; +export type * from './payroll'; diff --git a/resources/js/types/payroll.ts b/resources/js/types/payroll.ts new file mode 100644 index 0000000..30b19ba --- /dev/null +++ b/resources/js/types/payroll.ts @@ -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[]; +} diff --git a/routes/finance.php b/routes/finance.php index 041f764..6e680f4 100644 --- a/routes/finance.php +++ b/routes/finance.php @@ -1,6 +1,7 @@ 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'); }); });