From 2dda08bb8dabc6dd697dcf9e04a7ea554250fa0a Mon Sep 17 00:00:00 2001 From: Sari Date: Mon, 3 Aug 2026 09:12:46 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20audit=20log=20poin=20=E2=80=94=20catat?= =?UTF-8?q?=20pencatatan/penghapusan=20pelanggaran=20(sebelum/sesudah/delt?= =?UTF-8?q?a,=20metadata=20snapshot,=20actor,=20IP)=20+=20halaman=20Riwaya?= =?UTF-8?q?t=20Poin=20(filter,=20permission=20view-point-audit)=20+=204=20?= =?UTF-8?q?feature=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Admin/PointAuditController.php | 39 ++++ app/Http/Controllers/ViolationController.php | 25 +++ app/Models/PointAuditLog.php | 88 +++++++++ app/Services/ViolationService.php | 20 ++ ...3_090949_create_point_audit_logs_table.php | 44 +++++ ...8_03_090950_add_point_audit_permission.php | 44 +++++ resources/views/components/nav-item.blade.php | 1 + resources/views/layouts/app.blade.php | 4 + resources/views/point-audit/index.blade.php | 176 ++++++++++++++++++ routes/web.php | 4 + tests/Feature/PointAuditTest.php | 131 +++++++++++++ 11 files changed, 576 insertions(+) create mode 100644 app/Http/Controllers/Admin/PointAuditController.php create mode 100644 app/Models/PointAuditLog.php create mode 100644 database/migrations/2026_08_03_090949_create_point_audit_logs_table.php create mode 100644 database/migrations/2026_08_03_090950_add_point_audit_permission.php create mode 100644 resources/views/point-audit/index.blade.php create mode 100644 tests/Feature/PointAuditTest.php diff --git a/app/Http/Controllers/Admin/PointAuditController.php b/app/Http/Controllers/Admin/PointAuditController.php new file mode 100644 index 0000000..b21a481 --- /dev/null +++ b/app/Http/Controllers/Admin/PointAuditController.php @@ -0,0 +1,39 @@ +user()->isScopedWaliKelas()) { + $query->whereHas('student', fn ($q) => $q->whereIn('class_id', auth()->user()->homeroomClassIds())); + } + + $query->when($request->filled('search'), fn ($q) => $q->whereHas('student', fn ($qq) => $qq + ->where('full_name', 'like', "%{$request->search}%") + ->orWhere('nisn', 'like', "%{$request->search}%"))); + + $query->when($request->filled('action'), fn ($q) => $q->where('action', $request->action)); + $query->when($request->filled('date_from'), fn ($q) => $q->whereDate('created_at', '>=', $request->date_from)); + $query->when($request->filled('date_to'), fn ($q) => $q->whereDate('created_at', '<=', $request->date_to)); + + $logs = $query->orderByDesc('created_at')->paginate(25)->withQueryString(); + + $summary = [ + 'total' => PointAuditLog::count(), + 'penambahan' => PointAuditLog::where('points_delta', '>', 0)->count(), + 'pengurangan' => PointAuditLog::where('points_delta', '<', 0)->count(), + ]; + + return view('point-audit.index', compact('logs', 'summary')); + } +} diff --git a/app/Http/Controllers/ViolationController.php b/app/Http/Controllers/ViolationController.php index e345938..3dbc512 100644 --- a/app/Http/Controllers/ViolationController.php +++ b/app/Http/Controllers/ViolationController.php @@ -272,6 +272,31 @@ class ViolationController extends Controller public function destroy(Violation $violation): RedirectResponse { + abort_unless(auth()->user()->canViewStudent($violation->student_id), 403); + + // Audit log perubahan poin (penghapusan pelanggaran) + $student = $violation->student; + $before = $student->total_points; // termasuk pelanggaran yang akan dihapus + $after = max(0, $before - $violation->points); + + \App\Models\PointAuditLog::log( + studentId: $violation->student_id, + action: \App\Models\PointAuditLog::ACTION_DELETED, + pointsBefore: $before, + pointsAfter: $after, + description: ($violation->violationType?->name ?? 'Pelanggaran').' (dihapus)', + metadata: [ + 'violation_date' => $violation->violation_date?->format('Y-m-d'), + 'description' => $violation->description, + 'sanction' => $violation->sanction, + 'location' => $violation->location, + 'points' => $violation->points, + ], + violationId: $violation->id, + actorId: auth()->id(), + ipAddress: request()->ip(), + ); + $violation->delete(); return redirect()->route('violations.index') diff --git a/app/Models/PointAuditLog.php b/app/Models/PointAuditLog.php new file mode 100644 index 0000000..2891829 --- /dev/null +++ b/app/Models/PointAuditLog.php @@ -0,0 +1,88 @@ + */ + use HasFactory; + + public const ACTION_CREATED = 'created'; + public const ACTION_DELETED = 'deleted'; + public const ACTION_ADJUSTED = 'adjusted'; + + protected $fillable = [ + 'student_id', 'violation_id', 'action', + 'points_before', 'points_after', 'points_delta', + 'description', 'metadata', 'actor_id', 'ip_address', + ]; + + protected function casts(): array + { + return [ + 'points_before' => 'integer', + 'points_after' => 'integer', + 'points_delta' => 'integer', + 'metadata' => 'array', + ]; + } + + public function student(): BelongsTo + { + return $this->belongsTo(Student::class); + } + + public function violation(): BelongsTo + { + return $this->belongsTo(Violation::class); + } + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id'); + } + + public function getActionLabelAttribute(): string + { + return match ($this->action) { + self::ACTION_CREATED => 'Pencatatan', + self::ACTION_DELETED => 'Penghapusan', + self::ACTION_ADJUSTED => 'Penyesuaian', + default => ucfirst($this->action), + }; + } + + /** + * Catat log audit perubahan poin. + * + * @param array $metadata + */ + public static function log( + int $studentId, + string $action, + int $pointsBefore, + int $pointsAfter, + ?string $description = null, + ?array $metadata = null, + ?int $violationId = null, + ?int $actorId = null, + ?string $ipAddress = null, + ): self { + return static::create([ + 'student_id' => $studentId, + 'violation_id' => $violationId, + 'action' => $action, + 'points_before' => $pointsBefore, + 'points_after' => $pointsAfter, + 'points_delta' => $pointsAfter - $pointsBefore, + 'description' => $description, + 'metadata' => $metadata, + 'actor_id' => $actorId, + 'ip_address' => $ipAddress, + ]); + } +} diff --git a/app/Services/ViolationService.php b/app/Services/ViolationService.php index e6c13d1..22b41c9 100644 --- a/app/Services/ViolationService.php +++ b/app/Services/ViolationService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Events\ViolationRecorded; +use App\Models\PointAuditLog; use App\Models\SpLetter; use App\Models\SpThreshold; use App\Models\Student; @@ -45,6 +46,25 @@ class ViolationService } } + // Audit log perubahan poin (pencatatan pelanggaran) + $after = $violation->student->total_points; + PointAuditLog::log( + studentId: $violation->student_id, + action: PointAuditLog::ACTION_CREATED, + pointsBefore: $after - $violation->points, + pointsAfter: $after, + description: $violation->violationType?->name ?? 'Pelanggaran', + metadata: [ + 'violation_date' => $violation->violation_date?->format('Y-m-d'), + 'description' => $violation->description, + 'sanction' => $violation->sanction, + 'location' => $violation->location, + ], + violationId: $violation->id, + actorId: $userId, + ipAddress: request()->ip(), + ); + return $violation; }); diff --git a/database/migrations/2026_08_03_090949_create_point_audit_logs_table.php b/database/migrations/2026_08_03_090949_create_point_audit_logs_table.php new file mode 100644 index 0000000..935b1a8 --- /dev/null +++ b/database/migrations/2026_08_03_090949_create_point_audit_logs_table.php @@ -0,0 +1,44 @@ +id(); + $table->unsignedBigInteger('student_id'); + $table->unsignedBigInteger('violation_id')->nullable(); + $table->string('action', 20); // created | deleted | adjusted + $table->integer('points_before')->default(0); + $table->integer('points_after')->default(0); + $table->integer('points_delta')->default(0); // bertanda (+ / -) + $table->string('description')->nullable(); + $table->json('metadata')->nullable(); // snapshot data saat aksi + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->timestamps(); + + $table->foreign('student_id')->references('id')->on('students')->cascadeOnDelete(); + $table->foreign('violation_id')->references('id')->on('violations')->nullOnDelete(); + $table->foreign('actor_id')->references('id')->on('users')->nullOnDelete(); + + $table->index(['student_id', 'created_at']); + $table->index('action'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('point_audit_logs'); + } +}; diff --git a/database/migrations/2026_08_03_090950_add_point_audit_permission.php b/database/migrations/2026_08_03_090950_add_point_audit_permission.php new file mode 100644 index 0000000..eec702d --- /dev/null +++ b/database/migrations/2026_08_03_090950_add_point_audit_permission.php @@ -0,0 +1,44 @@ +insert([ + 'key' => 'view-point-audit', + 'group' => 'master-data', + 'label' => 'Lihat Riwayat Perubahan Poin', + 'description' => 'Melihat audit log penambahan/pengurangan poin siswa', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $permissionId = DB::table('permissions')->where('key', 'view-point-audit')->value('id'); + + // BK & Waka Kesiswaan (waka disetarakan dengan BK) + foreach (['bk', 'waka_kesiswaan'] as $role) { + DB::table('role_permissions')->insert([ + 'role' => $role, + 'permission_id' => $permissionId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + public function down(): void + { + $permissionId = DB::table('permissions')->where('key', 'view-point-audit')->value('id'); + + DB::table('role_permissions')->where('permission_id', $permissionId)->delete(); + DB::table('permissions')->where('key', 'view-point-audit')->delete(); + } +}; diff --git a/resources/views/components/nav-item.blade.php b/resources/views/components/nav-item.blade.php index 5809030..ca2d636 100644 --- a/resources/views/components/nav-item.blade.php +++ b/resources/views/components/nav-item.blade.php @@ -14,6 +14,7 @@ $faIcons = [ 'clipboard-check' => 'fa-solid fa-clipboard-check', 'document-text' => 'fa-solid fa-file-lines', 'file-lines' => 'fa-solid fa-file-lines', + 'clock-rotate-left' => 'fa-solid fa-clock-rotate-left', 'tag' => 'fa-solid fa-tag', 'list' => 'fa-solid fa-list', 'chart-bar' => 'fa-solid fa-chart-simple', diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 933ee6d..a91c4ad 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -171,6 +171,10 @@ Laporan @endcanPermission + @canPermission('view-point-audit') + Riwayat Poin + @endcanPermission + @canPermission('categories-manage')

Master Data

diff --git a/resources/views/point-audit/index.blade.php b/resources/views/point-audit/index.blade.php new file mode 100644 index 0000000..4b9993a --- /dev/null +++ b/resources/views/point-audit/index.blade.php @@ -0,0 +1,176 @@ +@extends('layouts.app') + +@section('title', 'Riwayat Poin') + +@section('content') +
+ + {{-- Header --}} +
+
+
+
+
+ +
+

Riwayat Perubahan Poin

+

Jejak audit setiap penambahan & penghapusan poin pelanggaran siswa

+
+
+
+
+ + {{-- Ringkasan --}} +
+
+
+
{{ number_format($summary['total']) }}
+
Total Catatan
+
+
+
+
+
+
{{ number_format($summary['penambahan']) }}
+
Penambahan Poin
+
+
+
+
+
+
{{ number_format($summary['pengurangan']) }}
+
Penghapusan Poin
+
+
+
+
+ + {{-- Filter --}} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + @if (request()->anyFilled(['search', 'action', 'date_from', 'date_to'])) + + Reset + + @endif +
+
+ + {{-- Tabel Log --}} +
+
+ + + + + + + + + + + + + + @forelse ($logs as $log) + + + + + + + + + + @empty + + + + @endforelse + +
WaktuSiswaAksiΔ PoinSebelum → SesudahKeteranganOleh
+
{{ $log->created_at->format('d/m/Y') }}
+
{{ $log->created_at->format('H:i') }} WIB
+
+
{{ $log->student?->full_name ?? '(siswa terhapus)' }}
+
{{ $log->student?->class?->name }} · {{ $log->student?->nisn }}
+
+ @if ($log->action === 'created') + + Pencatatan + + @elseif ($log->action === 'deleted') + + Penghapusan + + @else + Penyesuaian + @endif + + + {{ $log->points_delta > 0 ? '+' : '' }}{{ $log->points_delta }} + + + {{ $log->points_before }} → {{ $log->points_after }} + +
{{ $log->description }}
+ @if (! empty($log->metadata['description'])) +
{{ $log->metadata['description'] }}
+ @endif +
+
{{ $log->actor?->name ?? 'Sistem' }}
+ @if ($log->ip_address) +
{{ $log->ip_address }}
+ @endif +
+
+ +
+

Belum ada catatan perubahan poin

+

Log akan muncul otomatis saat pelanggaran dicatat atau dihapus

+
+
+ + @if ($logs->hasPages()) +
+ {{ $logs->links() }} +
+ @endif +
+ +
+@endsection diff --git a/routes/web.php b/routes/web.php index b777b7a..b67c638 100644 --- a/routes/web.php +++ b/routes/web.php @@ -136,6 +136,10 @@ Route::middleware('auth')->group(function () { Route::get('/reports/violations/pdf', [\App\Http\Controllers\Admin\ViolationReportController::class, 'pdf'])->name('reports.violations.pdf'); }); + Route::middleware('permission:view-point-audit')->group(function () { + Route::get('/point-audit', [\App\Http\Controllers\Admin\PointAuditController::class, 'index'])->name('point-audit.index'); + }); + // ===== Administrasi (permission-based) ===== Route::middleware('permission:settings-manage')->group(function () { Route::get('/settings', [SettingsController::class, 'index'])->name('settings.index'); diff --git a/tests/Feature/PointAuditTest.php b/tests/Feature/PointAuditTest.php new file mode 100644 index 0000000..01aa55d --- /dev/null +++ b/tests/Feature/PointAuditTest.php @@ -0,0 +1,131 @@ +bk = User::factory()->create(['roles' => ['bk'], 'name' => 'Guru BK Uji']); + } + + protected function makeStudent(int $points = 0): Student + { + $class = Classes::create(['name' => 'X-TKJ 1', 'level' => 'X']); + + return Student::create([ + 'full_name' => 'Siswa Audit', + 'nisn' => '1234567890', + 'class_id' => $class->id, + 'class_name' => 'X-TKJ 1', + ]); + } + + protected function makeType(int $points = 5): ViolationType + { + $category = ViolationCategory::create(['name' => 'Kedisiplinan', 'slug' => 'kedisiplinan-'.uniqid()]); + + return ViolationType::create([ + 'name' => 'Terlambat', + 'slug' => 'terlambat-'.uniqid(), + 'points' => $points, + 'category_id' => $category->id, + ]); + } + + public function test_pencatatan_pelanggaran_membuat_audit_log(): void + { + $student = $this->makeStudent(); + $type = $this->makeType(5); + + $violation = app(ViolationService::class)->recordViolation([ + 'student_id' => $student->id, + 'violation_type_id' => $type->id, + 'points' => $type->points, + 'description' => 'Terlambat 15 menit', + 'violation_date' => '2026-08-01', + ], $this->bk->id); + + $log = PointAuditLog::where('student_id', $student->id)->firstOrFail(); + + $this->assertSame(PointAuditLog::ACTION_CREATED, $log->action); + $this->assertSame(0, $log->points_before); + $this->assertSame(5, $log->points_after); + $this->assertSame(5, $log->points_delta); + $this->assertSame($violation->id, $log->violation_id); + $this->assertSame($this->bk->id, $log->actor_id); + $this->assertSame('Terlambat', $log->description); + $this->assertSame('Terlambat 15 menit', $log->metadata['description'] ?? null); + } + + public function test_penghapusan_pelanggaran_membuat_audit_log_negatif(): void + { + $student = $this->makeStudent(); + $type = $this->makeType(5); + + $violation = app(ViolationService::class)->recordViolation([ + 'student_id' => $student->id, + 'violation_type_id' => $type->id, + 'points' => $type->points, + 'description' => 'Kasus sengketa', + 'violation_date' => '2026-08-01', + ], $this->bk->id); + + $this->actingAs(User::factory()->create(['roles' => ['admin']])) + ->delete("/violations/{$violation->id}") + ->assertRedirect(); + + $deletedLog = PointAuditLog::where('student_id', $student->id) + ->where('action', PointAuditLog::ACTION_DELETED) + ->firstOrFail(); + + $this->assertSame(5, $deletedLog->points_before); + $this->assertSame(0, $deletedLog->points_after); + $this->assertSame(-5, $deletedLog->points_delta); + $this->assertSame('Kasus sengketa', $deletedLog->metadata['description'] ?? null); + $this->assertSame(0, $student->refresh()->total_points); + } + + public function test_halaman_riwayat_memerlukan_permission(): void + { + $user = User::factory()->create(['roles' => ['staff']]); + + $this->actingAs($user)->get('/point-audit')->assertForbidden(); + } + + public function test_halaman_riwayat_menampilkan_log(): void + { + $student = $this->makeStudent(); + $type = $this->makeType(5); + + app(ViolationService::class)->recordViolation([ + 'student_id' => $student->id, + 'violation_type_id' => $type->id, + 'points' => $type->points, + 'description' => 'Terlambat 15 menit', + 'violation_date' => '2026-08-01', + ], $this->bk->id); + + $this->actingAs($this->bk) + ->get('/point-audit') + ->assertOk() + ->assertSee('Riwayat Perubahan Poin') + ->assertSee('Siswa Audit') + ->assertSee('Pencatatan') + ->assertSee('Terlambat 15 menit'); + } +}