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:
@@ -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('/');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user