Initial commit: Aplikasi Ketertiban Siswa

- Fitur: input pelanggaran, sistem poin, surat peringatan otomatis, SP progress
- Realtime notifikasi via Laravel Reverb + Echo
- Dashboard statistik, manajemen user, manajemen master data
- Login 2 kolom premium, user dropdown, pengaturan akun
- Tema biru kalem, logo sekolah dinamis, sidebar brand dinamis
- Sinkronisasi siswa dari Database Kesiswaan via API
This commit is contained in:
Bro
2026-07-21 22:43:13 +07:00
commit 1c35734f7b
116 changed files with 19664 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Events;
use App\Models\Violation;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
class ViolationRecorded implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets;
public Violation $violation;
public array $data;
public function __construct(Violation $violation)
{
$this->violation = $violation;
$this->data = [
'id' => $violation->id,
'student_name' => $violation->student->full_name,
'student_nisn' => $violation->student->nisn,
'class_name' => $violation->student->class_name,
'violation_type' => $violation->violationType->name,
'category_color' => $violation->violationType->category->color ?? '#ef4444',
'points' => $violation->points,
'total_points' => $violation->student->total_points,
'time' => $violation->created_at->diffForHumans(),
'url' => route('violations.show', $violation->id),
];
}
public function broadcastOn(): array
{
return [
new Channel('violations'),
];
}
public function broadcastAs(): string
{
return 'violation.recorded';
}
}
@@ -0,0 +1,168 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SpThreshold;
use App\Models\ViolationCategory;
use App\Models\ViolationType;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
class MasterDataController extends Controller
{
// === Violation Categories ===
public function categories(): View
{
$categories = ViolationCategory::orderBy('sort_order')->get();
return view('settings.categories', compact('categories'));
}
public function storeCategory(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'color' => ['required', 'string', 'max:7'],
'description' => ['nullable', 'string', 'max:500'],
'sort_order' => ['nullable', 'integer', 'min:0'],
]);
$validated['slug'] = Str::slug($validated['name']);
ViolationCategory::create($validated);
return back()->with('success', 'Kategori pelanggaran berhasil ditambahkan.');
}
public function updateCategory(Request $request, ViolationCategory $category): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'color' => ['required', 'string', 'max:7'],
'description' => ['nullable', 'string', 'max:500'],
'sort_order' => ['nullable', 'integer', 'min:0'],
'is_active' => ['boolean'],
]);
$validated['slug'] = Str::slug($validated['name']);
$validated['is_active'] = $request->boolean('is_active');
$category->update($validated);
return back()->with('success', 'Kategori berhasil diperbarui.');
}
public function destroyCategory(ViolationCategory $category): RedirectResponse
{
if ($category->violationTypes()->count() > 0) {
return back()->with('error', 'Kategori masih memiliki jenis pelanggaran. Hapus jenis pelanggaran terlebih dahulu.');
}
$category->delete();
return back()->with('success', 'Kategori berhasil dihapus.');
}
// === Violation Types ===
public function types(Request $request): View
{
$query = ViolationType::with('category');
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('default_sanction', 'like', "%{$search}%");
});
}
if ($request->filled('category_id')) {
$query->where('category_id', $request->category_id);
}
$types = $query->orderBy('category_id')->orderBy('name')->paginate(20);
$categories = ViolationCategory::orderBy('sort_order')->get();
return view('settings.violation-types', compact('types', 'categories'));
}
public function storeType(Request $request): RedirectResponse
{
$validated = $request->validate([
'category_id' => ['required', 'exists:violation_categories,id'],
'name' => ['required', 'string', 'max:255'],
'points' => ['required', 'integer', 'min:0', 'max:500'],
'default_sanction' => ['nullable', 'string', 'max:500'],
'description' => ['nullable', 'string', 'max:1000'],
]);
$validated['slug'] = Str::slug($validated['name']);
$validated['is_active'] = true;
ViolationType::create($validated);
return back()->with('success', 'Jenis pelanggaran berhasil ditambahkan.');
}
public function updateType(Request $request, ViolationType $type): RedirectResponse
{
$validated = $request->validate([
'category_id' => ['required', 'exists:violation_categories,id'],
'name' => ['required', 'string', 'max:255'],
'points' => ['required', 'integer', 'min:0', 'max:500'],
'default_sanction' => ['nullable', 'string', 'max:500'],
'description' => ['nullable', 'string', 'max:1000'],
'is_active' => ['boolean'],
]);
$validated['slug'] = Str::slug($validated['name']);
$validated['is_active'] = $request->boolean('is_active');
$type->update($validated);
return back()->with('success', 'Jenis pelanggaran berhasil diperbarui.');
}
public function destroyType(ViolationType $type): RedirectResponse
{
if ($type->violations()->count() > 0) {
return back()->with('error', 'Jenis pelanggaran ini sudah tercatat pada pelanggaran siswa. Nonaktifkan saja.');
}
$type->delete();
return back()->with('success', 'Jenis pelanggaran berhasil dihapus.');
}
// === SP Thresholds ===
public function thresholds(): View
{
$thresholds = SpThreshold::orderBy('min_points')->get();
return view('settings.thresholds', compact('thresholds'));
}
public function updateThresholds(Request $request): RedirectResponse
{
$thresholds = $request->validate([
'thresholds' => ['required', 'array'],
'thresholds.*.id' => ['required', 'exists:sp_thresholds,id'],
'thresholds.*.min_points' => ['required', 'integer', 'min:0'],
'thresholds.*.max_points' => ['nullable', 'integer', 'min:0'],
'thresholds.*.name' => ['required', 'string', 'max:255'],
'thresholds.*.default_description' => ['nullable', 'string', 'max:500'],
]);
foreach ($thresholds['thresholds'] as $data) {
SpThreshold::where('id', $data['id'])->update([
'name' => $data['name'],
'min_points' => $data['min_points'],
'max_points' => $data['max_points'],
'default_description' => $data['default_description'],
]);
}
return back()->with('success', 'Ambang batas SP berhasil diperbarui.');
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
class ResetController extends Controller
{
public function index(): View
{
$stats = [
'violations' => DB::table('violations')->count(),
'evidences' => DB::table('violation_evidences')->count(),
'sp_letters' => DB::table('sp_letters')->count(),
'notifications' => DB::table('notifications')->count(),
'students' => DB::table('students')->count(),
'classes' => DB::table('classes')->count(),
'settings' => DB::table('settings')->count(),
'users_other' => DB::table('users')->where('id', '!=', 1)->count(),
];
return view('settings.reset', compact('stats'));
}
public function reset(Request $request): RedirectResponse
{
$request->validate([
'confirm_password' => ['required', 'string'],
]);
if (!Hash::check($request->confirm_password, $request->user()->password)) {
return back()->with('error', 'Password salah. Reset dibatalkan.');
}
DB::statement('SET FOREIGN_KEY_CHECKS=0');
DB::table('violation_evidences')->delete();
DB::table('violations')->delete();
DB::table('sp_letters')->delete();
DB::table('notifications')->delete();
DB::table('students')->delete();
DB::table('classes')->delete();
DB::table('violation_types')->delete();
DB::table('violation_categories')->delete();
DB::table('sp_thresholds')->delete();
DB::table('settings')->delete();
User::where('id', '!=', 1)->delete();
User::where('id', 1)->update(['is_active' => true, 'role' => 'admin']);
DB::statement('SET FOREIGN_KEY_CHECKS=1');
$violationsDir = storage_path('app/public/violations');
if (is_dir($violationsDir)) {
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($violationsDir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $file) {
if ($file->isDir()) rmdir($file->getRealPath());
else unlink($file->getRealPath());
}
}
return redirect()->route('users.index')
->with('success', 'Reset berhasil! Semua data pelanggaran, siswa, master data, dan user lain dihapus. Hanya akun admin yang tersisa.');
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SettingsController extends Controller
{
public function index(): View
{
$settings = Setting::where('group', 'school')->get()->keyBy('key');
return view('settings.index', compact('settings'));
}
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'app_name' => ['required', 'string', 'max:255'],
'school_name' => ['required', 'string', 'max:255'],
'school_address' => ['nullable', 'string', 'max:500'],
'school_phone' => ['nullable', 'string', 'max:50'],
'kepala_sekolah_name' => ['nullable', 'string', 'max:255'],
'kepala_sekolah_nip' => ['nullable', 'string', 'max:50'],
'bk_koordinator_name' => ['nullable', 'string', 'max:255'],
]);
foreach ($validated as $key => $value) {
Setting::setValue($key, $value, 'school');
}
if ($request->hasFile('school_logo')) {
$path = $request->file('school_logo')->store('school', 'public');
Setting::setValue('school_logo', $path, 'school', 'Logo sekolah');
}
if ($request->hasFile('login_background')) {
$path = $request->file('login_background')->store('school', 'public');
Setting::setValue('login_background', $path, 'school', 'Background halaman login');
}
return back()->with('success', 'Pengaturan berhasil disimpan.');
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use App\Services\StudentSyncService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SyncController extends Controller
{
protected StudentSyncService $syncService;
public function __construct(StudentSyncService $syncService)
{
$this->syncService = $syncService;
}
public function index(): View
{
$baseUrl = Setting::getValue('kesiswaan_base_url', '');
$savedToken = Setting::getValue('kesiswaan_token', '');
$hasToken = !empty($savedToken);
$studentCount = \App\Models\Student::count();
return view('settings.sync', compact('baseUrl', 'savedToken', 'hasToken', 'studentCount'));
}
public function syncNow(Request $request): RedirectResponse
{
$validated = $request->validate([
'base_url' => ['required', 'url'],
'token' => ['nullable', 'string'],
]);
// Use saved token if not provided in form
if (empty($validated['token'])) {
$validated['token'] = Setting::getValue('kesiswaan_token', '');
}
if (empty($validated['token'])) {
return back()->with('error', 'Token sinkronisasi harus diisi.');
}
$result = $this->syncService->sync($validated['base_url'], $validated['token']);
if (isset($result['error'])) {
return back()->with('error', $result['error']);
}
// Save to settings
Setting::setValue('kesiswaan_base_url', $validated['base_url'], 'integration', 'URL Database Kesiswaan');
Setting::setValue('kesiswaan_token', $validated['token'], 'integration', 'Token sinkronisasi');
$msg = 'Sinkron berhasil! ' . $result['students_created'] . ' siswa baru, ' . $result['students_updated'] . ' diperbarui, ' . $result['classes_created'] . ' kelas baru.';
if (!empty($result['errors'])) {
$skipped = count($result['errors']);
$msg .= ' (' . $skipped . ' dilewati — siswa tidak aktif/tanpa NISN).';
}
return back()->with('success', $msg);
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
class UserController extends Controller
{
public function index(Request $request): View
{
$query = User::query();
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($request->filled('role')) {
$query->where('role', $request->role);
}
$users = $query->orderBy('name')->paginate(20);
return view('users.index', compact('users'));
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'username' => ['required', 'string', 'max:255', 'unique:users,username'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'min:6'],
'role' => ['required', 'in:admin,bk,wali_kelas,staff'],
'is_active' => ['boolean'],
]);
$validated['password'] = Hash::make($validated['password']);
$validated['is_active'] = $request->boolean('is_active', true);
User::create($validated);
return redirect()->route('users.index')
->with('success', 'User berhasil ditambahkan.');
}
public function update(Request $request, User $user): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'username' => ['required', 'string', 'max:255', 'unique:users,username,' . $user->id],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email,' . $user->id],
'password' => ['nullable', 'string', 'min:6'],
'role' => ['required', 'in:admin,bk,wali_kelas,staff'],
'is_active' => ['boolean'],
]);
if (!empty($validated['password'])) {
$validated['password'] = Hash::make($validated['password']);
} else {
unset($validated['password']);
}
$validated['is_active'] = $request->boolean('is_active', true);
$user->update($validated);
return redirect()->route('users.index')
->with('success', 'User berhasil diperbarui.');
}
public function destroy(User $user): RedirectResponse
{
if ($user->id === auth()->id()) {
return back()->with('error', 'Tidak bisa menghapus akun sendiri.');
}
$user->delete();
return redirect()->route('users.index')
->with('success', 'User berhasil dihapus.');
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class LoginController extends Controller
{
public function showLoginForm(): View
{
return view('auth.login');
}
public function login(Request $request): RedirectResponse
{
$credentials = $request->validate([
'username' => ['required', 'string'],
'password' => ['required', 'string'],
]);
if (Auth::attempt($credentials, $request->boolean('remember'))) {
$request->session()->regenerate();
return redirect()->intended(route('dashboard'));
}
return back()->withErrors([
'username' => 'Username atau password salah.',
])->onlyInput('username');
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers;
use App\Models\AppNotification;
use App\Models\Student;
use App\Models\Violation;
use App\Models\SpLetter;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function index(Request $request): View
{
$today = now()->toDateString();
$stats = [
'today_violations' => Violation::whereDate('violation_date', $today)->count(),
'total_violations' => Violation::count(),
'total_students' => Student::where('is_active', true)->count(),
'active_sp' => SpLetter::where('status', 'draft')->count(),
];
$recentViolations = Violation::with(['student', 'violationType.category', 'recorder'])
->latest()
->take(10)
->get();
$topStudents = Student::where('is_active', true)
->withSum('violations', 'points')
->orderByDesc('violations_sum_points')
->take(5)
->get()
->map(function ($s) {
$s->total_points = $s->violations_sum_points ?? 0;
return $s;
});
$unreadNotifications = AppNotification::where('user_id', $request->user()->id)
->where('is_read', false)
->latest()
->take(10)
->get();
$notificationCount = AppNotification::where('user_id', $request->user()->id)
->where('is_read', false)
->count();
$spThresholds = \App\Models\SpThreshold::where('is_active', true)->get();
return view('dashboard.index', compact(
'stats', 'recentViolations', 'topStudents',
'unreadNotifications', 'notificationCount', 'spThresholds'
));
}
public function markNotificationRead(Request $request, $id)
{
$notif = AppNotification::where('user_id', $request->user()->id)->findOrFail($id);
$notif->update(['is_read' => true, 'read_at' => now()]);
return response()->json(['success' => true]);
}
public function markAllNotificationsRead(Request $request)
{
AppNotification::where('user_id', $request->user()->id)
->where('is_read', false)
->update(['is_read' => true, 'read_at' => now()]);
return response()->json(['success' => true]);
}
public function getUnreadCount(Request $request)
{
$count = AppNotification::where('user_id', $request->user()->id)
->where('is_read', false)
->count();
return response()->json(['count' => $count]);
}
public function getRecentNotifications(Request $request)
{
$notifications = AppNotification::where('user_id', $request->user()->id)
->where('is_read', false)
->latest()
->take(5)
->get();
return response()->json(['notifications' => $notifications]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class ProfileController extends Controller
{
public function index(): View
{
$user = auth()->user();
return view('profile.index', compact('user'));
}
public function update(Request $request): RedirectResponse
{
$user = auth()->user();
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'username' => ['required', 'string', 'max:255', Rule::unique('users')->ignore($user->id)],
'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
]);
$user->update($validated);
if ($request->filled('current_password') || $request->filled('password')) {
$request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'string', 'min:6', 'confirmed'],
]);
$user->update(['password' => Hash::make($request->password)]);
}
return back()->with('success', 'Profil berhasil diperbarui.');
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers;
use App\Models\SpLetter;
use App\Models\Setting;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SpLetterController extends Controller
{
public function index(Request $request): View
{
$letters = SpLetter::with(['student', 'spThreshold', 'generator'])
->latest()
->paginate(20);
return view('sp-letters.index', compact('letters'));
}
public function show(SpLetter $spLetter): View
{
$spLetter->load(['student', 'spThreshold', 'generator']);
$school = [
'name' => Setting::getValue('school_name', 'SMK'),
'address' => Setting::getValue('school_address', ''),
'phone' => Setting::getValue('school_phone', ''),
'kepala_sekolah' => Setting::getValue('kepala_sekolah_name', ''),
'kepala_sekolah_nip' => Setting::getValue('kepala_sekolah_nip', ''),
];
return view('sp-letters.show', compact('spLetter', 'school'));
}
public function print(SpLetter $spLetter)
{
$spLetter->load(['student', 'spThreshold', 'generator']);
$school = [
'name' => Setting::getValue('school_name', 'SMK'),
'address' => Setting::getValue('school_address', ''),
'phone' => Setting::getValue('school_phone', ''),
'kepala_sekolah' => Setting::getValue('kepala_sekolah_name', ''),
'kepala_sekolah_nip' => Setting::getValue('kepala_sekolah_nip', ''),
];
$spLetter->update(['printed_at' => now(), 'status' => 'printed']);
// For now, return an HTML print view
return view('sp-letters.print', compact('spLetter', 'school'));
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers;
use App\Models\Student;
use App\Models\SpLetter;
use App\Models\SpThreshold;
use App\Models\Violation;
use Illuminate\Http\Request;
use Illuminate\View\View;
class StudentReportController extends Controller
{
public function index(Request $request): View
{
$query = Student::where('is_active', true);
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('full_name', 'like', "%{$search}%")
->orWhere('nisn', 'like', "%{$search}%")
->orWhere('student_number', 'like', "%{$search}%");
});
}
if ($request->filled('class_level')) {
$query->where('class_level', $request->class_level);
}
if ($request->filled('class_name')) {
$query->where('class_name', $request->class_name);
}
if ($request->filled('department')) {
$query->where('department_code', $request->department);
}
$students = $query->withCount(['violations' => fn($q) => $q->whereNull('deleted_at')])
->orderBy('class_name')
->orderBy('full_name')
->paginate(20);
// Data untuk dropdown filter
$classLevels = Student::where('is_active', true)->distinct()->pluck('class_level')->sort()->values();
$classNames = Student::where('is_active', true)->distinct()->pluck('class_name')->sort()->values();
$departments = Student::where('is_active', true)
->selectRaw('DISTINCT department_code, department_name')
->whereNotNull('department_code')
->where('department_code', '!=', '')
->get()
->pluck('department_name', 'department_code');
return view('students.index', compact('students', 'classLevels', 'classNames', 'departments'));
}
public function show(Student $student): View
{
$student->load(['violations' => function ($q) {
$q->with(['violationType.category', 'recorder', 'evidences'])
->latest()
->take(50);
}, 'spLetters' => function ($q) {
$q->with('spThreshold')->latest();
}]);
$totalPoints = $student->total_points;
$violationCount = $student->violations()->whereNull('deleted_at')->count();
$lastViolation = $student->violations()->whereNull('deleted_at')->latest()->first();
$spThresholds = SpThreshold::where('is_active', true)->orderBy('min_points')->get();
// Cari SP threshold yang sudah tercapai
$currentSpLevel = null;
$nextSpThreshold = null;
foreach ($spThresholds as $threshold) {
if ($totalPoints >= $threshold->min_points) {
$currentSpLevel = $threshold;
} elseif (!$nextSpThreshold) {
$nextSpThreshold = $threshold;
}
}
// SP yang sudah terbit
$activeSpLetters = $student->spLetters()->whereIn('status', ['draft', 'issued'])->count();
return view('students.show', compact(
'student', 'totalPoints', 'violationCount', 'lastViolation',
'spThresholds', 'currentSpLevel', 'nextSpThreshold', 'activeSpLetters'
));
}
}
@@ -0,0 +1,149 @@
<?php
namespace App\Http\Controllers;
use App\Models\Student;
use App\Models\Violation;
use App\Models\ViolationType;
use App\Models\AppNotification;
use App\Services\ViolationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ViolationController extends Controller
{
protected ViolationService $violationService;
public function __construct(ViolationService $violationService)
{
$this->violationService = $violationService;
}
public function index(Request $request): View
{
$query = Violation::with(['student', 'violationType.category', 'recorder']);
if ($request->filled('search')) {
$search = $request->search;
$query->whereHas('student', function ($q) use ($search) {
$q->where('full_name', 'like', "%{$search}%")
->orWhere('nisn', 'like', "%{$search}%");
});
}
if ($request->filled('category_id')) {
$query->whereHas('violationType', function ($q) use ($request) {
$q->where('category_id', $request->category_id);
});
}
if ($request->filled('class_level')) {
$query->whereHas('student', function ($q) use ($request) {
$q->where('class_level', $request->class_level);
});
}
if ($request->filled('date_from')) {
$query->whereDate('violation_date', '>=', $request->date_from);
}
if ($request->filled('date_to')) {
$query->whereDate('violation_date', '<=', $request->date_to);
}
$violations = $query->latest()->paginate(20);
$categories = \App\Models\ViolationCategory::where('is_active', true)->get();
return view('violations.index', compact('violations', 'categories'));
}
public function create(): View
{
$students = Student::where('is_active', true)->orderBy('full_name')->get();
$violationTypes = ViolationType::with('category')->where('is_active', true)->get();
// Group by category for searchable dropdown
$typeGroups = $violationTypes->groupBy(fn($t) => $t->category?->name ?? 'Lainnya')
->map(fn($group, $cat) => [
'label' => $cat,
'color' => $group->first()->category?->color ?? '#6b7280',
'types' => $group->map(fn($t) => [
'id' => $t->id,
'name' => $t->name,
'points' => $t->points,
'sanction' => $t->default_sanction,
])->values(),
])->values();
return view('violations.create', compact('students', 'violationTypes', 'typeGroups'));
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'student_id' => ['required', 'exists:students,id'],
'violation_type_id' => ['required', 'exists:violation_types,id'],
'description' => ['nullable', 'string', 'max:1000'],
'location' => ['nullable', 'string', 'max:255'],
'violation_date' => ['required', 'date'],
'violation_time' => ['nullable', 'date_format:H:i'],
'notes' => ['nullable', 'string', 'max:1000'],
'evidences' => ['nullable', 'array', 'max:5'],
'evidences.*' => ['image', 'mimes:jpeg,png,jpg,gif,webp', 'max:5120'],
'sanction' => ['nullable', 'string', 'max:500'],
]);
$violationType = ViolationType::findOrFail($validated['violation_type_id']);
$validated['points'] = $violationType->points;
$validated['sanction'] = $validated['sanction'] ?? $violationType->default_sanction;
//$this->violationService->recordViolation($validated, $request->user()->id);
$data = $validated;
$data['evidences'] = $request->file('evidences') ?? [];
$this->violationService->recordViolation($data, $request->user()->id);
return redirect()->route('violations.index')
->with('success', 'Pelanggaran berhasil dicatat.');
}
public function show(Violation $violation): View
{
$violation->load(['student', 'violationType.category', 'recorder', 'evidences']);
return view('violations.show', compact('violation'));
}
public function verify(Request $request, Violation $violation): RedirectResponse
{
$violation->update([
'is_verified' => true,
'verified_by' => $request->user()->id,
'verified_at' => now(),
]);
return back()->with('success', 'Pelanggaran berhasil diverifikasi.');
}
public function destroy(Violation $violation): RedirectResponse
{
$violation->delete();
return redirect()->route('violations.index')
->with('success', 'Pelanggaran berhasil dihapus.');
}
public function searchStudents(Request $request)
{
$search = $request->get('q', '');
$students = Student::where('is_active', true)
->where(function ($q) use ($search) {
$q->where('full_name', 'like', "%{$search}%")
->orWhere('nisn', 'like', "%{$search}%");
})
->orderBy('full_name')
->take(20)
->get(['id', 'nisn', 'full_name', 'class_name', 'class_level']);
return response()->json($students);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AppNotification extends Model
{
protected $table = 'notifications';
protected $fillable = [
'user_id', 'type', 'title', 'body', 'data',
'icon', 'color', 'action_url', 'is_read',
'read_at', 'violation_id', 'sp_letter_id',
];
protected function casts(): array
{
return [
'data' => 'json',
'is_read' => 'boolean',
'read_at' => 'datetime',
];
}
public function user()
{
return $this->belongsTo(User::class);
}
public function violation()
{
return $this->belongsTo(Violation::class);
}
public function spLetter()
{
return $this->belongsTo(SpLetter::class, 'sp_letter_id');
}
public function scopeUnread($query)
{
return $query->where('is_read', false);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Classes extends Model
{
protected $table = 'classes';
protected $fillable = [
'source_id', 'name', 'level', 'department_code',
'department_name', 'academic_year_name', 'is_active',
'metadata',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'metadata' => 'json',
];
}
public function students()
{
return $this->hasMany(Student::class, 'class_id');
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $fillable = ['key', 'value', 'group', 'description'];
public static function getValue(string $key, mixed $default = null): mixed
{
$setting = static::where('key', $key)->first();
return $setting ? $setting->value : $default;
}
public static function setValue(string $key, mixed $value, string $group = 'general', ?string $description = null): void
{
static::updateOrCreate(
['key' => $key],
['value' => $value, 'group' => $group, 'description' => $description]
);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SpLetter extends Model
{
protected $fillable = [
'student_id', 'sp_threshold_id', 'generated_by',
'letter_number', 'title', 'body', 'total_points_at_time',
'violations_included', 'status', 'file_path',
'printed_at', 'delivered_at',
];
protected function casts(): array
{
return [
'total_points_at_time' => 'integer',
'violations_included' => 'json',
'printed_at' => 'datetime',
'delivered_at' => 'datetime',
];
}
public function student()
{
return $this->belongsTo(Student::class);
}
public function spThreshold()
{
return $this->belongsTo(SpThreshold::class, 'sp_threshold_id');
}
public function generator()
{
return $this->belongsTo(User::class, 'generated_by');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SpThreshold extends Model
{
protected $fillable = [
'name', 'slug', 'min_points', 'max_points',
'color', 'default_description', 'is_active',
];
protected function casts(): array
{
return [
'min_points' => 'integer',
'max_points' => 'integer',
'is_active' => 'boolean',
];
}
public function spLetters()
{
return $this->hasMany(SpLetter::class, 'sp_threshold_id');
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Student extends Model
{
protected $fillable = [
'source_id', 'nisn', 'student_number', 'full_name',
'gender', 'place_of_birth', 'date_of_birth', 'address',
'phone_number', 'email', 'class_name', 'class_level',
'department_code', 'department_name', 'academic_year_name',
'status', 'photo_path', 'class_id', 'is_active',
'metadata', 'synced_at',
];
protected function casts(): array
{
return [
'date_of_birth' => 'date',
'is_active' => 'boolean',
'metadata' => 'json',
'synced_at' => 'datetime',
];
}
public function violations(): HasMany
{
return $this->hasMany(Violation::class);
}
public function spLetters(): HasMany
{
return $this->hasMany(SpLetter::class);
}
public function class()
{
return $this->belongsTo(Classes::class, 'class_id');
}
public function getTotalPointsAttribute(): int
{
return $this->violations()
->whereNull('deleted_at')
->sum('points');
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use HasFactory, Notifiable;
protected $fillable = [
'name', 'username', 'email', 'password', 'role', 'is_active',
];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_active' => 'boolean',
];
}
public function isAdmin(): bool { return $this->role === 'admin'; }
public function isBK(): bool { return $this->role === 'bk'; }
public function isWaliKelas(): bool { return $this->role === 'wali_kelas'; }
public function recordedViolations()
{
return $this->hasMany(Violation::class, 'recorded_by');
}
public function appNotifications()
{
return $this->hasMany(AppNotification::class, 'user_id');
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Violation extends Model
{
use SoftDeletes;
protected $fillable = [
'student_id', 'violation_type_id', 'recorded_by',
'description', 'points', 'sanction', 'location',
'violation_date', 'violation_time',
'is_verified', 'verified_by', 'verified_at',
'notes',
];
protected function casts(): array
{
return [
'violation_date' => 'date',
'violation_time' => 'datetime:H:i',
'is_verified' => 'boolean',
'verified_at' => 'datetime',
'points' => 'integer',
];
}
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
public function violationType(): BelongsTo
{
return $this->belongsTo(ViolationType::class, 'violation_type_id');
}
public function recorder(): BelongsTo
{
return $this->belongsTo(User::class, 'recorded_by');
}
public function verifier(): BelongsTo
{
return $this->belongsTo(User::class, 'verified_by');
}
public function evidences(): HasMany
{
return $this->hasMany(ViolationEvidence::class);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ViolationCategory extends Model
{
protected $fillable = [
'name', 'slug', 'color', 'description', 'sort_order', 'is_active',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'sort_order' => 'integer',
];
}
public function violationTypes()
{
return $this->hasMany(ViolationType::class, 'category_id');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class ViolationEvidence extends Model
{
protected $table = 'violation_evidences';
protected $fillable = [
'violation_id', 'file_path', 'original_name', 'file_size', 'mime_type',
];
public function violation()
{
return $this->belongsTo(Violation::class);
}
public function getUrlAttribute(): string
{
return Storage::url($this->file_path);
}
public function getThumbnailUrlAttribute(): string
{
return Storage::url($this->file_path);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ViolationType extends Model
{
protected $fillable = [
'category_id', 'name', 'slug', 'points',
'default_sanction', 'description', 'is_active',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'points' => 'integer',
];
}
public function category()
{
return $this->belongsTo(ViolationCategory::class, 'category_id');
}
public function violations()
{
return $this->hasMany(Violation::class, 'violation_type_id');
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
class BroadcastServiceProvider extends ServiceProvider
{
public function boot(): void
{
Broadcast::routes();
require base_path('routes/channels.php');
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\Services;
use App\Models\Classes;
use App\Models\Student;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class StudentSyncService
{
public function sync(string $baseUrl, string $token): array
{
$results = [
'students_created' => 0,
'students_updated' => 0,
'classes_created' => 0,
'classes_updated' => 0,
'errors' => [],
];
try {
$response = Http::acceptJson()
->withToken($token)
->timeout(60)
->get(rtrim($baseUrl, '/') . '/api/integration/students');
if (!$response->successful()) {
return ['error' => 'Gagal terhubung: ' . $response->status()];
}
$payload = $response->json();
$items = Arr::get($payload, 'data', []);
DB::transaction(function () use ($items, &$results) {
foreach ($items as $item) {
// Only sync active students
$status = $item['status'] ?? 'active';
if ($status !== 'active') {
$results['errors'][] = 'Skipped (status: ' . $status . '): ' . ($item['full_name'] ?? 'unknown');
continue;
}
// Use NISN as primary key, fallback to student_number if NISN is null
$nisn = $item['nisn'] ?? null;
if (!$nisn) {
$studentNumber = $item['student_number'] ?? null;
if ($studentNumber) {
$nisn = 'NIS_' . preg_replace('/[^A-Za-z0-9\-]/', '_', $studentNumber);
$results['errors'][] = 'Using student_number as ID: ' . ($item['full_name'] ?? 'unknown') . ' (' . $studentNumber . ')';
} else {
$results['errors'][] = 'Skipped (no NISN/NIS): ' . ($item['full_name'] ?? 'unknown');
continue;
}
}
// Sync class
$classData = $item['classroom'] ?? [];
$className = $item['class_name'] ?? '-';
$class = Classes::updateOrCreate(
['source_id' => (string) ($classData['id'] ?? $className)],
[
'name' => $className,
'level' => $classData['level'] ?? $item['class_level'] ?? 'X',
'department_code' => $item['department_code'] ?? '',
'department_name' => $item['department_name'] ?? '',
'academic_year_name' => $item['academic_year_name'] ?? '',
'is_active' => true,
]
);
if ($class->wasRecentlyCreated) $results['classes_created']++;
elseif ($class->wasChanged()) $results['classes_updated']++;
// Sync student
$student = Student::updateOrCreate(
['nisn' => $nisn],
[
'source_id' => (string) ($item['id'] ?? ''),
'student_number' => $item['student_number'],
'full_name' => $item['full_name'],
'gender' => $item['gender'],
'place_of_birth' => $item['place_of_birth'],
'date_of_birth' => $item['date_of_birth'] ?: null,
'address' => $item['address'],
'phone_number' => $item['phone_number'],
'email' => $item['email'],
'class_name' => $className,
'class_level' => $classData['level'] ?? $item['class_level'] ?? 'X',
'department_code' => $item['department_code'] ?? '',
'department_name' => $item['department_name'] ?? '',
'academic_year_name' => $item['academic_year_name'] ?? '',
'status' => 'active',
'photo_path' => $item['photo_path'],
'class_id' => $class->id,
'is_active' => true,
'synced_at' => now(),
'metadata' => [
'source_updated_at' => $item['updated_at'],
'synced_from' => 'database-kesiswaan',
],
]
);
if ($student->wasRecentlyCreated) $results['students_created']++;
else $results['students_updated']++;
}
});
return $results;
} catch (\Exception $e) {
Log::error('Student sync failed: ' . $e->getMessage());
return ['error' => 'Sinkron gagal: ' . $e->getMessage()];
}
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
namespace App\Services;
use App\Events\ViolationRecorded;
use App\Models\SpLetter;
use App\Models\SpThreshold;
use App\Models\Student;
use App\Models\User;
use App\Models\Violation;
use App\Models\AppNotification;
use App\Models\Setting;
use Illuminate\Support\Facades\DB;
class ViolationService
{
public function recordViolation(array $data, int $userId): Violation
{
$violation = DB::transaction(function () use ($data, $userId) {
$violation = Violation::create([
'student_id' => $data['student_id'],
'violation_type_id' => $data['violation_type_id'],
'recorded_by' => $userId,
'description' => $data['description'] ?? null,
'points' => $data['points'],
'sanction' => $data['sanction'] ?? null,
'location' => $data['location'] ?? null,
'violation_date' => $data['violation_date'],
'violation_time' => $data['violation_time'] ?? null,
'notes' => $data['notes'] ?? null,
]);
// Save evidence photos
if (isset($data['evidences']) && is_array($data['evidences'])) {
foreach ($data['evidences'] as $file) {
if (!$file || !$file->isValid()) continue;
$path = $file->store('violations/' . $violation->id, 'public');
$violation->evidences()->create([
'file_path' => $path,
'original_name' => $file->getClientOriginalName(),
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
]);
}
}
return $violation;
});
// Check SP thresholds
$student = $violation->student;
$totalPoints = $student->total_points;
$thresholdReached = SpThreshold::where('min_points', '<=', $totalPoints)
->where('is_active', true)
->orderByDesc('min_points')
->first();
if ($thresholdReached) {
$existingSp = SpLetter::where('student_id', $student->id)
->where('sp_threshold_id', $thresholdReached->id)
->exists();
if (!$existingSp) {
$this->generateSpLetter($student, $thresholdReached, $userId);
}
}
// Broadcast realtime event
$this->notifyRealtime($violation);
return $violation;
}
protected function generateSpLetter(Student $student, SpThreshold $threshold, int $userId): SpLetter
{
$schoolName = Setting::getValue('school_name', 'SMK');
$letterCount = SpLetter::where('sp_threshold_id', $threshold->id)->count() + 1;
$letter = SpLetter::create([
'student_id' => $student->id,
'sp_threshold_id' => $threshold->id,
'generated_by' => $userId,
'letter_number' => sprintf('%s/%s/%s/%s',
$threshold->slug,
str_pad((string) $letterCount, 3, '0', STR_PAD_LEFT),
date('m'),
date('Y')
),
'title' => $threshold->name . ' - ' . $student->full_name,
'total_points_at_time' => $student->total_points,
'violations_included' => $student->violations()
->whereNull('deleted_at')
->get(['id', 'violation_date', 'points', 'description'])
->toArray(),
'status' => 'draft',
]);
return $letter;
}
protected function notifyRealtime(Violation $violation): void
{
try {
$student = $violation->student;
// Create notifications for BK users
$bkUsers = User::whereIn('role', ['admin', 'bk'])->get();
foreach ($bkUsers as $user) {
AppNotification::create([
'user_id' => $user->id,
'type' => 'violation_recorded',
'title' => 'Pelanggaran Baru: ' . $student->full_name,
'body' => $violation->violationType->name . ' (+' . $violation->points . ' poin)',
'data' => [
'violation_id' => $violation->id,
'student_name' => $student->full_name,
'points' => $violation->points,
'total_points' => $student->total_points,
],
'icon' => 'exclamation-triangle',
'color' => $violation->violationType->category->color ?? '#ef4444',
'action_url' => route('violations.show', $violation->id),
'violation_id' => $violation->id,
]);
}
// Broadcast via Reverb
broadcast(new ViolationRecorded($violation))->toOthers();
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('Notify realtime failed: ' . $e->getMessage());
}
}
}