feat: audit log poin — catat pencatatan/penghapusan pelanggaran (sebelum/sesudah/delta, metadata snapshot, actor, IP) + halaman Riwayat Poin (filter, permission view-point-audit) + 4 feature test
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PointAuditLog;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class PointAuditController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$query = PointAuditLog::with(['student', 'actor']);
|
||||||
|
|
||||||
|
// Wali kelas murni: hanya siswa di kelasnya
|
||||||
|
if (auth()->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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -272,6 +272,31 @@ class ViolationController extends Controller
|
|||||||
|
|
||||||
public function destroy(Violation $violation): RedirectResponse
|
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();
|
$violation->delete();
|
||||||
|
|
||||||
return redirect()->route('violations.index')
|
return redirect()->route('violations.index')
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PointAuditLog extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\PointAuditLogFactory> */
|
||||||
|
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<string, mixed> $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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Events\ViolationRecorded;
|
use App\Events\ViolationRecorded;
|
||||||
|
use App\Models\PointAuditLog;
|
||||||
use App\Models\SpLetter;
|
use App\Models\SpLetter;
|
||||||
use App\Models\SpThreshold;
|
use App\Models\SpThreshold;
|
||||||
use App\Models\Student;
|
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;
|
return $violation;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('point_audit_logs', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Permission baru: view-point-audit (Riwayat Perubahan Poin).
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
DB::table('permissions')->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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@ $faIcons = [
|
|||||||
'clipboard-check' => 'fa-solid fa-clipboard-check',
|
'clipboard-check' => 'fa-solid fa-clipboard-check',
|
||||||
'document-text' => 'fa-solid fa-file-lines',
|
'document-text' => 'fa-solid fa-file-lines',
|
||||||
'file-lines' => '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',
|
'tag' => 'fa-solid fa-tag',
|
||||||
'list' => 'fa-solid fa-list',
|
'list' => 'fa-solid fa-list',
|
||||||
'chart-bar' => 'fa-solid fa-chart-simple',
|
'chart-bar' => 'fa-solid fa-chart-simple',
|
||||||
|
|||||||
@@ -171,6 +171,10 @@
|
|||||||
<x-nav-item href="{{ route('reports.violations') }}" icon="file-lines" :active="request()->routeIs('reports.violations*')">Laporan</x-nav-item>
|
<x-nav-item href="{{ route('reports.violations') }}" icon="file-lines" :active="request()->routeIs('reports.violations*')">Laporan</x-nav-item>
|
||||||
@endcanPermission
|
@endcanPermission
|
||||||
|
|
||||||
|
@canPermission('view-point-audit')
|
||||||
|
<x-nav-item href="{{ route('point-audit.index') }}" icon="clock-rotate-left" :active="request()->routeIs('point-audit.*')">Riwayat Poin</x-nav-item>
|
||||||
|
@endcanPermission
|
||||||
|
|
||||||
@canPermission('categories-manage')
|
@canPermission('categories-manage')
|
||||||
<div class="pt-4 mt-4 border-t border-gray-200">
|
<div class="pt-4 mt-4 border-t border-gray-200">
|
||||||
<p class="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Master Data</p>
|
<p class="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Master Data</p>
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Riwayat Poin')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
{{-- Header --}}
|
||||||
|
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-slate-700 via-slate-800 to-slate-900 shadow-lg">
|
||||||
|
<div class="absolute inset-0 opacity-10" style="background-image: radial-gradient(circle at 20% 30%, white 1.5px, transparent 1.5px); background-size: 22px 22px;"></div>
|
||||||
|
<div class="absolute -right-16 -top-16 w-64 h-64 rounded-full bg-white/10 blur-2xl"></div>
|
||||||
|
<div class="relative px-6 py-6 lg:px-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="hidden sm:flex items-center justify-center w-14 h-14 rounded-2xl bg-white/15 backdrop-blur border border-white/20">
|
||||||
|
<i class="fa-solid fa-clock-rotate-left text-xl text-white"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white tracking-tight">Riwayat Perubahan Poin</h1>
|
||||||
|
<p class="text-sm text-slate-300 mt-1">Jejak audit setiap penambahan & penghapusan poin pelanggaran siswa</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Ringkasan --}}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900">{{ number_format($summary['total']) }}</div>
|
||||||
|
<div class="text-xs text-gray-400 uppercase tracking-wider font-medium mt-0.5">Total Catatan</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-slate-50 flex items-center justify-center"><i class="fa-solid fa-list text-slate-400"></i></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold text-emerald-600">{{ number_format($summary['penambahan']) }}</div>
|
||||||
|
<div class="text-xs text-gray-400 uppercase tracking-wider font-medium mt-0.5">Penambahan Poin</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center"><i class="fa-solid fa-plus text-emerald-500"></i></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold text-rose-600">{{ number_format($summary['pengurangan']) }}</div>
|
||||||
|
<div class="text-xs text-gray-400 uppercase tracking-wider font-medium mt-0.5">Penghapusan Poin</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-rose-50 flex items-center justify-center"><i class="fa-solid fa-minus text-rose-500"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Filter --}}
|
||||||
|
<form method="GET" class="bg-white rounded-2xl shadow-sm border border-gray-200 p-5">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||||
|
<div class="sm:col-span-2 relative">
|
||||||
|
<i class="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
|
||||||
|
<input type="text" name="search" value="{{ request('search') }}" placeholder="Cari NISN / Nama siswa..."
|
||||||
|
class="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<i class="fa-solid fa-calendar absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
|
||||||
|
<input type="date" name="date_from" value="{{ request('date_from') }}"
|
||||||
|
class="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<i class="fa-solid fa-calendar absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
|
||||||
|
<input type="date" name="date_to" value="{{ request('date_to') }}"
|
||||||
|
class="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<i class="fa-solid fa-filter absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
|
||||||
|
<select name="action"
|
||||||
|
class="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50/50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition appearance-none">
|
||||||
|
<option value="">Semua Aksi</option>
|
||||||
|
<option value="created" @selected(request('action') === 'created')>Pencatatan (+)</option>
|
||||||
|
<option value="deleted" @selected(request('action') === 'deleted')>Penghapusan (−)</option>
|
||||||
|
</select>
|
||||||
|
<i class="fa-solid fa-chevron-down absolute right-3.5 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-4">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm">
|
||||||
|
<i class="fa-solid fa-filter text-xs"></i> Terapkan Filter
|
||||||
|
</button>
|
||||||
|
@if (request()->anyFilled(['search', 'action', 'date_from', 'date_to']))
|
||||||
|
<a href="{{ route('point-audit.index') }}"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold text-gray-500 bg-gray-100 rounded-xl hover:bg-gray-200 transition">
|
||||||
|
<i class="fa-solid fa-rotate-left text-xs"></i> Reset
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{-- Tabel Log --}}
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50/70">
|
||||||
|
<tr class="text-left text-xs text-gray-500 uppercase tracking-wider">
|
||||||
|
<th class="px-6 py-3 font-semibold">Waktu</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">Siswa</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">Aksi</th>
|
||||||
|
<th class="px-4 py-3 text-center font-semibold">Δ Poin</th>
|
||||||
|
<th class="px-4 py-3 text-center font-semibold">Sebelum → Sesudah</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">Keterangan</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">Oleh</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-50">
|
||||||
|
@forelse ($logs as $log)
|
||||||
|
<tr class="hover:bg-gray-50/60 transition">
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap">
|
||||||
|
<div class="text-gray-700 font-medium">{{ $log->created_at->format('d/m/Y') }}</div>
|
||||||
|
<div class="text-xs text-gray-400">{{ $log->created_at->format('H:i') }} WIB</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="font-semibold text-gray-800">{{ $log->student?->full_name ?? '(siswa terhapus)' }}</div>
|
||||||
|
<div class="text-xs text-gray-400">{{ $log->student?->class?->name }} · {{ $log->student?->nisn }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
@if ($log->action === 'created')
|
||||||
|
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700">
|
||||||
|
<i class="fa-solid fa-plus text-[10px]"></i> Pencatatan
|
||||||
|
</span>
|
||||||
|
@elseif ($log->action === 'deleted')
|
||||||
|
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold bg-rose-50 text-rose-700">
|
||||||
|
<i class="fa-solid fa-trash-can text-[10px]"></i> Penghapusan
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="inline-flex px-2.5 py-1 rounded-full text-xs font-bold bg-slate-100 text-slate-600">Penyesuaian</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<span class="inline-flex px-2 py-0.5 rounded-lg text-sm font-bold {{ $log->points_delta > 0 ? 'text-emerald-600' : 'text-rose-600' }}">
|
||||||
|
{{ $log->points_delta > 0 ? '+' : '' }}{{ $log->points_delta }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center text-gray-600">
|
||||||
|
{{ $log->points_before }} → <span class="font-bold text-gray-800">{{ $log->points_after }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 max-w-xs">
|
||||||
|
<div class="text-gray-700 truncate" title="{{ $log->description }}">{{ $log->description }}</div>
|
||||||
|
@if (! empty($log->metadata['description']))
|
||||||
|
<div class="text-xs text-gray-400 truncate max-w-[220px]" title="{{ $log->metadata['description'] }}">{{ $log->metadata['description'] }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="text-gray-700">{{ $log->actor?->name ?? 'Sistem' }}</div>
|
||||||
|
@if ($log->ip_address)
|
||||||
|
<div class="text-xs text-gray-400 font-mono">{{ $log->ip_address }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="py-16 text-center">
|
||||||
|
<div class="w-16 h-16 mx-auto rounded-2xl bg-gray-50 flex items-center justify-center mb-4">
|
||||||
|
<i class="fa-solid fa-clock-rotate-left text-gray-300 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-semibold text-gray-500">Belum ada catatan perubahan poin</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Log akan muncul otomatis saat pelanggaran dicatat atau dihapus</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($logs->hasPages())
|
||||||
|
<div class="px-6 py-4 border-t border-gray-100">
|
||||||
|
{{ $logs->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -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::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) =====
|
// ===== Administrasi (permission-based) =====
|
||||||
Route::middleware('permission:settings-manage')->group(function () {
|
Route::middleware('permission:settings-manage')->group(function () {
|
||||||
Route::get('/settings', [SettingsController::class, 'index'])->name('settings.index');
|
Route::get('/settings', [SettingsController::class, 'index'])->name('settings.index');
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Classes;
|
||||||
|
use App\Models\PointAuditLog;
|
||||||
|
use App\Models\Student;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Violation;
|
||||||
|
use App\Models\ViolationCategory;
|
||||||
|
use App\Models\ViolationType;
|
||||||
|
use App\Services\ViolationService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PointAuditTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user