diff --git a/app/Console/Commands/GeneratePayrollCommand.php b/app/Console/Commands/GeneratePayrollCommand.php new file mode 100644 index 0000000..30f8d04 --- /dev/null +++ b/app/Console/Commands/GeneratePayrollCommand.php @@ -0,0 +1,85 @@ +year; + $month = $now->month; + + $period = PayrollPeriod::firstOrCreate( + ['year' => $year, 'month' => $month], + ['status' => PayrollPeriodStatus::OPEN] + ); + + if ($period->status !== PayrollPeriodStatus::OPEN) { + $this->error("Periode gaji {$this->getMonthName($month)} {$year} sudah ditutup."); + + return Command::FAILURE; + } + + $employees = Employee::whereHas('user', fn($q) => $q->where('is_active', true)) + ->where(fn($q) => $q->whereNull('resign_date')->orWhere('resign_date', '>=', $now->toDateString())) + ->get(); + + $existingPayrollEmployeeIds = Payroll::where('payroll_period_id', $period->id) + ->pluck('employee_id') + ->toArray(); + + $newPayrolls = 0; + + foreach ($employees as $employee) { + if (in_array($employee->id, $existingPayrollEmployeeIds)) { + continue; + } + + Payroll::create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'base_salary' => $employee->base_salary, + 'bonus_amount' => 0, + 'deduction_amount' => 0, + 'total_amount' => $employee->base_salary, + 'status' => PayrollStatus::UNPAID, + ]); + + $newPayrolls++; + } + + $this->info("Berhasil generate {$newPayrolls} gaji untuk periode {$this->getMonthName($month)} {$year}."); + + return Command::SUCCESS; + } + + private function getMonthName(int $month): string + { + return [ + 1 => 'Januari', + 2 => 'Februari', + 3 => 'Maret', + 4 => 'April', + 5 => 'Mei', + 6 => 'Juni', + 7 => 'Juli', + 8 => 'Agustus', + 9 => 'September', + 10 => 'Oktober', + 11 => 'November', + 12 => 'Desember', + ][$month] ?? ''; + } +} diff --git a/app/Enums/PayrollAdjustmentType.php b/app/Enums/PayrollAdjustmentType.php index 5a50e5e..fd0e617 100644 --- a/app/Enums/PayrollAdjustmentType.php +++ b/app/Enums/PayrollAdjustmentType.php @@ -10,5 +10,4 @@ enum PayrollAdjustmentType: string case BONUS = 'bonus'; case DEDUCTION = 'deduction'; - case CORRECTION = 'correction'; } diff --git a/app/Http/Controllers/Admin/Finance/PayrollAdjustmentController.php b/app/Http/Controllers/Admin/Finance/PayrollAdjustmentController.php new file mode 100644 index 0000000..0ca624d --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/PayrollAdjustmentController.php @@ -0,0 +1,37 @@ +handleAction( + fn () => $this->service->create($payroll, $request->validated()), + 'Adjustment gaji berhasil ditambahkan.', + 'admin.finance.payroll-periods.show', + ['payroll_period' => $payroll->payroll_period_id] + ); + } + + public function destroy(PayrollAdjustment $payrollAdjustment): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->delete($payrollAdjustment), + 'Adjustment gaji berhasil dihapus.', + 'admin.finance.payroll-periods.show', + ['payroll_period' => $payrollAdjustment->payroll->payroll_period_id] + ); + } +} diff --git a/app/Http/Controllers/Admin/Finance/PayrollController.php b/app/Http/Controllers/Admin/Finance/PayrollController.php new file mode 100644 index 0000000..ebef0a3 --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/PayrollController.php @@ -0,0 +1,35 @@ +handleAction( + fn () => $this->service->pay($payroll), + 'Gaji berhasil dibayar.', + 'admin.finance.payroll-periods.show', + ['payroll_period' => $payroll->payroll_period_id] + ); + } + + public function cancel(Payroll $payroll): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->cancel($payroll), + 'Gaji berhasil dibatalkan.', + 'admin.finance.payroll-periods.show', + ['payroll_period' => $payroll->payroll_period_id] + ); + } +} diff --git a/app/Http/Controllers/Admin/Finance/PayrollPeriodController.php b/app/Http/Controllers/Admin/Finance/PayrollPeriodController.php new file mode 100644 index 0000000..90f581b --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/PayrollPeriodController.php @@ -0,0 +1,64 @@ + $this->service->getAll(), + ]); + } + + public function current(): RedirectResponse + { + $now = now(); + + $period = PayrollPeriod::firstOrCreate( + ['year' => $now->year, 'month' => $now->month], + ['status' => PayrollPeriodStatus::OPEN] + ); + + return to_route('admin.finance.payroll-periods.show', ['payroll_period' => $period->id]); + } + + public function show(PayrollPeriod $payrollPeriod): Response + { + $period = $this->service->getDetail($payrollPeriod); + + return Inertia::render('admin/finance/payroll-period/show', [ + 'payrollPeriod' => $period, + ]); + } + + public function close(PayrollPeriod $payrollPeriod): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->close($payrollPeriod), + 'Periode gaji berhasil ditutup.', + 'admin.finance.payroll-periods.index' + ); + } + + public function reopen(PayrollPeriod $payrollPeriod): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->reopen($payrollPeriod), + 'Periode gaji berhasil dibuka kembali.', + 'admin.finance.payroll-periods.index' + ); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 97c8d99..3cc2690 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -8,7 +8,7 @@ abstract class Controller { - protected function handleAction(callable $action, string $successMessage, string $redirectRoute): RedirectResponse + protected function handleAction(callable $action, string $successMessage, string $redirectRoute, array $parameters = []): RedirectResponse { try { $action(); @@ -20,6 +20,6 @@ protected function handleAction(callable $action, string $successMessage, string Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]); } - return to_route($redirectRoute); + return to_route($redirectRoute, $parameters); } } diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index dae699c..f2b74a3 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -3,12 +3,10 @@ namespace App\Http\Controllers\Settings; use App\Http\Controllers\Controller; -use App\Http\Requests\Settings\ProfileDeleteRequest; use App\Http\Requests\Settings\ProfileUpdateRequest; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; use Inertia\Inertia; use Inertia\Response; @@ -19,8 +17,11 @@ class ProfileController extends Controller */ public function edit(Request $request): Response { + $user = $request->user(); + $user->load('userProfile'); + return Inertia::render('settings/profile', [ - 'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail, + 'mustVerifyEmail' => $user instanceof MustVerifyEmail, 'status' => $request->session()->get('status'), ]); } @@ -43,28 +44,17 @@ public function update(ProfileUpdateRequest $request): RedirectResponse $user->userProfile()->updateOrCreate( [], - ['full_name' => $validated['name']], + [ + 'full_name' => $validated['name'], + 'phone_number' => $validated['phone_number'] ?? null, + 'gender' => $validated['gender'] ?? null, + 'birth_date' => $validated['birth_date'] ?? null, + 'address' => $validated['address'] ?? null, + ], ); Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]); return to_route('profile.edit'); } - - /** - * Delete the user's profile. - */ - public function destroy(ProfileDeleteRequest $request): RedirectResponse - { - $user = $request->user(); - - Auth::logout(); - - $user->delete(); - - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - return redirect('/'); - } } diff --git a/app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php b/app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php new file mode 100644 index 0000000..b11741f --- /dev/null +++ b/app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php @@ -0,0 +1,46 @@ +has('amount')) { + $this->merge([ + 'amount' => str_replace('.', '', $this->amount), + ]); + } + } + + public function rules(): array + { + return [ + 'type' => ['required', Rule::in(PayrollAdjustmentType::values())], + 'amount' => ['required', 'integer', 'min:1'], + 'description' => ['required', 'string', 'max:100'], + 'attendance_id' => ['nullable', 'integer', 'exists:attendances,id'], + ]; + } + + public function attributes(): array + { + return [ + 'type' => 'jenis', + 'amount' => 'jumlah', + 'description' => 'keterangan', + 'attendance_id' => 'presensi', + ]; + } +} diff --git a/app/Models/PayrollAdjustment.php b/app/Models/PayrollAdjustment.php index 44fcfc8..61749de 100644 --- a/app/Models/PayrollAdjustment.php +++ b/app/Models/PayrollAdjustment.php @@ -30,12 +30,6 @@ protected function bonus(Builder $query): void $query->where('type', PayrollAdjustmentType::BONUS); } - #[Scope] - protected function correction(Builder $query): void - { - $query->where('type', PayrollAdjustmentType::CORRECTION); - } - #[Scope] protected function deduction(Builder $query): void { diff --git a/app/Models/PayrollPeriod.php b/app/Models/PayrollPeriod.php index 871cc37..04f925b 100644 --- a/app/Models/PayrollPeriod.php +++ b/app/Models/PayrollPeriod.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\PayrollPeriodStatus; +use App\Enums\PayrollStatus; use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; @@ -37,6 +38,18 @@ protected function open(Builder $query): void $query->where('status', PayrollPeriodStatus::OPEN); } + #[Scope] + protected function paid(Builder $query): void + { + $query->where('status', PayrollStatus::PAID); + } + + #[Scope] + protected function cancelled(Builder $query): void + { + $query->where('status', PayrollStatus::CANCELLED); + } + public function closedBy(): BelongsTo { return $this->belongsTo(User::class, 'closed_by_id'); diff --git a/app/Services/Admin/Finance/PayrollAdjustmentService.php b/app/Services/Admin/Finance/PayrollAdjustmentService.php new file mode 100644 index 0000000..386bffb --- /dev/null +++ b/app/Services/Admin/Finance/PayrollAdjustmentService.php @@ -0,0 +1,77 @@ +status !== PayrollStatus::UNPAID) { + throw ValidationException::withMessages([ + 'payroll' => 'Hanya gaji berstatus belum dibayar yang bisa diadjust.', + ]); + } + + return DB::transaction(function () use ($payroll, $data) { + $adjustment = PayrollAdjustment::create([ + 'payroll_id' => $payroll->id, + 'created_by_id' => auth()->id(), + 'attendance_id' => $data['attendance_id'] ?? null, + 'type' => $data['type'], + 'amount' => $data['amount'], + 'description' => $data['description'], + ]); + + $this->recalculatePayroll($payroll); + + return $adjustment; + }); + } + + public function delete(PayrollAdjustment $adjustment): bool + { + $payroll = $adjustment->payroll; + + if ($payroll->status !== PayrollStatus::UNPAID) { + throw ValidationException::withMessages([ + 'payroll' => 'Hanya gaji berstatus belum dibayar yang bisa diadjust.', + ]); + } + + return DB::transaction(function () use ($adjustment, $payroll) { + $result = $adjustment->delete(); + + $this->recalculatePayroll($payroll); + + return $result; + }); + } + + private function recalculatePayroll(Payroll $payroll): void + { + $payroll->refresh(); + + $bonuses = $payroll->payrollAdjustments() + ->where('type', PayrollAdjustmentType::BONUS) + ->sum('amount'); + + $deductions = $payroll->payrollAdjustments() + ->where('type', PayrollAdjustmentType::DEDUCTION) + ->sum('amount'); + + $total = $payroll->base_salary + $bonuses - $deductions; + + $payroll->update([ + 'bonus_amount' => $bonuses, + 'deduction_amount' => $deductions, + 'total_amount' => $total, + ]); + } +} diff --git a/app/Services/Admin/Finance/PayrollPeriodService.php b/app/Services/Admin/Finance/PayrollPeriodService.php new file mode 100644 index 0000000..6fdf247 --- /dev/null +++ b/app/Services/Admin/Finance/PayrollPeriodService.php @@ -0,0 +1,150 @@ +withCount('payrolls') + ->withSum('payrolls', 'total_amount') + ->withSum('payrolls', 'bonus_amount') + ->withSum('payrolls', 'deduction_amount') + ->withCount(['payrolls as paid_count' => function ($q) { + $q->paid(); + }]) + ->withCount(['payrolls as cancelled_count' => function ($q) { + $q->cancelled(); + }]) + ->latest('year') + ->latest('month') + ->get(); + } + + public function getDetail(PayrollPeriod $period): PayrollPeriod + { + return $period->load([ + 'payrolls' => function ($query) { + $query->with(['employee.user.userProfile', 'payrollAdjustments']) + ->orderBy('id'); + }, + ]); + } + + public function close(PayrollPeriod $period): PayrollPeriod + { + if ($period->status === PayrollPeriodStatus::CLOSED) { + throw ValidationException::withMessages([ + 'period' => 'Periode sudah ditutup.', + ]); + } + + $hasUnpaid = $period->payrolls() + ->where('status', PayrollStatus::UNPAID) + ->exists(); + + if ($hasUnpaid) { + throw ValidationException::withMessages([ + 'period' => 'Masih ada gaji yang belum dibayar.', + ]); + } + + $period->update([ + 'status' => PayrollPeriodStatus::CLOSED, + 'closed_by_id' => auth()->id(), + 'closed_at' => now(), + ]); + + return $period; + } + + public function reopen(PayrollPeriod $period): PayrollPeriod + { + if ($period->status === PayrollPeriodStatus::OPEN) { + throw ValidationException::withMessages([ + 'period' => 'Periode sudah terbuka.', + ]); + } + + $period->update([ + 'status' => PayrollPeriodStatus::OPEN, + 'closed_by_id' => null, + 'closed_at' => null, + ]); + + return $period; + } + + public function pay(Payroll $payroll): Payroll + { + if ($payroll->status === PayrollStatus::PAID) { + throw ValidationException::withMessages([ + 'payroll' => 'Gaji sudah dibayar.', + ]); + } + + if ($payroll->status === PayrollStatus::CANCELLED) { + throw ValidationException::withMessages([ + 'payroll' => 'Gaji sudah dibatalkan.', + ]); + } + + return DB::transaction(function () use ($payroll) { + $cashAccount = CashAccount::firstOrFail(); + + $newBalance = $cashAccount->balance + $payroll->total_amount; + $cashAccount->update(['balance' => $newBalance]); + + $cashTransaction = CashTransaction::create([ + 'cash_account_id' => $cashAccount->id, + 'created_by_id' => auth()->id(), + 'amount' => $payroll->total_amount, + 'balance_after' => $newBalance, + 'type' => CashTransactionType::DEPOSIT, + 'description' => 'Pembayaran gaji karyawan', + ]); + + $payroll->update([ + 'status' => PayrollStatus::PAID, + 'cash_transaction_id' => $cashTransaction->id, + 'paid_by_id' => auth()->id(), + 'paid_at' => now(), + ]); + + return $payroll; + }); + } + + public function cancel(Payroll $payroll): Payroll + { + if ($payroll->status === PayrollStatus::PAID) { + throw ValidationException::withMessages([ + 'payroll' => 'Gaji yang sudah dibayar tidak dapat dibatalkan.', + ]); + } + + if ($payroll->status === PayrollStatus::CANCELLED) { + throw ValidationException::withMessages([ + 'payroll' => 'Gaji sudah dibatalkan.', + ]); + } + + $payroll->update([ + 'status' => PayrollStatus::CANCELLED, + ]); + + return $payroll; + } +} diff --git a/database/factories/PayrollAdjustmentFactory.php b/database/factories/PayrollAdjustmentFactory.php index 1e1e93e..df4808a 100644 --- a/database/factories/PayrollAdjustmentFactory.php +++ b/database/factories/PayrollAdjustmentFactory.php @@ -15,7 +15,7 @@ public function definition(): array 'payroll_id' => Payroll::factory(), 'attendance_id' => Attendance::factory(), 'created_by_id' => User::factory(), - 'type' => fake()->randomElement(['bonus', 'deduction', 'correction']), + 'type' => fake()->randomElement(['bonus', 'deduction']), 'amount' => fake()->numberBetween(10000, 500000), 'description' => fake()->sentence(), ]; diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index fa17837..e217ae3 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -13,6 +13,7 @@ import { useCurrentUrl } from '@/hooks/use-current-url'; import { dashboard } from '@/routes'; import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts'; import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances'; +import { current as payrollCurrent, index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods'; import { index as expensesIndex } from '@/routes/admin/finance/expenses'; import { index as employeesIndex } from '@/routes/admin/hr/employees'; import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests'; @@ -77,7 +78,7 @@ const keuanganItems: NavMenuItem[] = [ { title: 'Kas Toko', href: cashAccountsIndex.url(), icon: Wallet }, { title: 'Pengeluaran', href: expensesIndex.url(), icon: ArrowUpFromLine }, { title: 'Kasbon', href: employeeAdvancesIndex.url(), icon: HandCoins }, - { title: 'Gaji', href: '#', icon: DollarSign }, + { title: 'Gaji', href: payrollCurrent.url(), icon: DollarSign }, ]; const hrItems: NavMenuItem[] = [ diff --git a/resources/js/pages/admin/finance/payroll-period/columns.tsx b/resources/js/pages/admin/finance/payroll-period/columns.tsx new file mode 100644 index 0000000..e6e5715 --- /dev/null +++ b/resources/js/pages/admin/finance/payroll-period/columns.tsx @@ -0,0 +1,286 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { ArrowUpDown, Eye, Lock, Unlock } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { formatCurrency } from '@/lib/utils'; +import { Link } from '@inertiajs/react'; + +export type PayrollPeriod = { + id: number; + year: number; + month: number; + status: 'open' | 'closed'; + closed_at: string | null; + created_at: string; + payrolls_count: number; + paid_count: number; + cancelled_count: number; + payrolls_sum_total_amount: number | null; + payrolls_sum_bonus_amount: number | null; + payrolls_sum_deduction_amount: number | null; +}; + +const MONTH_NAMES = [ + '', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', + 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember', +]; + +function formatPeriod(period: PayrollPeriod): string { + return `${MONTH_NAMES[period.month]} ${period.year}`; +} + +function getStatusBadge(status: string) { + const statusConfig: Record = { + open: { + label: 'Terbuka', + className: 'bg-green-100 text-green-800 hover:bg-green-100', + }, + closed: { + label: 'Ditutup', + className: 'bg-gray-100 text-gray-800 hover:bg-gray-100', + }, + }; + + const config = statusConfig[status] ?? statusConfig.open; + + return ( + + {config.label} + + ); +} + +type CreateColumnsParams = { + showUrl: (id: number) => string; + handleClose: (period: PayrollPeriod) => void; + handleReopen: (period: PayrollPeriod) => void; +}; + +export function createPayrollPeriodColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { showUrl, handleClose, handleReopen } = params; + + return [ + { + id: 'no', + header: () => No, + cell: ({ row }) => ( + + {row.index + 1} + + ), + meta: { + className: 'w-[50px] text-center', + headerClassName: 'w-[50px] text-center', + }, + }, + { + accessorKey: 'year', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {formatPeriod(row.original)} + ), + }, + { + accessorKey: 'payrolls_count', + header: () => Jumlah Karyawan, + cell: ({ row }) => ( + {row.getValue('payrolls_count') as number} + ), + }, + { + accessorKey: 'payrolls_sum_total_amount', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatCurrency((row.getValue('payrolls_sum_total_amount') as number) ?? 0)} + + ), + }, + { + accessorKey: 'payrolls_sum_bonus_amount', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = (row.getValue('payrolls_sum_bonus_amount') as number) ?? 0; + + return ( + 0 ? 'text-green-600 font-medium' : 'text-muted-foreground'}> + {value > 0 ? '+ ' : ''}{formatCurrency(value)} + + ); + }, + }, + { + accessorKey: 'payrolls_sum_deduction_amount', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = (row.getValue('payrolls_sum_deduction_amount') as number) ?? 0; + + return ( + 0 ? 'text-red-600 font-medium' : 'text-muted-foreground'}> + {value > 0 ? '- ' : ''}{formatCurrency(value)} + + ); + }, + }, + { + id: 'payment_status', + header: () => Status Bayar, + cell: ({ row }) => { + const period = row.original; + const paid = period.paid_count ?? 0; + const cancelled = period.cancelled_count ?? 0; + const total = period.payrolls_count ?? 0; + const unpaid = total - paid - cancelled; + + return ( +
+ {paid > 0 && ( + {paid} dibayar + )} + {unpaid > 0 && ( + {unpaid} menunggu + )} + {cancelled > 0 && ( + {cancelled} dibatalkan + )} +
+ ); + }, + }, + { + accessorKey: 'status', + header: () => Status, + cell: ({ row }) => ( + {getStatusBadge(row.getValue('status') as string)} + ), + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => { + const period = row.original; + + return ( + +
+ + + + + + Lihat Detail + + + + {period.status === 'open' && ( + + + + + + Tutup Periode + + + )} + + {period.status === 'closed' && ( + + + + + + Buka Periode + + + )} +
+
+ ); + }, + }, + ]; +} diff --git a/resources/js/pages/admin/finance/payroll-period/index.tsx b/resources/js/pages/admin/finance/payroll-period/index.tsx new file mode 100644 index 0000000..a154cc3 --- /dev/null +++ b/resources/js/pages/admin/finance/payroll-period/index.tsx @@ -0,0 +1,112 @@ +import { Head, router } from '@inertiajs/react'; +import { useState } from 'react'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { DataTable } from '@/components/data-table'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods'; +import { show as payrollPeriodShow } from '@/routes/admin/finance/payroll-periods'; +import { close, reopen } from '@/routes/admin/finance/payroll-periods'; +import { createPayrollPeriodColumns } from './columns'; +import type { PayrollPeriod } from './columns'; + +type Props = { + payrollPeriods: PayrollPeriod[]; +}; + +const MONTH_NAMES = [ + '', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', + 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember', +]; + +export default function PayrollPeriodIndex({ payrollPeriods }: Props) { + const [closing, setClosing] = useState(null); + const [reopening, setReopening] = useState(null); + + function handleClose() { + if (!closing) return; + + router.post(close(closing.id), {}, { + onSuccess: () => setClosing(null), + }); + } + + function handleReopen() { + if (!reopening) return; + + router.post(reopen(reopening.id), {}, { + onSuccess: () => setReopening(null), + }); + } + + const columns = createPayrollPeriodColumns({ + showUrl: (id) => payrollPeriodShow(id).url, + handleClose: (period) => setClosing(period), + handleReopen: (period) => setReopening(period), + }); + + return ( + <> + + +
+
+
+

+ Gaji +

+
+
+ + + + { + if (!open) setClosing(null); + }} + title="Tutup Periode Gaji" + description={`Apakah Anda yakin ingin menutup periode gaji ${closing ? `${MONTH_NAMES[closing.month]} ${closing.year}` : ''}? Semua gaji harus sudah dibayar sebelum periode ditutup.`} + confirmLabel="Tutup" + onConfirm={handleClose} + /> + + { + if (!open) setReopening(null); + }} + title="Buka Periode Gaji" + description={`Apakah Anda yakin ingin membuka kembali periode gaji ${reopening ? `${MONTH_NAMES[reopening.month]} ${reopening.year}` : ''}?`} + confirmLabel="Buka" + onConfirm={handleReopen} + /> +
+ + ); +} + +PayrollPeriodIndex.layout = { + breadcrumbs: [ + { + title: 'Keuangan', + href: payrollPeriodsIndex(), + }, + { + title: 'Gaji', + href: payrollPeriodsIndex(), + }, + ], +}; diff --git a/resources/js/pages/admin/finance/payroll-period/show-columns.tsx b/resources/js/pages/admin/finance/payroll-period/show-columns.tsx new file mode 100644 index 0000000..f474c78 --- /dev/null +++ b/resources/js/pages/admin/finance/payroll-period/show-columns.tsx @@ -0,0 +1,300 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { ArrowUpDown, CircleDollarSign, Pencil, Trash2, XCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { formatCurrency } from '@/lib/utils'; + +export type Payroll = { + id: number; + base_salary: number; + bonus_amount: number; + deduction_amount: number; + total_amount: number; + status: 'unpaid' | 'paid' | 'cancelled'; + paid_at: string | null; + employee: { + user: { + user_profile: { + full_name: string; + }; + }; + }; + payroll_adjustments?: PayrollAdjustment[]; +}; + +export type PayrollAdjustment = { + id: number; + type: 'bonus' | 'deduction'; + amount: number; + description: string; + created_at: string; +}; + +function getStatusBadge(status: string) { + const statusConfig: Record = { + unpaid: { + label: 'Belum Dibayar', + className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100', + }, + paid: { + label: 'Dibayar', + className: 'bg-green-100 text-green-800 hover:bg-green-100', + }, + cancelled: { + label: 'Dibatalkan', + className: 'bg-red-100 text-red-800 hover:bg-red-100', + }, + }; + + const config = statusConfig[status] ?? statusConfig.unpaid; + + return ( + + {config.label} + + ); +} + +type CreateColumnsParams = { + handlePay: (payroll: Payroll) => void; + handleCancel: (payroll: Payroll) => void; + handleAddAdjustment: (payroll: Payroll) => void; + handleDeleteAdjustment: (adjustment: PayrollAdjustment, payrollId: number) => void; +}; + +export function createPayrollColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { handlePay, handleCancel, handleAddAdjustment, handleDeleteAdjustment } = params; + + return [ + { + id: 'no', + header: () => No, + cell: ({ row }) => ( + + {row.index + 1} + + ), + meta: { + className: 'w-[50px] text-center', + headerClassName: 'w-[50px] text-center', + }, + }, + { + id: 'employee_name', + header: () => Nama Karyawan, + cell: ({ row }) => { + const employee = row.original.employee; + + return {employee?.user?.user_profile?.full_name ?? '-'}; + }, + }, + { + accessorKey: 'base_salary', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {formatCurrency(row.getValue('base_salary') as number)} + ), + }, + { + accessorKey: 'bonus_amount', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue('bonus_amount') as number; + + return ( + 0 ? 'text-green-600 font-medium' : ''}> + {value > 0 ? '+ ' : ''}{formatCurrency(value)} + + ); + }, + }, + { + accessorKey: 'deduction_amount', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue('deduction_amount') as number; + + return ( + 0 ? 'text-red-600 font-medium' : ''}> + {value > 0 ? '- ' : ''}{formatCurrency(value)} + + ); + }, + }, + { + accessorKey: 'total_amount', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {formatCurrency(row.getValue('total_amount') as number)} + ), + }, + { + accessorKey: 'status', + header: () => Status, + cell: ({ row }) => ( + {getStatusBadge(row.getValue('status') as string)} + ), + }, + { + id: 'adjustments', + header: () => Penyesuaian, + cell: ({ row }) => { + const payroll = row.original; + const adjustments = payroll.payroll_adjustments ?? []; + + if (adjustments.length === 0) { + return -; + } + + return ( +
+ {adjustments.map((adj: PayrollAdjustment) => ( +
+ + {adj.type === 'bonus' ? '+' : '-'} {formatCurrency(adj.amount)} + + + {adj.description} + + {payroll.status === 'unpaid' && ( + + )} +
+ ))} +
+ ); + }, + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[120px] text-center', + headerClassName: 'w-[120px] text-center', + }, + cell: ({ row }) => { + const payroll = row.original; + + return ( + +
+ {payroll.status === 'unpaid' && ( + <> + + + + + + Tambah Adjustment + + + + + + + + + Tandai Dibayar + + + + + + + + + Batalkan + + + + )} +
+
+ ); + }, + }, + ]; +} diff --git a/resources/js/pages/admin/finance/payroll-period/show.tsx b/resources/js/pages/admin/finance/payroll-period/show.tsx new file mode 100644 index 0000000..798cecd --- /dev/null +++ b/resources/js/pages/admin/finance/payroll-period/show.tsx @@ -0,0 +1,281 @@ +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { DataTable } from '@/components/data-table'; +import InputError from '@/components/input-error'; +import { RupiahInput } from '@/components/rupiah-input'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { formatCurrency } from '@/lib/utils'; +import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments'; +import { + index as payrollPeriodsIndex +} from '@/routes/admin/finance/payroll-periods'; +import { + cancel as payrollCancel, + pay as payrollPay, +} from '@/routes/admin/finance/payrolls'; +import { store as adjustmentStore } from '@/routes/admin/finance/payrolls/adjustments'; +import { Form, Head, router } from '@inertiajs/react'; +import { ArrowLeft } from 'lucide-react'; +import { useState } from 'react'; +import type { Payroll, PayrollAdjustment } from './show-columns'; +import { createPayrollColumns } from './show-columns'; + +type Props = { + payrollPeriod: { + id: number; + year: number; + month: number; + status: string; + payrolls: Payroll[]; + }; +}; + +const MONTH_NAMES = [ + '', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', + 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember', +]; + +export default function PayrollPeriodShow({ payrollPeriod }: Props) { + const [paying, setPaying] = useState(null); + const [cancelling, setCancelling] = useState(null); + const [addingAdjustment, setAddingAdjustment] = useState(null); + const [deletingAdjustment, setDeletingAdjustment] = useState<{ adjustment: PayrollAdjustment; payrollId: number } | null>(null); + const [adjustmentType, setAdjustmentType] = useState('bonus'); + + function handlePay() { + if (!paying) return; + + router.post(payrollPay(paying.id), {}, { + onSuccess: () => setPaying(null), + }); + } + + function handleCancel() { + if (!cancelling) return; + + router.post(payrollCancel(cancelling.id), {}, { + onSuccess: () => setCancelling(null), + }); + } + + function handleDeleteAdjustment() { + if (!deletingAdjustment) return; + + router.delete(adjustmentDestroy(deletingAdjustment.adjustment.id), { + onSuccess: () => setDeletingAdjustment(null), + }); + } + + const columns = createPayrollColumns({ + handlePay: (payroll) => setPaying(payroll), + handleCancel: (payroll) => setCancelling(payroll), + handleAddAdjustment: (payroll) => { + setAddingAdjustment(payroll); + setAdjustmentType('bonus'); + }, + handleDeleteAdjustment: (adjustment, payrollId) => { + setDeletingAdjustment({ adjustment, payrollId }); + }, + }); + + const totalBaseSalary = payrollPeriod.payrolls.reduce((sum, p) => sum + p.base_salary, 0); + const totalBonus = payrollPeriod.payrolls.reduce((sum, p) => sum + p.bonus_amount, 0); + const totalDeduction = payrollPeriod.payrolls.reduce((sum, p) => sum + p.deduction_amount, 0); + const totalAmount = payrollPeriod.payrolls.reduce((sum, p) => sum + p.total_amount, 0); + + return ( + <> + + +
+
+
+

+ Gaji {MONTH_NAMES[payrollPeriod.month]} {payrollPeriod.year} +

+

+ {payrollPeriod.payrolls.length} karyawan · Status: {payrollPeriod.status === 'open' ? 'Terbuka' : 'Ditutup'} +

+
+ +
+ +
+
+

Total Gaji Pokok

+

{formatCurrency(totalBaseSalary)}

+
+
+

Total Bonus

+

{formatCurrency(totalBonus)}

+
+
+

Total Potongan

+

{formatCurrency(totalDeduction)}

+
+
+

Total Gaji

+

{formatCurrency(totalAmount)}

+
+
+ + + + {/* Dialog Tambah Penyesuaian */} + { + if (!open) { + setAddingAdjustment(null); + setAdjustmentType('bonus'); + } + }}> + + {addingAdjustment && ( +
{ + setAddingAdjustment(null); + setAdjustmentType('bonus'); + }} + > + {({ errors, processing }) => ( + <> + + Tambah Penyesuaian + +
+
+ + + +
+ + +
+
+ + +
+
+ +
+
+ + + +
+
+ + + +
+
+ + + + + + )} +
+ )} +
+
+ + {/* Dialog Konfirmasi Bayar */} + { + if (!open) setPaying(null); + }} + title="Tandai Dibayar" + description={`Apakah Anda yakin ingin menandai gaji "${paying?.employee?.user?.user_profile?.full_name}" sebesar ${formatCurrency(paying?.total_amount ?? 0)} sebagai sudah dibayar? Penyesuaian tidak dapat ditambahkan setelah dibayar.`} + confirmLabel="Bayar" + onConfirm={handlePay} + /> + + {/* Dialog Konfirmasi Batalkan */} + { + if (!open) setCancelling(null); + }} + title="Batalkan Gaji" + description={`Apakah Anda yakin ingin membatalkan gaji "${cancelling?.employee?.user?.user_profile?.full_name}"?`} + confirmLabel="Batalkan" + onConfirm={handleCancel} + /> + + {/* Dialog Konfirmasi Hapus Penyesuaian */} + { + if (!open) setDeletingAdjustment(null); + }} + title="Hapus Penyesuaian" + description={`Apakah Anda yakin ingin menghapus penyesuaian "${deletingAdjustment?.adjustment.description}" sebesar ${formatCurrency(deletingAdjustment?.adjustment.amount ?? 0)}?`} + confirmLabel="Hapus" + onConfirm={handleDeleteAdjustment} + /> +
+ + ); +} + +PayrollPeriodShow.layout = { + breadcrumbs: [ + { + title: 'Keuangan', + href: payrollPeriodsIndex(), + }, + { + title: 'Gaji', + href: payrollPeriodsIndex(), + }, + { + title: 'Detail', + href: '#', + }, + ], +}; diff --git a/routes/console.php b/routes/console.php index 3c9adf1..bfbf13b 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,7 @@ comment(Inspiring::quote()); -})->purpose('Display an inspiring quote'); +Schedule::command(GeneratePayrollCommand::class)->monthlyOn(1, '00:00'); diff --git a/routes/web.php b/routes/web.php index 2d677ae..e852427 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,9 @@ use App\Http\Controllers\Admin\Finance\CashAccountController; use App\Http\Controllers\Admin\Finance\EmployeeAdvanceController; use App\Http\Controllers\Admin\Finance\ExpenseController; +use App\Http\Controllers\Admin\Finance\PayrollAdjustmentController; +use App\Http\Controllers\Admin\Finance\PayrollController; +use App\Http\Controllers\Admin\Finance\PayrollPeriodController; use App\Http\Controllers\Admin\Master\CategoryController; use App\Http\Controllers\Admin\Master\CustomerController; use App\Http\Controllers\Admin\Master\ProductController; @@ -34,6 +37,17 @@ Route::resource('employee-advances', EmployeeAdvanceController::class)->except(['show', 'create', 'edit']); Route::post('employee-advances/{employeeAdvance}/approve', [EmployeeAdvanceController::class, 'approve'])->name('employee-advances.approve'); Route::post('employee-advances/{employeeAdvance}/pay', [EmployeeAdvanceController::class, 'pay'])->name('employee-advances.pay'); + + Route::get('payroll-periods/current', [PayrollPeriodController::class, 'current'])->name('payroll-periods.current'); + Route::resource('payroll-periods', PayrollPeriodController::class)->only(['index', 'show']); + Route::post('payroll-periods/{payrollPeriod}/close', [PayrollPeriodController::class, 'close'])->name('payroll-periods.close'); + Route::post('payroll-periods/{payrollPeriod}/reopen', [PayrollPeriodController::class, 'reopen'])->name('payroll-periods.reopen'); + + Route::post('payrolls/{payroll}/pay', [PayrollController::class, 'pay'])->name('payrolls.pay'); + Route::post('payrolls/{payroll}/cancel', [PayrollController::class, 'cancel'])->name('payrolls.cancel'); + + Route::post('payrolls/{payroll}/adjustments', [PayrollAdjustmentController::class, 'store'])->name('payrolls.adjustments.store'); + Route::delete('payroll-adjustments/{payrollAdjustment}', [PayrollAdjustmentController::class, 'destroy'])->name('payroll-adjustments.destroy'); }); Route::prefix('admin/hr')->name('admin.hr.')->group(function () { diff --git a/tests/Feature/Admin/Finance/PayrollTest.php b/tests/Feature/Admin/Finance/PayrollTest.php new file mode 100644 index 0000000..f19ede9 --- /dev/null +++ b/tests/Feature/Admin/Finance/PayrollTest.php @@ -0,0 +1,1192 @@ +get(route('admin.finance.payroll-periods.index')); + $response->assertRedirect(route('login')); +}); + +test('guests are redirected to the login page from payroll period show', function () { + $period = PayrollPeriod::factory()->create(); + $response = $this->get(route('admin.finance.payroll-periods.show', $period)); + $response->assertRedirect(route('login')); +}); + +test('guests are redirected to the login page from current', function () { + $response = $this->get(route('admin.finance.payroll-periods.current')); + $response->assertRedirect(route('login')); +}); + +test('guests are redirected to the login page from close', function () { + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $response = $this->post(route('admin.finance.payroll-periods.close', $period)); + $response->assertRedirect(route('login')); +}); + +test('guests are redirected to the login page from reopen', function () { + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::CLOSED]); + $response = $this->post(route('admin.finance.payroll-periods.reopen', $period)); + $response->assertRedirect(route('login')); +}); + +test('guests are redirected to the login page from pay', function () { + $payroll = Payroll::factory()->create(['status' => PayrollStatus::UNPAID]); + $response = $this->post(route('admin.finance.payrolls.pay', $payroll)); + $response->assertRedirect(route('login')); +}); + +test('guests are redirected to the login page from cancel', function () { + $payroll = Payroll::factory()->create(['status' => PayrollStatus::UNPAID]); + $response = $this->post(route('admin.finance.payrolls.cancel', $payroll)); + $response->assertRedirect(route('login')); +}); + +test('guests are redirected to the login page from store adjustment', function () { + $payroll = Payroll::factory()->create(['status' => PayrollStatus::UNPAID]); + $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'bonus', + 'amount' => 500000, + 'description' => 'Test', + ]); + $response->assertRedirect(route('login')); +}); + +test('guests are redirected to the login page from delete adjustment', function () { + $adjustment = PayrollAdjustment::factory()->create(); + $response = $this->delete(route('admin.finance.payroll-adjustments.destroy', $adjustment)); + $response->assertRedirect(route('login')); +}); + +/* +|-------------------------------------------------------------------------- +| PAYROLL PERIOD - INDEX +|-------------------------------------------------------------------------- +*/ + +test('authenticated users can visit the payroll period index page', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->get(route('admin.finance.payroll-periods.index')); + $response->assertOk(); +}); + +test('index page displays payroll periods', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['year' => 2026, 'month' => 7]); + $employee = Employee::factory()->create(['base_salary' => 3000000]); + Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'base_salary' => 3000000, + 'total_amount' => 3000000, + ]); + + $response = $this->get(route('admin.finance.payroll-periods.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/finance/payroll-period/index') + ->has('payrollPeriods', 1) + ); +}); + +test('index page works when no payroll periods exist', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->get(route('admin.finance.payroll-periods.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/finance/payroll-period/index') + ->has('payrollPeriods', 0) + ); +}); + +test('index page displays correct aggregates', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['year' => 2026, 'month' => 7]); + $emp1 = Employee::factory()->create(['base_salary' => 3000000]); + $emp2 = Employee::factory()->create(['base_salary' => 4000000]); + + Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $emp1->id, + 'base_salary' => 3000000, + 'bonus_amount' => 500000, + 'deduction_amount' => 100000, + 'total_amount' => 3400000, + 'status' => PayrollStatus::PAID, + ]); + Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $emp2->id, + 'base_salary' => 4000000, + 'bonus_amount' => 0, + 'deduction_amount' => 0, + 'total_amount' => 4000000, + 'status' => PayrollStatus::UNPAID, + ]); + + $response = $this->get(route('admin.finance.payroll-periods.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/finance/payroll-period/index') + ->has('payrollPeriods', 1) + ->where('payrollPeriods.0.payrolls_count', 2) + ->where('payrollPeriods.0.paid_count', 1) + ); +}); + +/* +|-------------------------------------------------------------------------- +| PAYROLL PERIOD - SHOW +|-------------------------------------------------------------------------- +*/ + +test('authenticated users can visit the payroll period show page', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['year' => 2026, 'month' => 7]); + + $response = $this->get(route('admin.finance.payroll-periods.show', $period)); + $response->assertOk(); +}); + +test('show page displays payroll period detail with payrolls', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['year' => 2026, 'month' => 7]); + $employee = Employee::factory()->create(['base_salary' => 3000000]); + Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'base_salary' => 3000000, + 'total_amount' => 3000000, + ]); + + $response = $this->get(route('admin.finance.payroll-periods.show', $period)); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/finance/payroll-period/show') + ->has('payrollPeriod') + ->where('payrollPeriod.year', 2026) + ->where('payrollPeriod.month', 7) + ->has('payrollPeriod.payrolls', 1) + ); +}); + +/* +|-------------------------------------------------------------------------- +| PAYROLL PERIOD - CURRENT +|-------------------------------------------------------------------------- +*/ + +test('current route creates period if not exists and redirects', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->get(route('admin.finance.payroll-periods.current')); + $response->assertRedirect(); + + $now = now(); + $this->assertDatabaseHas('payroll_periods', [ + 'year' => $now->year, + 'month' => $now->month, + 'status' => PayrollPeriodStatus::OPEN->value, + ]); +}); + +test('current route redirects to existing period', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $now = now(); + $period = PayrollPeriod::factory()->create([ + 'year' => $now->year, + 'month' => $now->month, + 'status' => PayrollPeriodStatus::OPEN, + ]); + + $response = $this->get(route('admin.finance.payroll-periods.current')); + $response->assertRedirect(); + + $this->assertDatabaseCount('payroll_periods', 1); +}); + +/* +|-------------------------------------------------------------------------- +| PAYROLL PERIOD - CLOSE +|-------------------------------------------------------------------------- +*/ + +test('open period can be closed when all payrolls are paid', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::PAID, + ]); + + $response = $this->post(route('admin.finance.payroll-periods.close', $period)); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.finance.payroll-periods.index')); + + $period->refresh(); + expect($period->status)->toBe(PayrollPeriodStatus::CLOSED); + expect($period->closed_by_id)->toBe($user->id); + expect($period->closed_at)->not->toBeNull(); +}); + +test('close fails when period already closed', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::CLOSED]); + + $response = $this->post(route('admin.finance.payroll-periods.close', $period)); + $response->assertRedirect(); + + $period->refresh(); + expect($period->status)->toBe(PayrollPeriodStatus::CLOSED); +}); + +test('close fails when there are unpaid payrolls', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::UNPAID, + ]); + + $response = $this->post(route('admin.finance.payroll-periods.close', $period)); + $response->assertRedirect(); + + $period->refresh(); + expect($period->status)->toBe(PayrollPeriodStatus::OPEN); +}); + +/* +|-------------------------------------------------------------------------- +| PAYROLL PERIOD - REOPEN +|-------------------------------------------------------------------------- +*/ + +test('closed period can be reopened', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::CLOSED]); + + $response = $this->post(route('admin.finance.payroll-periods.reopen', $period)); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.finance.payroll-periods.index')); + + $period->refresh(); + expect($period->status)->toBe(PayrollPeriodStatus::OPEN); + expect($period->closed_by_id)->toBeNull(); + expect($period->closed_at)->toBeNull(); +}); + +test('reopen fails when period already open', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + + $response = $this->post(route('admin.finance.payroll-periods.reopen', $period)); + $response->assertRedirect(); + + $period->refresh(); + expect($period->status)->toBe(PayrollPeriodStatus::OPEN); +}); + +/* +|-------------------------------------------------------------------------- +| PAYROLL - PAY +|-------------------------------------------------------------------------- +*/ + +test('unpaid payroll can be paid', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + CashAccount::factory()->create(['balance' => 0]); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(['base_salary' => 3000000]); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'base_salary' => 3000000, + 'bonus_amount' => 500000, + 'deduction_amount' => 100000, + 'total_amount' => 3400000, + 'status' => PayrollStatus::UNPAID, + ]); + + $response = $this->post(route('admin.finance.payrolls.pay', $payroll)); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.finance.payroll-periods.show', $period)); + + $payroll->refresh(); + expect($payroll->status)->toBe(PayrollStatus::PAID); + expect($payroll->paid_by_id)->toBe($user->id); + expect($payroll->paid_at)->not->toBeNull(); + expect($payroll->cash_transaction_id)->not->toBeNull(); + + expect(CashAccount::first()->balance)->toBe(3400000); +}); + +test('pay creates cash transaction with correct data', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + CashAccount::factory()->create(['balance' => 0]); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(['base_salary' => 3000000]); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'base_salary' => 3000000, + 'total_amount' => 3000000, + 'status' => PayrollStatus::UNPAID, + ]); + + $this->post(route('admin.finance.payrolls.pay', $payroll)); + + $this->assertDatabaseHas('cash_transactions', [ + 'cash_account_id' => CashAccount::first()->id, + 'created_by_id' => $user->id, + 'amount' => 3000000, + 'type' => 'deposit', + 'description' => 'Pembayaran gaji karyawan', + ]); +}); + +test('already paid payroll cannot be paid again', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + CashAccount::factory()->create(['balance' => 0]); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::PAID, + 'total_amount' => 3000000, + ]); + + $response = $this->post(route('admin.finance.payrolls.pay', $payroll)); + $response->assertRedirect(); + + expect(CashAccount::first()->balance)->toBe(0); +}); + +test('cancelled payroll cannot be paid', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + CashAccount::factory()->create(['balance' => 0]); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::CANCELLED, + ]); + + $response = $this->post(route('admin.finance.payrolls.pay', $payroll)); + $response->assertRedirect(); + + expect(CashAccount::first()->balance)->toBe(0); +}); + +test('paying non-existent payroll returns 404', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.finance.payrolls.pay', 999999)); + $response->assertStatus(404); +}); + +/* +|-------------------------------------------------------------------------- +| PAYROLL - CANCEL +|-------------------------------------------------------------------------- +*/ + +test('unpaid payroll can be cancelled', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::UNPAID, + ]); + + $response = $this->post(route('admin.finance.payrolls.cancel', $payroll)); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.finance.payroll-periods.show', $period)); + + $payroll->refresh(); + expect($payroll->status)->toBe(PayrollStatus::CANCELLED); +}); + +test('paid payroll cannot be cancelled', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::PAID, + ]); + + $response = $this->post(route('admin.finance.payrolls.cancel', $payroll)); + $response->assertRedirect(); + + $payroll->refresh(); + expect($payroll->status)->toBe(PayrollStatus::PAID); +}); + +test('already cancelled payroll cannot be cancelled again', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::CANCELLED, + ]); + + $response = $this->post(route('admin.finance.payrolls.cancel', $payroll)); + $response->assertRedirect(); + + $payroll->refresh(); + expect($payroll->status)->toBe(PayrollStatus::CANCELLED); +}); + +test('cancelling non-existent payroll returns 404', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.finance.payrolls.cancel', 999999)); + $response->assertStatus(404); +}); + +/* +|-------------------------------------------------------------------------- +| PAYROLL ADJUSTMENT - STORE +|-------------------------------------------------------------------------- +*/ + +test('bonus adjustment can be added to unpaid payroll', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(['base_salary' => 3000000]); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'base_salary' => 3000000, + 'bonus_amount' => 0, + 'deduction_amount' => 0, + 'total_amount' => 3000000, + 'status' => PayrollStatus::UNPAID, + ]); + + $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'bonus', + 'amount' => 500000, + 'description' => 'Bonus performa', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.finance.payroll-periods.show', $period)); + + $this->assertDatabaseHas('payroll_adjustments', [ + 'payroll_id' => $payroll->id, + 'type' => PayrollAdjustmentType::BONUS->value, + 'amount' => 500000, + 'description' => 'Bonus performa', + ]); + + $payroll->refresh(); + expect($payroll->bonus_amount)->toBe(500000); + expect($payroll->total_amount)->toBe(3500000); +}); + +test('deduction adjustment can be added to unpaid payroll', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(['base_salary' => 3000000]); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'base_salary' => 3000000, + 'bonus_amount' => 0, + 'deduction_amount' => 0, + 'total_amount' => 3000000, + 'status' => PayrollStatus::UNPAID, + ]); + + $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'deduction', + 'amount' => 200000, + 'description' => 'Potongan telat', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.finance.payroll-periods.show', $period)); + + $payroll->refresh(); + expect($payroll->deduction_amount)->toBe(200000); + expect($payroll->total_amount)->toBe(2800000); +}); + +test('multiple adjustments accumulate correctly', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(['base_salary' => 3000000]); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'base_salary' => 3000000, + 'bonus_amount' => 0, + 'deduction_amount' => 0, + 'total_amount' => 3000000, + 'status' => PayrollStatus::UNPAID, + ]); + + $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'bonus', + 'amount' => 500000, + 'description' => 'Bonus 1', + ]); + + $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'bonus', + 'amount' => 300000, + 'description' => 'Bonus 2', + ]); + + $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'deduction', + 'amount' => 100000, + 'description' => 'Potongan 1', + ]); + + $payroll->refresh(); + expect($payroll->bonus_amount)->toBe(800000); + expect($payroll->deduction_amount)->toBe(100000); + expect($payroll->total_amount)->toBe(3700000); +}); + +test('adjustment cannot be added to paid payroll', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::PAID, + ]); + + $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'bonus', + 'amount' => 500000, + 'description' => 'Bonus', + ]); + + $response->assertRedirect(); + $this->assertDatabaseCount('payroll_adjustments', 0); +}); + +test('adjustment cannot be added to cancelled payroll', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::CANCELLED, + ]); + + $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'bonus', + 'amount' => 500000, + 'description' => 'Bonus', + ]); + + $response->assertRedirect(); + $this->assertDatabaseCount('payroll_adjustments', 0); +}); + +test('adjustment type is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::UNPAID, + ]); + + $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => '', + 'amount' => 500000, + 'description' => 'Test', + ]); + + $response->assertSessionHasErrors('type'); +}); + +test('adjustment type must be valid', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::UNPAID, + ]); + + $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'invalid_type', + 'amount' => 500000, + 'description' => 'Test', + ]); + + $response->assertSessionHasErrors('type'); +}); + +test('adjustment amount is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::UNPAID, + ]); + + $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'bonus', + 'amount' => '', + 'description' => 'Test', + ]); + + $response->assertSessionHasErrors('amount'); +}); + +test('adjustment amount must be at least 1', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::UNPAID, + ]); + + $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'bonus', + 'amount' => 0, + 'description' => 'Test', + ]); + + $response->assertSessionHasErrors('amount'); +}); + +test('adjustment description is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::UNPAID, + ]); + + $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'bonus', + 'amount' => 500000, + 'description' => '', + ]); + + $response->assertSessionHasErrors('description'); +}); + +test('adjustment description max length is 100', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::UNPAID, + ]); + + $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'bonus', + 'amount' => 500000, + 'description' => str_repeat('a', 101), + ]); + + $response->assertSessionHasErrors('description'); +}); + +test('adjustment description exactly 100 characters is accepted', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::UNPAID, + ]); + + $response = $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'bonus', + 'amount' => 500000, + 'description' => str_repeat('a', 100), + ]); + + $response->assertSessionHasNoErrors(); +}); + +/* +|-------------------------------------------------------------------------- +| PAYROLL ADJUSTMENT - DELETE +|-------------------------------------------------------------------------- +*/ + +test('adjustment can be deleted from unpaid payroll', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(['base_salary' => 3000000]); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'base_salary' => 3000000, + 'bonus_amount' => 500000, + 'deduction_amount' => 0, + 'total_amount' => 3500000, + 'status' => PayrollStatus::UNPAID, + ]); + + $adjustment = PayrollAdjustment::factory()->create([ + 'payroll_id' => $payroll->id, + 'created_by_id' => $user->id, + 'type' => PayrollAdjustmentType::BONUS, + 'amount' => 500000, + 'description' => 'Bonus performa', + ]); + + $response = $this->delete(route('admin.finance.payroll-adjustments.destroy', $adjustment)); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.finance.payroll-periods.show', $period)); + + $this->assertSoftDeleted('payroll_adjustments', ['id' => $adjustment->id]); + + $payroll->refresh(); + expect($payroll->bonus_amount)->toBe(0); + expect($payroll->total_amount)->toBe(3000000); +}); + +test('deleting deduction adjustment recalculates correctly', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(['base_salary' => 3000000]); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'base_salary' => 3000000, + 'bonus_amount' => 0, + 'deduction_amount' => 200000, + 'total_amount' => 2800000, + 'status' => PayrollStatus::UNPAID, + ]); + + $adjustment = PayrollAdjustment::factory()->create([ + 'payroll_id' => $payroll->id, + 'created_by_id' => $user->id, + 'type' => PayrollAdjustmentType::DEDUCTION, + 'amount' => 200000, + 'description' => 'Potongan telat', + ]); + + $this->delete(route('admin.finance.payroll-adjustments.destroy', $adjustment)); + + $payroll->refresh(); + expect($payroll->deduction_amount)->toBe(0); + expect($payroll->total_amount)->toBe(3000000); +}); + +test('adjustment cannot be deleted from paid payroll', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $period = PayrollPeriod::factory()->create(['status' => PayrollPeriodStatus::OPEN]); + $employee = Employee::factory()->create(); + $payroll = Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'status' => PayrollStatus::PAID, + ]); + + $adjustment = PayrollAdjustment::factory()->create([ + 'payroll_id' => $payroll->id, + 'created_by_id' => $user->id, + 'type' => PayrollAdjustmentType::BONUS, + 'amount' => 500000, + 'description' => 'Test', + ]); + + $response = $this->delete(route('admin.finance.payroll-adjustments.destroy', $adjustment)); + $response->assertRedirect(); + + $this->assertDatabaseHas('payroll_adjustments', ['id' => $adjustment->id, 'deleted_at' => null]); +}); + +test('deleting non-existent adjustment returns 404', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->delete(route('admin.finance.payroll-adjustments.destroy', 999999)); + $response->assertStatus(404); +}); + +/* +|-------------------------------------------------------------------------- +| GENERATE PAYROLL COMMAND +|-------------------------------------------------------------------------- +*/ + +test('generate payroll creates period and payrolls for active employees', function () { + Employee::factory()->count(3)->create([ + 'base_salary' => 3000000, + 'resign_date' => null, + ]); + + $this->artisan('payroll:generate') + ->expectsOutputToContain('Berhasil generate 3 gaji') + ->assertSuccessful(); + + $now = now(); + $this->assertDatabaseHas('payroll_periods', [ + 'year' => $now->year, + 'month' => $now->month, + 'status' => PayrollPeriodStatus::OPEN->value, + ]); + + $period = PayrollPeriod::where('year', $now->year)->where('month', $now->month)->first(); + $this->assertDatabaseCount('payrolls', 3); + + $payrolls = Payroll::where('payroll_period_id', $period->id)->get(); + expect($payrolls->every(fn ($p) => $p->status === PayrollStatus::UNPAID))->toBeTrue(); + expect($payrolls->every(fn ($p) => $p->base_salary === 3000000))->toBeTrue(); + expect($payrolls->every(fn ($p) => $p->total_amount === 3000000))->toBeTrue(); +}); + +test('generate payroll does not duplicate existing payrolls', function () { + $now = now(); + $period = PayrollPeriod::factory()->create([ + 'year' => $now->year, + 'month' => $now->month, + 'status' => PayrollPeriodStatus::OPEN, + ]); + + $employee = Employee::factory()->create(['base_salary' => 3000000]); + Payroll::factory()->create([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + ]); + + $this->artisan('payroll:generate') + ->expectsOutputToContain('Berhasil generate 0 gaji') + ->assertSuccessful(); + + $this->assertDatabaseCount('payrolls', 1); +}); + +test('generate payroll excludes resigned employees', function () { + Employee::factory()->create([ + 'base_salary' => 3000000, + 'resign_date' => now()->subMonth()->toDateString(), + ]); + + $this->artisan('payroll:generate')->assertSuccessful(); + + $this->assertDatabaseCount('payrolls', 0); +}); + +test('generate payroll uses employee base salary', function () { + Employee::factory()->create(['base_salary' => 3500000]); + Employee::factory()->create(['base_salary' => 4000000]); + + $this->artisan('payroll:generate') + ->expectsOutputToContain('Berhasil generate 2 gaji') + ->assertSuccessful(); + + $this->assertDatabaseHas('payrolls', ['base_salary' => 3500000, 'total_amount' => 3500000]); + $this->assertDatabaseHas('payrolls', ['base_salary' => 4000000, 'total_amount' => 4000000]); +}); + +test('generate payroll creates period with open status', function () { + Employee::factory()->create(); + + $this->artisan('payroll:generate')->assertSuccessful(); + + $now = now(); + $period = PayrollPeriod::where('year', $now->year)->where('month', $now->month)->first(); + expect($period->status)->toBe(PayrollPeriodStatus::OPEN); +}); + +test('generate payroll does not change closed period', function () { + $now = now(); + PayrollPeriod::factory()->create([ + 'year' => $now->year, + 'month' => $now->month, + 'status' => PayrollPeriodStatus::CLOSED, + ]); + + Employee::factory()->create(); + + $this->artisan('payroll:generate') + ->expectsOutputToContain('sudah ditutup') + ->assertExitCode(1); +}); + +/* +|-------------------------------------------------------------------------- +| MODEL & RELATIONSHIPS +|-------------------------------------------------------------------------- +*/ + +test('payroll factory creates valid payroll', function () { + $payroll = Payroll::factory()->create(); + + expect($payroll->employee)->not->toBeNull(); + expect($payroll->payrollPeriod)->not->toBeNull(); + expect($payroll->base_salary)->toBeGreaterThan(0); + expect($payroll->total_amount)->toBeGreaterThanOrEqual(0); + expect($payroll->status)->toBeInstanceOf(PayrollStatus::class); +}); + +test('payroll has correct relationships', function () { + $payroll = Payroll::factory()->create(); + + expect($payroll->employee)->not->toBeNull(); + expect($payroll->payrollPeriod)->not->toBeNull(); + expect($payroll->payrollAdjustments)->not->toBeNull(); +}); + +test('payroll period factory creates valid payroll period', function () { + $period = PayrollPeriod::factory()->create(); + + expect($period->year)->toBeGreaterThan(0); + expect($period->month)->toBeGreaterThanOrEqual(1); + expect($period->month)->toBeLessThanOrEqual(12); + expect($period->status)->toBeInstanceOf(PayrollPeriodStatus::class); +}); + +test('payroll adjustment factory creates valid adjustment', function () { + $adjustment = PayrollAdjustment::factory()->create(); + + expect($adjustment->payroll)->not->toBeNull(); + expect($adjustment->createdBy)->not->toBeNull(); + expect($adjustment->amount)->toBeGreaterThan(0); + expect($adjustment->type)->toBeInstanceOf(PayrollAdjustmentType::class); +}); + +/* +|-------------------------------------------------------------------------- +| REALISTIC USER SCENARIOS +|-------------------------------------------------------------------------- +*/ + +test('full payroll workflow: generate, adjust, pay', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + CashAccount::factory()->create(['balance' => 0]); + + $employee = Employee::factory()->create(['base_salary' => 3000000]); + + $this->artisan('payroll:generate')->assertSuccessful(); + + $now = now(); + $period = PayrollPeriod::where('year', $now->year)->where('month', $now->month)->first(); + $payroll = Payroll::where('payroll_period_id', $period->id)->first(); + + expect($payroll->status)->toBe(PayrollStatus::UNPAID); + expect($payroll->total_amount)->toBe(3000000); + + $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'bonus', + 'amount' => 500000, + 'description' => 'Bonus performa', + ]); + + $payroll->refresh(); + expect($payroll->bonus_amount)->toBe(500000); + expect($payroll->total_amount)->toBe(3500000); + + $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'deduction', + 'amount' => 100000, + 'description' => 'Potongan telat', + ]); + + $payroll->refresh(); + expect($payroll->deduction_amount)->toBe(100000); + expect($payroll->total_amount)->toBe(3400000); + + $this->post(route('admin.finance.payrolls.pay', $payroll)); + + $payroll->refresh(); + expect($payroll->status)->toBe(PayrollStatus::PAID); + + expect(CashAccount::first()->balance)->toBe(3400000); +}); + +test('full payroll workflow: generate, adjust, delete adjustment, pay', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + CashAccount::factory()->create(['balance' => 0]); + + $employee = Employee::factory()->create(['base_salary' => 3000000]); + + $this->artisan('payroll:generate')->assertSuccessful(); + + $now = now(); + $period = PayrollPeriod::where('year', $now->year)->where('month', $now->month)->first(); + $payroll = Payroll::where('payroll_period_id', $period->id)->first(); + + $this->post(route('admin.finance.payrolls.adjustments.store', $payroll), [ + 'type' => 'bonus', + 'amount' => 500000, + 'description' => 'Bonus', + ]); + + $adjustment = PayrollAdjustment::where('payroll_id', $payroll->id)->first(); + $this->delete(route('admin.finance.payroll-adjustments.destroy', $adjustment)); + + $payroll->refresh(); + expect($payroll->bonus_amount)->toBe(0); + expect($payroll->total_amount)->toBe(3000000); + + $this->post(route('admin.finance.payrolls.pay', $payroll)); + + expect(CashAccount::first()->balance)->toBe(3000000); +}); + +test('full payroll workflow: generate, close period', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + CashAccount::factory()->create(['balance' => 0]); + + $employee = Employee::factory()->create(['base_salary' => 3000000]); + + $this->artisan('payroll:generate')->assertSuccessful(); + + $now = now(); + $period = PayrollPeriod::where('year', $now->year)->where('month', $now->month)->first(); + $payroll = Payroll::where('payroll_period_id', $period->id)->first(); + + $this->post(route('admin.finance.payrolls.pay', $payroll)); + + $this->post(route('admin.finance.payroll-periods.close', $period)); + + $period->refresh(); + expect($period->status)->toBe(PayrollPeriodStatus::CLOSED); + + $this->post(route('admin.finance.payroll-periods.reopen', $period)); + + $period->refresh(); + expect($period->status)->toBe(PayrollPeriodStatus::OPEN); +});