diff --git a/app/Console/Commands/OpenPayrollPeriodCommand.php b/app/Console/Commands/OpenPayrollPeriodCommand.php new file mode 100644 index 0000000..796331f --- /dev/null +++ b/app/Console/Commands/OpenPayrollPeriodCommand.php @@ -0,0 +1,26 @@ +openCurrentPeriod(); + + $this->info(sprintf( + 'Periode gaji %s dibuka. Total %d slip gaji.', + $period->period_label, + $period->payrolls()->count(), + )); + + return self::SUCCESS; + } +} diff --git a/app/Enums/PayrollAdjustmentType.php b/app/Enums/PayrollAdjustmentType.php new file mode 100644 index 0000000..1d0aa8c --- /dev/null +++ b/app/Enums/PayrollAdjustmentType.php @@ -0,0 +1,21 @@ + 'Tunjangan', + self::DEDUCTION => 'Potongan', + }; + } +} diff --git a/app/Enums/PayrollPeriodStatus.php b/app/Enums/PayrollPeriodStatus.php new file mode 100644 index 0000000..8d88d22 --- /dev/null +++ b/app/Enums/PayrollPeriodStatus.php @@ -0,0 +1,21 @@ + 'Dibuka', + self::CLOSED => 'Ditutup', + }; + } +} diff --git a/app/Enums/PayrollStatus.php b/app/Enums/PayrollStatus.php new file mode 100644 index 0000000..ce82901 --- /dev/null +++ b/app/Enums/PayrollStatus.php @@ -0,0 +1,21 @@ + 'Belum Dibayar', + self::PAID => 'Sudah Dibayar', + }; + } +} diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index 9048ff0..5cf6357 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -61,6 +61,11 @@ enum Permission: string case EMPLOYEE_ADVANCES_VERIFY = 'employee-advances.verify'; case EMPLOYEE_ADVANCES_PAY = 'employee-advances.pay'; + case PAYROLL_VIEW = 'payroll.view'; + case PAYROLL_PAY = 'payroll.pay'; + case PAYROLL_ADJUST = 'payroll.adjust'; + case PAYROLL_CLOSE = 'payroll.close'; + public function label(): string { return match ($this) { @@ -116,6 +121,11 @@ public function label(): string self::EMPLOYEE_ADVANCES_DELETE => 'Hapus Kasbon', self::EMPLOYEE_ADVANCES_VERIFY => 'Setujui/Tolak Kasbon', self::EMPLOYEE_ADVANCES_PAY => 'Pelunasi Kasbon', + + self::PAYROLL_VIEW => 'Lihat Gaji', + self::PAYROLL_PAY => 'Bayar Gaji', + self::PAYROLL_ADJUST => 'Sesuaikan Gaji', + self::PAYROLL_CLOSE => 'Tutup Periode Gaji', }; } @@ -141,6 +151,8 @@ public function group(): string self::EMPLOYEE_ADVANCES_VIEW, self::EMPLOYEE_ADVANCES_CREATE, self::EMPLOYEE_ADVANCES_UPDATE, self::EMPLOYEE_ADVANCES_DELETE, self::EMPLOYEE_ADVANCES_VERIFY, self::EMPLOYEE_ADVANCES_PAY => 'Kasbon', + self::PAYROLL_VIEW, self::PAYROLL_PAY, self::PAYROLL_ADJUST, + self::PAYROLL_CLOSE => 'Gaji', }; } diff --git a/app/Enums/Role.php b/app/Enums/Role.php index 4c3e46d..a5a2f46 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -79,6 +79,10 @@ public function permissions(): array Permission::EMPLOYEE_ADVANCES_UPDATE, Permission::EMPLOYEE_ADVANCES_DELETE, Permission::EMPLOYEE_ADVANCES_PAY, + Permission::PAYROLL_VIEW, + Permission::PAYROLL_PAY, + Permission::PAYROLL_ADJUST, + Permission::PAYROLL_CLOSE, ], self::ADMIN_TOKO => [ Permission::DASHBOARD_VIEW, @@ -117,6 +121,10 @@ public function permissions(): array Permission::EMPLOYEE_ADVANCES_UPDATE, Permission::EMPLOYEE_ADVANCES_DELETE, Permission::EMPLOYEE_ADVANCES_PAY, + Permission::PAYROLL_VIEW, + Permission::PAYROLL_PAY, + Permission::PAYROLL_ADJUST, + Permission::PAYROLL_CLOSE, ], self::ADMIN_BAHAN_BAKU => [ Permission::DASHBOARD_VIEW, diff --git a/app/Http/Controllers/Admin/Finance/PayrollController.php b/app/Http/Controllers/Admin/Finance/PayrollController.php new file mode 100644 index 0000000..de6f32e --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/PayrollController.php @@ -0,0 +1,43 @@ +payrollService->pay($payroll, auth()->user()); + + Inertia::flash('success', 'Gaji berhasil dibayar.'); + + return redirect()->route('admin.finance.payroll.index', [ + 'period_id' => $payroll->payroll_period_id, + ]); + } + + public function storeAdjustment(PayrollAdjustmentRequest $request, Payroll $payroll): RedirectResponse + { + $this->payrollService->addAdjustment( + $payroll, + $request->validated(), + auth()->user(), + ); + + Inertia::flash('success', 'Penyesuaian gaji berhasil ditambahkan.'); + + return redirect()->route('admin.finance.payroll.index', [ + 'period_id' => $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..9615961 --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/PayrollPeriodController.php @@ -0,0 +1,56 @@ +parseDataTableQuery($request); + $periodId = $request->integer('period_id') ?: null; + $period = $this->payrollService->resolvePeriod($periodId); + + return Inertia::render('admin/finance/payroll/Index', [ + 'periods' => $this->payrollService->listPeriods(), + 'currentPeriod' => $period, + 'payrolls' => $period + ? $this->payrollService->paginateForPeriod($period, $tableQuery) + : null, + 'summary' => $period + ? $this->payrollService->periodSummary($period) + : null, + 'adjustmentTypes' => PayrollAdjustmentType::selectOptions(), + 'filters' => array_merge( + $this->dataTableFilters($tableQuery), + ['period_id' => $period?->id], + ), + ]); + } + + public function close(PayrollPeriod $payrollPeriod): RedirectResponse + { + $this->payrollService->closePeriod($payrollPeriod, auth()->user()); + + Inertia::flash('success', 'Periode gaji berhasil ditutup.'); + + return redirect()->route('admin.finance.payroll.index', [ + 'period_id' => $payrollPeriod->id, + ]); + } +} diff --git a/app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php b/app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php new file mode 100644 index 0000000..34b737a --- /dev/null +++ b/app/Http/Requests/Admin/Finance/PayrollAdjustmentRequest.php @@ -0,0 +1,28 @@ +user()?->can(Permission::PAYROLL_ADJUST->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'type' => ['required', Rule::enum(PayrollAdjustmentType::class)], + 'amount' => ['required', 'integer', 'min:1'], + 'description' => ['required', 'string', 'max:500'], + ]; + } +} diff --git a/app/Models/CashTransaction.php b/app/Models/CashTransaction.php index 44c719a..4223849 100644 --- a/app/Models/CashTransaction.php +++ b/app/Models/CashTransaction.php @@ -96,6 +96,7 @@ public static function labelForReferenceType(?string $referenceType): string return match ($referenceType) { Expense::class => 'Pengeluaran', EmployeeAdvance::class => 'Kasbon Pegawai', + Payroll::class => 'Gaji Pegawai', default => class_basename($referenceType), }; } diff --git a/app/Models/Employee.php b/app/Models/Employee.php index 78108ec..21c69cd 100644 --- a/app/Models/Employee.php +++ b/app/Models/Employee.php @@ -97,4 +97,9 @@ public function advances(): HasMany { return $this->hasMany(EmployeeAdvance::class); } + + public function payrolls(): HasMany + { + return $this->hasMany(Payroll::class); + } } diff --git a/app/Models/Payroll.php b/app/Models/Payroll.php new file mode 100644 index 0000000..d036480 --- /dev/null +++ b/app/Models/Payroll.php @@ -0,0 +1,162 @@ + 'integer', + 'bonus_amount' => 'integer', + 'deduction_amount' => 'integer', + 'net_amount' => 'integer', + 'status' => PayrollStatus::class, + 'paid_at' => 'datetime', + ]; + } + + public function baseSalaryFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->base_salary, 0, ',', '.'), + ); + } + + public function bonusAmountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->bonus_amount, 0, ',', '.'), + ); + } + + public function deductionAmountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->deduction_amount, 0, ',', '.'), + ); + } + + public function netAmountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->net_amount, 0, ',', '.'), + ); + } + + public function statusLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->status?->label(), + ); + } + + public function employeeName(): Attribute + { + return Attribute::make( + get: fn () => $this->employee?->user?->profile?->full_name + ?? $this->employee?->user?->username, + ); + } + + public function paidAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->paid_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function canPay(): Attribute + { + return Attribute::make( + get: fn () => $this->status === PayrollStatus::UNPAID + && $this->relationLoaded('payrollPeriod') + && $this->payrollPeriod?->isOpen(), + ); + } + + public function canAdjust(): Attribute + { + return Attribute::make( + get: fn () => $this->status === PayrollStatus::UNPAID + && $this->relationLoaded('payrollPeriod') + && $this->payrollPeriod?->isOpen(), + ); + } + + public function payrollPeriod(): BelongsTo + { + return $this->belongsTo(PayrollPeriod::class); + } + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function paidBy(): BelongsTo + { + return $this->belongsTo(User::class, 'paid_by_id'); + } + + public function cashTransaction(): BelongsTo + { + return $this->belongsTo(CashTransaction::class); + } + + public function adjustments(): HasMany + { + return $this->hasMany(PayrollAdjustment::class); + } + + public function recalculateAmounts(): void + { + $bonusAmount = (int) $this->adjustments() + ->where('type', PayrollAdjustmentType::BONUS) + ->sum('amount'); + + $manualDeduction = (int) $this->adjustments() + ->where('type', PayrollAdjustmentType::DEDUCTION) + ->sum('amount'); + + $kasbonDeduction = $this->calculateKasbonDeduction(); + + $this->bonus_amount = $bonusAmount; + $this->deduction_amount = $kasbonDeduction + $manualDeduction; + $this->net_amount = max(0, $this->base_salary + $bonusAmount - $this->deduction_amount); + } + + public function calculateKasbonDeduction(): int + { + $outstanding = (int) EmployeeAdvance::query() + ->where('employee_id', $this->employee_id) + ->where('status', EmployeeAdvanceStatus::APPROVED) + ->sum('amount'); + + return min($outstanding, $this->base_salary + (int) $this->adjustments() + ->where('type', PayrollAdjustmentType::BONUS) + ->sum('amount')); + } +} diff --git a/app/Models/PayrollAdjustment.php b/app/Models/PayrollAdjustment.php new file mode 100644 index 0000000..60ff6f7 --- /dev/null +++ b/app/Models/PayrollAdjustment.php @@ -0,0 +1,64 @@ + PayrollAdjustmentType::class, + 'amount' => 'integer', + 'created_at' => 'datetime', + ]; + } + + public function amountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'), + ); + } + + public function typeLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->type?->label(), + ); + } + + public function createdAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function createdByName(): Attribute + { + return Attribute::make( + get: fn () => $this->createdBy?->profile?->full_name ?? $this->createdBy?->username, + ); + } + + public function payroll(): BelongsTo + { + return $this->belongsTo(Payroll::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_id'); + } +} diff --git a/app/Models/PayrollPeriod.php b/app/Models/PayrollPeriod.php new file mode 100644 index 0000000..84cd7ca --- /dev/null +++ b/app/Models/PayrollPeriod.php @@ -0,0 +1,61 @@ + PayrollPeriodStatus::class, + 'closed_at' => 'datetime', + ]; + } + + public function periodLabel(): Attribute + { + return Attribute::make( + get: fn () => Carbon::create($this->year, $this->month, 1)->translatedFormat('F Y'), + ); + } + + public function statusLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->status?->label(), + ); + } + + public function closedAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->closed_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function payrolls(): HasMany + { + return $this->hasMany(Payroll::class); + } + + public function closedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'closed_by_id'); + } + + public function isOpen(): bool + { + return $this->status === PayrollPeriodStatus::OPEN; + } +} diff --git a/app/Services/Finance/PayrollService.php b/app/Services/Finance/PayrollService.php new file mode 100644 index 0000000..604c4b7 --- /dev/null +++ b/app/Services/Finance/PayrollService.php @@ -0,0 +1,343 @@ + + */ + public function listPeriods(): Collection + { + return PayrollPeriod::query() + ->orderByDesc('year') + ->orderByDesc('month') + ->get(); + } + + public function resolvePeriod(?int $periodId): ?PayrollPeriod + { + if ($periodId !== null) { + return PayrollPeriod::query()->find($periodId); + } + + return PayrollPeriod::query() + ->where('status', PayrollPeriodStatus::OPEN) + ->orderByDesc('year') + ->orderByDesc('month') + ->first() + ?? PayrollPeriod::query() + ->orderByDesc('year') + ->orderByDesc('month') + ->first(); + } + + /** + * @return array{total_net_amount: int, total_net_amount_formatted: string, unpaid_count: int, paid_count: int} + */ + public function periodSummary(PayrollPeriod $period): array + { + $totalNetAmount = (int) Payroll::query() + ->where('payroll_period_id', $period->id) + ->where('status', PayrollStatus::UNPAID) + ->sum('net_amount'); + + $unpaidCount = Payroll::query() + ->where('payroll_period_id', $period->id) + ->where('status', PayrollStatus::UNPAID) + ->count(); + + $paidCount = Payroll::query() + ->where('payroll_period_id', $period->id) + ->where('status', PayrollStatus::PAID) + ->count(); + + return [ + 'total_net_amount' => $totalNetAmount, + 'total_net_amount_formatted' => 'Rp '.number_format($totalNetAmount, 0, ',', '.'), + 'unpaid_count' => $unpaidCount, + 'paid_count' => $paidCount, + ]; + } + + /** + * @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery + */ + public function paginateForPeriod(PayrollPeriod $period, array $tableQuery): LengthAwarePaginator + { + $query = Payroll::query() + ->with(['employee.user.profile', 'payrollPeriod']) + ->where('payroll_period_id', $period->id) + ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { + $search = $tableQuery['search']; + $query->where(function (Builder $query) use ($search): void { + $query->whereHas('employee.user.profile', fn (Builder $query) => $query->where('full_name', 'like', "%{$search}%")) + ->orWhereHas('employee.user', fn (Builder $query) => $query->where('username', 'like', "%{$search}%")); + }); + }); + + $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']); + + return $query + ->paginate(10) + ->withQueryString(); + } + + public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod + { + return DB::transaction(function () use ($closedBy): PayrollPeriod { + $now = now(); + $year = $now->year; + $month = $now->month; + + PayrollPeriod::query() + ->where('status', PayrollPeriodStatus::OPEN) + ->update([ + 'status' => PayrollPeriodStatus::CLOSED, + 'closed_at' => now(), + 'closed_by_id' => $closedBy?->id, + ]); + + $period = PayrollPeriod::query() + ->where('year', $year) + ->where('month', $month) + ->first(); + + if ($period === null) { + $period = PayrollPeriod::create([ + 'year' => $year, + 'month' => $month, + 'status' => PayrollPeriodStatus::OPEN, + ]); + } elseif ($period->status === PayrollPeriodStatus::CLOSED) { + $period->status = PayrollPeriodStatus::OPEN; + $period->closed_at = null; + $period->closed_by_id = null; + $period->save(); + } + + $this->generatePayrollsForPeriod($period); + + return $period->fresh(); + }); + } + + public function closePeriod(PayrollPeriod $period, User $user): void + { + if (! $period->isOpen()) { + throw ValidationException::withMessages([ + 'payroll_period' => 'Periode gaji ini sudah ditutup.', + ]); + } + + $unpaidCount = Payroll::query() + ->where('payroll_period_id', $period->id) + ->where('status', PayrollStatus::UNPAID) + ->where('net_amount', '>', 0) + ->count(); + + if ($unpaidCount > 0) { + throw ValidationException::withMessages([ + 'payroll_period' => 'Masih ada gaji yang belum dibayar.', + ]); + } + + $period->status = PayrollPeriodStatus::CLOSED; + $period->closed_at = now(); + $period->closed_by_id = $user->id; + $period->save(); + } + + public function generatePayrollsForPeriod(PayrollPeriod $period): void + { + $employees = $this->payrollEligibleEmployees($period); + + foreach ($employees as $employee) { + $exists = Payroll::query() + ->where('payroll_period_id', $period->id) + ->where('employee_id', $employee->id) + ->exists(); + + if ($exists) { + continue; + } + + $payroll = new Payroll([ + 'payroll_period_id' => $period->id, + 'employee_id' => $employee->id, + 'base_salary' => $employee->base_salary, + 'bonus_amount' => 0, + 'deduction_amount' => 0, + 'net_amount' => 0, + 'status' => PayrollStatus::UNPAID, + ]); + + $payroll->save(); + $payroll->recalculateAmounts(); + $payroll->save(); + } + } + + /** + * @param array{type: string, amount: int, description: string} $validated + */ + public function addAdjustment(Payroll $payroll, array $validated, User $user): void + { + $payroll->loadMissing('payrollPeriod'); + + if (! $payroll->can_adjust) { + throw ValidationException::withMessages([ + 'payroll' => 'Penyesuaian hanya dapat ditambahkan pada gaji yang belum dibayar di periode terbuka.', + ]); + } + + DB::transaction(function () use ($payroll, $validated, $user): void { + $payroll->adjustments()->create([ + 'type' => PayrollAdjustmentType::from($validated['type']), + 'amount' => (int) $validated['amount'], + 'description' => $validated['description'], + 'created_by_id' => $user->id, + ]); + + $payroll->load('adjustments'); + $payroll->recalculateAmounts(); + $payroll->save(); + }); + } + + public function pay(Payroll $payroll, User $user): void + { + $payroll->loadMissing(['payrollPeriod', 'employee.user.profile']); + + if ($payroll->status !== PayrollStatus::UNPAID) { + throw ValidationException::withMessages([ + 'payroll' => 'Gaji ini sudah dibayar.', + ]); + } + + if (! $payroll->payrollPeriod?->isOpen()) { + throw ValidationException::withMessages([ + 'payroll' => 'Periode gaji sudah ditutup.', + ]); + } + + if ($payroll->net_amount <= 0) { + DB::transaction(function () use ($payroll, $user): void { + $payroll->status = PayrollStatus::PAID; + $payroll->paid_at = now(); + $payroll->paid_by_id = $user->id; + $payroll->save(); + + $this->settleKasbonFromPayroll($payroll, $user); + }); + + return; + } + + DB::transaction(function () use ($payroll, $user): void { + $description = sprintf( + 'Pembayaran gaji: %s (%s)', + $payroll->employeeName, + $payroll->payrollPeriod->period_label, + ); + + $cashTransaction = $this->cashService->recordOutgoing( + $payroll, + $payroll->net_amount, + $description, + $user, + ); + + $payroll->cash_transaction_id = $cashTransaction->id; + $payroll->status = PayrollStatus::PAID; + $payroll->paid_at = now(); + $payroll->paid_by_id = $user->id; + $payroll->save(); + + $this->settleKasbonFromPayroll($payroll, $user); + }); + } + + /** + * @return Collection + */ + private function payrollEligibleEmployees(PayrollPeriod $period): Collection + { + $periodStart = Carbon::create($period->year, $period->month, 1)->startOfMonth(); + + return Employee::query() + ->with('user.roles') + ->whereHas('user', function (Builder $query): void { + $query->where('is_active', true) + ->whereDoesntHave('roles', fn (Builder $query) => $query->whereIn('name', [ + Role::DEVELOPER->value, + Role::OWNER->value, + ])); + }) + ->where(function (Builder $query) use ($periodStart): void { + $query->whereNull('resign_date') + ->orWhere('resign_date', '>=', $periodStart); + }) + ->get(); + } + + private function settleKasbonFromPayroll(Payroll $payroll, User $user): void + { + $remaining = (int) $payroll->deduction_amount; + + if ($remaining <= 0) { + return; + } + + $advances = EmployeeAdvance::query() + ->where('employee_id', $payroll->employee_id) + ->where('status', EmployeeAdvanceStatus::APPROVED) + ->orderBy('created_at') + ->get(); + + foreach ($advances as $advance) { + if ($remaining <= 0) { + break; + } + + $advance->paid_at = now(); + $advance->paid_by_id = $user->id; + $advance->status = EmployeeAdvanceStatus::PAID; + $advance->save(); + + $remaining -= $advance->amount; + } + } + + private function applySorting(Builder $query, string $sort, string $direction): void + { + if (in_array($sort, ['base_salary', 'bonus_amount', 'deduction_amount', 'net_amount', 'status', 'created_at'], true)) { + $query->orderBy($sort, $direction); + + return; + } + + $query->orderBy('employee_id'); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index b9b2d94..7adc116 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; @@ -34,4 +35,9 @@ $exceptions->shouldRenderJsonWhen( fn (Request $request) => $request->is('api/*'), ); + }) + ->withSchedule(function (Schedule $schedule): void { + $schedule->command('payroll:open-period') + ->monthlyOn(1, '00:05') + ->timezone('Asia/Jakarta'); })->create(); diff --git a/database/migrations/2026_06_11_100001_create_payroll_periods_table.php b/database/migrations/2026_06_11_100001_create_payroll_periods_table.php new file mode 100644 index 0000000..85c8497 --- /dev/null +++ b/database/migrations/2026_06_11_100001_create_payroll_periods_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedSmallInteger('year'); + $table->unsignedTinyInteger('month'); + $table->enum('status', array_column(PayrollPeriodStatus::cases(), 'value')) + ->default(PayrollPeriodStatus::OPEN->value); + $table->timestamp('closed_at')->nullable(); + $table->foreignId('closed_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['year', 'month']); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('payroll_periods'); + } +}; diff --git a/database/migrations/2026_06_11_100002_create_payrolls_table.php b/database/migrations/2026_06_11_100002_create_payrolls_table.php new file mode 100644 index 0000000..0e807aa --- /dev/null +++ b/database/migrations/2026_06_11_100002_create_payrolls_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('payroll_period_id')->constrained()->cascadeOnDelete(); + $table->foreignId('employee_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('base_salary'); + $table->unsignedBigInteger('bonus_amount')->default(0); + $table->unsignedBigInteger('deduction_amount')->default(0); + $table->unsignedBigInteger('net_amount'); + $table->enum('status', array_column(PayrollStatus::cases(), 'value')) + ->default(PayrollStatus::UNPAID->value); + $table->foreignId('cash_transaction_id')->nullable()->unique()->constrained()->restrictOnDelete(); + $table->timestamp('paid_at')->nullable(); + $table->foreignId('paid_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['payroll_period_id', 'employee_id']); + $table->index(['payroll_period_id', 'status']); + $table->index('employee_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('payrolls'); + } +}; diff --git a/database/migrations/2026_06_11_100003_create_payroll_adjustments_table.php b/database/migrations/2026_06_11_100003_create_payroll_adjustments_table.php new file mode 100644 index 0000000..7e5b6f7 --- /dev/null +++ b/database/migrations/2026_06_11_100003_create_payroll_adjustments_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('payroll_id')->constrained()->cascadeOnDelete(); + $table->enum('type', array_column(PayrollAdjustmentType::cases(), 'value')); + $table->unsignedBigInteger('amount'); + $table->string('description', 500); + $table->foreignId('created_by_id')->constrained('users')->restrictOnDelete(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['payroll_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('payroll_adjustments'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index b604f4f..5c40510 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ + +