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,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[{compose,docker-compose}.{yml,yaml}]
|
||||
indent_size = 4
|
||||
@@ -0,0 +1,65 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.codex
|
||||
/.cursor/
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
_ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,58 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||
|
||||
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
|
||||
|
||||
## Agentic Development
|
||||
|
||||
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
|
||||
|
||||
```bash
|
||||
composer require laravel/boost --dev
|
||||
|
||||
php artisan boost:install
|
||||
```
|
||||
|
||||
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*'),
|
||||
);
|
||||
})->create();
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\BroadcastServiceProvider::class,
|
||||
];
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/reverb": "^1.10",
|
||||
"laravel/tinker": "^3.0",
|
||||
"phpoffice/phpspreadsheet": "^5.9"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/pail": "^1.2.5",
|
||||
"laravel/pao": "^1.0.6",
|
||||
"laravel/pint": "^1.27",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^12.5.12"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install --ignore-scripts",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi @no_additional_args",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
Generated
+9572
File diff suppressed because it is too large
Load Diff
+126
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
];
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "array", "database", "file", "memcached",
|
||||
| "redis", "dynamodb", "storage", "octane",
|
||||
| "session", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CACHE_CONNECTION'),
|
||||
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
|
||||
'storage' => [
|
||||
'driver' => 'storage',
|
||||
'disk' => env('CACHE_STORAGE_DISK'),
|
||||
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'stores' => [
|
||||
'database',
|
||||
'array',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||
| stores, there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Serializable Classes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the classes that can be unserialized from cache
|
||||
| storage. By default, no PHP classes will be unserialized from your
|
||||
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
||||
|
|
||||
*/
|
||||
|
||||
'serializable_classes' => false,
|
||||
|
||||
];
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Pdo\Mysql;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run on the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as Memcached. You may define your connection settings here.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that is utilized to write
|
||||
| messages to your logs. The value provided here should match one of
|
||||
| the channels present in the list of "channels" configured below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deprecations Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the log channel that should be used to log warnings
|
||||
| regarding deprecated PHP and library features. This allows you to get
|
||||
| your application ready for upcoming major versions of dependencies.
|
||||
|
|
||||
*/
|
||||
|
||||
'deprecations' => [
|
||||
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Log Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the log channels for your application. Laravel
|
||||
| utilizes the Monolog PHP logging library, which includes a variety
|
||||
| of powerful log handlers and formatters that you're free to use.
|
||||
|
|
||||
| Available drivers: "single", "daily", "slack", "syslog",
|
||||
| "errorlog", "monolog", "custom", "stack"
|
||||
|
|
||||
*/
|
||||
|
||||
'channels' => [
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
|
||||
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => StreamHandler::class,
|
||||
'handler_with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send all email
|
||||
| messages unless another mailer is explicitly specified when sending
|
||||
| the message. All additional mailers can be configured within the
|
||||
| "mailers" array. Examples of each type of mailer are provided.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mailer Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| you and you are free to add your own as your application requires.
|
||||
|
|
||||
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||
| when delivering an email. You may specify which one you're using for
|
||||
| your mailers below. You may also add additional mailers if needed.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||
| "postmark", "resend", "log", "array",
|
||||
| "failover", "roundrobin"
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => env('MAIL_SCHEME'),
|
||||
'url' => env('MAIL_URL'),
|
||||
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||
'port' => env('MAIL_PORT', 2525),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'transport' => 'ses',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'transport' => 'postmark',
|
||||
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'transport' => 'resend',
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
'roundrobin' => [
|
||||
'transport' => 'roundrobin',
|
||||
'mailers' => [
|
||||
'ses',
|
||||
'postmark',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global "From" Address
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may wish for all emails sent by your application to be sent from
|
||||
| the same address. Here you may specify a name and address that is
|
||||
| used globally for all emails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue supports a variety of backends via a single, unified
|
||||
| API, giving you convenient access to each backend using identical
|
||||
| syntax for each. The default queue connection is defined below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the connection options for every queue backend
|
||||
| used by your application. An example configuration is provided for
|
||||
| each backend supported by Laravel. You're also free to add more.
|
||||
|
|
||||
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||
| "deferred", "background", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'sqs' => [
|
||||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'deferred' => [
|
||||
'driver' => 'deferred',
|
||||
],
|
||||
|
||||
'background' => [
|
||||
'driver' => 'background',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'connections' => [
|
||||
'database',
|
||||
'deferred',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Batching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following options configure the database and table that store job
|
||||
| batching information. These options can be updated to any database
|
||||
| connection and table which has been defined by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'batching' => [
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Failed Queue Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options configure the behavior of failed queue job logging so you
|
||||
| can control how and where failed jobs are stored. Laravel ships with
|
||||
| support for storing failed jobs in a simple file or in a database.
|
||||
|
|
||||
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Reverb Server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default server used by Reverb to handle
|
||||
| incoming messages as well as broadcasting message to all your
|
||||
| connected clients. At this time only "reverb" is supported.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('REVERB_SERVER', 'reverb'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Servers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define details for each of the supported Reverb servers.
|
||||
| Each server has its own configuration options that are defined in
|
||||
| the array below. You should ensure all the options are present.
|
||||
|
|
||||
*/
|
||||
|
||||
'servers' => [
|
||||
|
||||
'reverb' => [
|
||||
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
|
||||
'port' => env('REVERB_SERVER_PORT', 8080),
|
||||
'path' => env('REVERB_SERVER_PATH', ''),
|
||||
'hostname' => env('REVERB_HOST'),
|
||||
'options' => [
|
||||
'tls' => [],
|
||||
],
|
||||
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
|
||||
'scaling' => [
|
||||
'enabled' => env('REVERB_SCALING_ENABLED', false),
|
||||
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
|
||||
'server' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'timeout' => env('REDIS_TIMEOUT', 60),
|
||||
],
|
||||
],
|
||||
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
|
||||
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Applications
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define how Reverb applications are managed. If you choose
|
||||
| to use the "config" provider, you may define an array of apps which
|
||||
| your server will support, including their connection credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'apps' => [
|
||||
|
||||
'provider' => 'config',
|
||||
|
||||
'apps' => [
|
||||
[
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'allowed_origins' => ['*'],
|
||||
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
||||
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
||||
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
|
||||
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
|
||||
'rate_limiting' => [
|
||||
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
|
||||
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
|
||||
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
|
||||
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'postmark' => [
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
'kesiswaan' => [
|
||||
'base_url' => env('KESISWAAN_BASE_URL', ''),
|
||||
'token' => env('KESISWAAN_TOKEN', ''),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines the default session driver that is utilized for
|
||||
| incoming requests. Laravel supports a variety of storage options to
|
||||
| persist session data. Database storage is a great default choice.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "memcached",
|
||||
| "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SESSION_DRIVER', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Lifetime
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the number of minutes that you wish the session
|
||||
| to be allowed to remain idle before it expires. If you want them
|
||||
| to expire immediately when the browser is closed then you may
|
||||
| indicate that via the expire_on_close configuration option.
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to easily specify that all of your session data
|
||||
| should be encrypted before it's stored. All encryption is performed
|
||||
| automatically by Laravel and you may use the session like normal.
|
||||
|
|
||||
*/
|
||||
|
||||
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session File Location
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the "file" session driver, the session files are placed
|
||||
| on disk. The default storage location is defined here; however, you
|
||||
| are free to provide another location where they should be stored.
|
||||
|
|
||||
*/
|
||||
|
||||
'files' => storage_path('framework/sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" or "redis" session drivers, you may specify a
|
||||
| connection that should be used to manage these sessions. This should
|
||||
| correspond to a connection in your database configuration options.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" session driver, you may specify the table to
|
||||
| be used to store sessions. Of course, a sensible default is defined
|
||||
| for you; however, you're welcome to change this to another table.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => env('SESSION_TABLE', 'sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using one of the framework's cache driven session backends, you may
|
||||
| define the cache store which should be used to store the session data
|
||||
| between requests. This must match one of your defined cache stores.
|
||||
|
|
||||
| Affects: "dynamodb", "memcached", "redis"
|
||||
|
|
||||
*/
|
||||
|
||||
'store' => env('SESSION_STORE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Sweeping Lottery
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some session drivers must manually sweep their storage location to get
|
||||
| rid of old sessions from storage. Here are the chances that it will
|
||||
| happen on a given request. By default, the odds are 2 out of 100.
|
||||
|
|
||||
*/
|
||||
|
||||
'lottery' => [2, 100],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the name of the session cookie that is created by
|
||||
| the framework. Typically, you should not need to change this value
|
||||
| since doing so does not grant a meaningful security improvement.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The session cookie path determines the path for which the cookie will
|
||||
| be regarded as available. Typically, this will be the root path of
|
||||
| your application, but you're free to change this when necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('SESSION_PATH', '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the domain and subdomains the session cookie is
|
||||
| available to. By default, the cookie will be available to the root
|
||||
| domain without subdomains. Typically, this shouldn't be changed.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTPS Only Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By setting this option to true, session cookies will only be sent back
|
||||
| to the server if the browser has a HTTPS connection. This will keep
|
||||
| the cookie from being sent to you when it can't be done securely.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Access Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will prevent JavaScript from accessing the
|
||||
| value of the cookie and the cookie will only be accessible through
|
||||
| the HTTP protocol. It's unlikely you should disable this option.
|
||||
|
|
||||
*/
|
||||
|
||||
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Same-Site Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines how your cookies behave when cross-site requests
|
||||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" to permit secure cross-site requests.
|
||||
|
|
||||
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
*/
|
||||
|
||||
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Partitioned Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will tie the cookie to the top-level site for
|
||||
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
|
|
||||
*/
|
||||
|
||||
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Serialization
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the serialization strategy for session data, which
|
||||
| is JSON by default. Setting this to "php" allows the storage of PHP
|
||||
| objects in the session but can make an application vulnerable to
|
||||
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
||||
|
|
||||
| Supported: "json", "php"
|
||||
|
|
||||
*/
|
||||
|
||||
'serialization' => 'json',
|
||||
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('username')->unique();
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->string('role')->default('staff'); // admin, bk, wali_kelas, staff
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sessions');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('users');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedSmallInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->string('connection');
|
||||
$table->string('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
|
||||
$table->index(['connection', 'queue', 'failed_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('classes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('source_id')->nullable()->index();
|
||||
$table->string('name');
|
||||
$table->string('level'); // X, XI, XII
|
||||
$table->string('department_code')->nullable();
|
||||
$table->string('department_name')->nullable();
|
||||
$table->string('academic_year_name')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('classes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('students', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('source_id')->nullable()->index();
|
||||
$table->string('nisn')->unique();
|
||||
$table->string('student_number')->nullable()->index();
|
||||
$table->string('full_name');
|
||||
$table->string('gender')->nullable();
|
||||
$table->string('place_of_birth')->nullable();
|
||||
$table->date('date_of_birth')->nullable();
|
||||
$table->string('address')->nullable();
|
||||
$table->string('phone_number')->nullable();
|
||||
$table->string('email')->nullable();
|
||||
$table->string('class_name')->nullable();
|
||||
$table->string('class_level')->nullable();
|
||||
$table->string('department_code')->nullable();
|
||||
$table->string('department_name')->nullable();
|
||||
$table->string('academic_year_name')->nullable();
|
||||
$table->string('status')->default('active');
|
||||
$table->string('photo_path')->nullable();
|
||||
$table->foreignId('class_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('synced_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('students');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('violation_categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name'); // Ringan, Sedang, Berat
|
||||
$table->string('slug')->unique();
|
||||
$table->string('color')->default('#22c55e'); // green default
|
||||
$table->text('description')->nullable();
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('violation_categories');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('violation_types', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('category_id')->constrained('violation_categories')->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->integer('points')->default(0);
|
||||
$table->text('default_sanction')->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('violation_types');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('violations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('violation_type_id')->constrained('violation_types')->cascadeOnDelete();
|
||||
$table->foreignId('recorded_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->text('description')->nullable();
|
||||
$table->integer('points')->default(0);
|
||||
$table->string('sanction')->nullable();
|
||||
$table->string('location')->nullable();
|
||||
$table->date('violation_date');
|
||||
$table->time('violation_time')->nullable();
|
||||
$table->boolean('is_verified')->default(false);
|
||||
$table->foreignId('verified_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('verified_at')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index('violation_date');
|
||||
$table->index(['student_id', 'violation_date']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('violations');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('violation_evidences', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('violation_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('file_path');
|
||||
$table->string('original_name');
|
||||
$table->integer('file_size')->nullable();
|
||||
$table->string('mime_type')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('violation_evidences');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sp_thresholds', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name'); // SP 1, SP 2, SP 3
|
||||
$table->string('slug')->unique();
|
||||
$table->integer('min_points'); // ambang poin minimal
|
||||
$table->integer('max_points')->nullable(); // null = unlimited
|
||||
$table->string('color')->default('#ef4444');
|
||||
$table->text('default_description')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sp_thresholds');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sp_letters', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('sp_threshold_id')->constrained('sp_thresholds')->cascadeOnDelete();
|
||||
$table->foreignId('generated_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('letter_number')->unique();
|
||||
$table->string('title');
|
||||
$table->text('body')->nullable();
|
||||
$table->integer('total_points_at_time')->default(0);
|
||||
$table->json('violations_included')->nullable();
|
||||
$table->string('status')->default('draft'); // draft, printed, signed, delivered
|
||||
$table->string('file_path')->nullable();
|
||||
$table->timestamp('printed_at')->nullable();
|
||||
$table->timestamp('delivered_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sp_letters');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete();
|
||||
$table->string('type'); // violation_recorded, sp_generated, threshold_reached
|
||||
$table->string('title');
|
||||
$table->text('body')->nullable();
|
||||
$table->json('data')->nullable();
|
||||
$table->string('icon')->nullable();
|
||||
$table->string('color')->nullable();
|
||||
$table->string('action_url')->nullable();
|
||||
$table->boolean('is_read')->default(false);
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->foreignId('violation_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('sp_letter_id')->nullable()->constrained('sp_letters')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'is_read']);
|
||||
$table->index('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notifications');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('key')->unique();
|
||||
$table->text('value')->nullable();
|
||||
$table->string('group')->default('general');
|
||||
$table->text('description')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('settings');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('students', function (Blueprint $table) {
|
||||
$table->string('nisn')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Cannot reliably revert nullable without data loss
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\ViolationCategory;
|
||||
use App\Models\ViolationType;
|
||||
use App\Models\SpThreshold;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Admin user
|
||||
User::create([
|
||||
'name' => 'Administrator',
|
||||
'username' => 'admin',
|
||||
'email' => 'admin@ketertiban.local',
|
||||
'password' => Hash::make('Admin123!'),
|
||||
'role' => 'admin',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
User::create([
|
||||
'name' => 'BK Sekolah',
|
||||
'username' => 'bk',
|
||||
'email' => 'bk@ketertiban.local',
|
||||
'password' => Hash::make('Bk123!'),
|
||||
'role' => 'bk',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// Categories
|
||||
$ringan = ViolationCategory::create(['name' => 'Ringan', 'slug' => 'ringan', 'color' => '#22c55e', 'description' => 'Pelanggaran ringan — poin 1-15', 'sort_order' => 1, 'is_active' => true]);
|
||||
$sedang = ViolationCategory::create(['name' => 'Sedang', 'slug' => 'sedang', 'color' => '#eab308', 'description' => 'Pelanggaran sedang — poin 15-50', 'sort_order' => 2, 'is_active' => true]);
|
||||
$berat = ViolationCategory::create(['name' => 'Berat', 'slug' => 'berat', 'color' => '#ef4444', 'description' => 'Pelanggaran berat — poin 50-100', 'sort_order' => 3, 'is_active' => true]);
|
||||
|
||||
$this->createTypes($ringan->id, [
|
||||
['Terlambat datang ke sekolah', 2, 'Teguran lisan, catat nama'],
|
||||
['Terlambat masuk kelas setelah jam istirahat', 2, 'Teguran lisan'],
|
||||
['Tidak memakai ikat pinggang', 3, 'Teguran, pinjam ikat pinggang'],
|
||||
['Tidak memakai dasi', 3, 'Teguran, pinjam dasi'],
|
||||
['Tidak memakai topi sekolah (hari topi)', 3, 'Teguran lisan'],
|
||||
['Baju tidak dimasukkan', 3, 'Dimasukkan saat itu juga'],
|
||||
['Celana/rok digulung', 4, 'Dibetulkan saat itu juga'],
|
||||
['Sepatu tidak sesuai ketentuan', 4, 'Teguran, catat nama'],
|
||||
['Kaos kaki tidak sesuai ketentuan', 3, 'Teguran lisan'],
|
||||
['Rambut tidak rapi', 5, 'Teguran, potong rambut'],
|
||||
['Rambut dicat/diwarnai', 10, 'Cat rambut hitam kembali'],
|
||||
['Kuku panjang / dicat', 3, 'Potong kuku saat itu juga'],
|
||||
['Make-up / kosmetik berlebihan', 5, 'Bersihkan saat itu juga'],
|
||||
['Aksesoris tidak sesuai aturan', 5, 'Lepas, aksesoris disita'],
|
||||
['Makan/minum di kelas saat jam pelajaran', 3, 'Teguran lisan'],
|
||||
['Membuang sampah sembarangan', 5, 'Bersihkan area sekitarnya'],
|
||||
['Berkata tidak sopan / kotor', 5, 'Teguran, menulis pernyataan'],
|
||||
['Berteriak / ribut di dalam kelas', 3, 'Teguran lisan'],
|
||||
['Di koridor saat jam pelajaran tanpa izin', 4, 'Kembali ke kelas'],
|
||||
['Tidak membawa buku/catatan pelajaran', 3, 'Teguran, catat nama'],
|
||||
['Tidak mengerjakan PR/tugas', 5, 'Kerjakan saat istirahat'],
|
||||
['Mencontek saat ulangan', 10, 'Nilai dikurangi, teguran'],
|
||||
['Tidak ikut upacara tanpa keterangan', 8, 'Teguran, catat nama'],
|
||||
]);
|
||||
|
||||
$this->createTypes($sedang->id, [
|
||||
['Membolos jam pelajaran', 20, 'Panggilan orang tua, surat pernyataan'],
|
||||
['Meninggalkan sekolah tanpa izin', 25, 'Panggilan orang tua'],
|
||||
['Tidak masuk tanpa keterangan (alpha)', 15, 'Panggilan orang tua via wali kelas'],
|
||||
['Memalsukan surat izin / tanda tangan', 30, 'Panggilan orang tua, surat pernyataan'],
|
||||
['Merokok di lingkungan sekolah', 35, 'Skorsing 1 hari, panggilan orang tua'],
|
||||
['HP menyala/dipakai saat jam pelajaran', 20, 'HP disita 1 minggu'],
|
||||
['Merekam/memfoto teman tanpa izin', 25, 'HP disita, hapus rekaman'],
|
||||
['Berkelahi ringan (dorong, adu mulut fisik)', 30, 'Skorsing 1 hari, panggilan orang tua'],
|
||||
['Mengancam / mengintimidasi verbal', 30, 'Konseling BK, panggilan orang tua'],
|
||||
['Mengejek / membully teman', 25, 'Surat pernyataan, panggilan orang tua'],
|
||||
['Membawa alat perjudian', 40, 'Skorsing 3 hari, panggilan orang tua'],
|
||||
['Membawa barang terlarang (petasan dll)', 30, 'Barang disita, panggilan orang tua'],
|
||||
['Corat-coret fasilitas sekolah', 20, 'Bersihkan / cat ulang'],
|
||||
['Merusak fasilitas sekolah ringan', 25, 'Ganti rugi + perbaiki'],
|
||||
['Menyontek massal / menyebar jawaban', 30, 'Nilai 0, panggilan orang tua'],
|
||||
]);
|
||||
|
||||
$this->createTypes($berat->id, [
|
||||
['Berkelahi/tawuran berat', 75, 'Skorsing 1 minggu, panggilan orang tua'],
|
||||
['Membawa senjata tajam', 100, 'Dikeluarkan dari sekolah, laporan polisi'],
|
||||
['Membawa/mengonsumsi minuman keras', 100, 'Dikeluarkan, laporan polisi'],
|
||||
['Membawa/menggunakan narkoba', 100, 'Dikeluarkan, laporan polisi'],
|
||||
['Pencurian', 80, 'Skorsing, panggilan orang tua, laporan polisi'],
|
||||
['Perusakan fasilitas sekolah berat', 60, 'Ganti rugi penuh, skorsing 3 hari'],
|
||||
['Penganiayaan', 100, 'Dikeluarkan, laporan polisi'],
|
||||
['Pemerasan / perampasan barang', 80, 'Skorsing, panggilan orang tua, laporan polisi'],
|
||||
['Perbuatan asusila / pelecehan seksual', 100, 'Dikeluarkan, laporan polisi'],
|
||||
['Mengedarkan konten pornografi', 80, 'Skorsing, panggilan orang tua'],
|
||||
['Ancaman kekerasan serius', 100, 'Dikeluarkan, laporan polisi'],
|
||||
['Merusak sistem informasi sekolah', 90, 'Skorsing, panggilan orang tua'],
|
||||
]);
|
||||
|
||||
// SP Thresholds
|
||||
SpThreshold::create(['name' => 'SP 1', 'slug' => 'sp1', 'min_points' => 50, 'max_points' => 99, 'color' => '#eab308', 'default_description' => 'Surat Peringatan 1: Teguran resmi pertama']);
|
||||
SpThreshold::create(['name' => 'SP 2', 'slug' => 'sp2', 'min_points' => 100, 'max_points' => 149, 'color' => '#f97316', 'default_description' => 'Surat Peringatan 2: Panggilan orang tua wajib']);
|
||||
SpThreshold::create(['name' => 'SP 3', 'slug' => 'sp3', 'min_points' => 150, 'max_points' => null, 'color' => '#ef4444', 'default_description' => 'Surat Peringatan 3: Ancaman dikeluarkan']);
|
||||
|
||||
// School settings
|
||||
Setting::setValue('school_name', 'SMK Negeri 1 Contoh', 'school', 'Nama sekolah');
|
||||
Setting::setValue('school_address', 'Jl. Contoh No. 1, Kota', 'school', 'Alamat sekolah');
|
||||
Setting::setValue('school_phone', '(021) 1234567', 'school', 'No. telepon sekolah');
|
||||
Setting::setValue('school_logo', null, 'school', 'Logo sekolah');
|
||||
Setting::setValue('kepala_sekolah_name', 'Drs. H. Contoh', 'school', 'Nama Kepala Sekolah');
|
||||
Setting::setValue('kepala_sekolah_nip', '196001011990011001', 'school', 'NIP Kepala Sekolah');
|
||||
Setting::setValue('bk_koordinator_name', 'Drs. BK', 'school', 'Nama Koordinator BK');
|
||||
}
|
||||
|
||||
private function createTypes(int $categoryId, array $types): void
|
||||
{
|
||||
foreach ($types as [$name, $points, $sanction]) {
|
||||
ViolationType::create([
|
||||
'category_id' => $categoryId,
|
||||
'name' => $name,
|
||||
'slug' => Str::slug($name),
|
||||
'points' => $points,
|
||||
'default_sanction' => $sanction,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2038
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"alpinejs": "^3.15.12",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-echo": "^2.4.0",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"pusher-js": "^8.5.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||
"axios": "^1.18.1",
|
||||
"sweetalert2": "^11.26.25"
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="DB_URL" value=""/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,25 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
# Handle X-XSRF-Token Header
|
||||
RewriteCond %{HTTP:x-xsrf-token} .
|
||||
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$app->handleRequest(Request::capture());
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,22 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@fortawesome/fontawesome-free/css/fontawesome.css';
|
||||
@import '@fortawesome/fontawesome-free/css/regular.css';
|
||||
@import '@fortawesome/fontawesome-free/css/solid.css';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
|
||||
/* Custom styles for Aplikasi Ketertiban */
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #f1f1f1; }
|
||||
::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a1a1a1; }
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
body { background: white !important; }
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import './bootstrap';
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
|
||||
window.Alpine = Alpine;
|
||||
|
||||
Alpine.start();
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
import Echo from 'laravel-echo';
|
||||
import Pusher from 'pusher-js';
|
||||
|
||||
window.Pusher = Pusher;
|
||||
|
||||
// Echo will be initialized when the page has the Reverb config
|
||||
// The actual Echo initialization is done conditionally in the layout
|
||||
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
@php
|
||||
$schoolName = App\Models\Setting::getValue('school_name', 'Ketertiban');
|
||||
$appName = App\Models\Setting::getValue('app_name', 'Aplikasi Ketertiban');
|
||||
$logoPath = App\Models\Setting::getValue('school_logo');
|
||||
$bgPath = App\Models\Setting::getValue('login_background');
|
||||
@endphp
|
||||
<title>Login - {{ $appName }}</title>
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 px-3 py-3 sm:px-6 sm:py-6 antialiased">
|
||||
<div class="mx-auto grid min-h-[calc(100vh-1.5rem)] max-w-7xl overflow-hidden rounded-[24px] sm:rounded-3xl border border-slate-200 bg-white shadow-2xl lg:grid-cols-2 lg:min-h-[92vh]">
|
||||
{{-- Left Side: Branding --}}
|
||||
<div class="relative hidden overflow-hidden lg:flex lg:flex-col lg:justify-between">
|
||||
@if($bgPath)
|
||||
<img src="{{ asset('storage/' . $bgPath) }}" alt="Background" class="absolute inset-0 h-full w-full object-cover">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-black/10"></div>
|
||||
@else
|
||||
<div class="absolute inset-0"
|
||||
style="background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 50%, #3b82f6 100%);"></div>
|
||||
<div class="absolute inset-0"
|
||||
style="background-image: radial-gradient(rgba(255,255,255,0.06) 1px, transparent 1px); background-size: 24px 24px;"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/30 via-transparent to-transparent"></div>
|
||||
@endif
|
||||
|
||||
<div class="relative z-10 p-10">
|
||||
<div class="inline-flex h-16 w-16 items-center justify-center rounded-3xl bg-white/90 shadow-lg">
|
||||
@if($logoPath)
|
||||
<img src="{{ asset('storage/' . $logoPath) }}" alt="Logo" class="w-10 h-10 object-contain">
|
||||
@else
|
||||
<span class="text-xl font-extrabold tracking-tight text-blue-600">KT</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 mx-10 mb-10 rounded-3xl border border-white/15 bg-white/10 p-6 backdrop-blur-sm">
|
||||
<p class="text-sm font-semibold text-white">{{ $appName }}</p>
|
||||
<p class="text-xs text-white/50 mt-1">{{ $schoolName }}</p>
|
||||
<p class="mt-2 text-sm leading-6 text-white/70">
|
||||
Sistem pencatatan pelanggaran siswa terintegrasi dengan manajemen poin, surat peringatan otomatis, dan notifikasi realtime.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Right Side: Form --}}
|
||||
<div class="flex items-center justify-center bg-white p-4 sm:p-8 lg:p-8">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-5 text-center lg:text-left">
|
||||
<p class="text-sm font-semibold uppercase tracking-[0.2em] text-blue-600">{{ $appName }}</p>
|
||||
<p class="text-xs text-slate-400 mt-0.5">{{ $schoolName }}</p>
|
||||
<h2 class="mt-2 text-2xl sm:text-[28px] font-semibold text-slate-900">Selamat Datang</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||
Masukkan kredensial Anda untuk mengelola data pelanggaran, memantau poin siswa, dan menerbitkan surat peringatan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-4 rounded-2xl border border-blue-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('login') }}" class="space-y-3">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label for="username" class="mb-1.5 block text-sm font-medium text-slate-700">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
value="{{ old('username') }}"
|
||||
placeholder="Masukkan username"
|
||||
required
|
||||
autofocus
|
||||
class="w-full rounded-2xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 @error('username') border-red-300 @enderror"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="mb-1.5 block text-sm font-medium text-slate-700">Password</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Masukkan password"
|
||||
required
|
||||
class="w-full rounded-2xl border border-slate-200 bg-white px-4 py-2.5 pr-12 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="toggle-password"
|
||||
class="absolute inline-flex items-center justify-center text-slate-500 transition hover:text-blue-600"
|
||||
style="top: 50%; right: 16px; transform: translateY(-50%); width: 20px; height: 20px;"
|
||||
aria-label="Tampilkan password"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<i id="toggle-password-icon" class="fa-regular fa-eye" style="font-size: 15px; line-height: 1;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-3 text-sm text-slate-600">
|
||||
<input type="checkbox" name="remember" value="1"
|
||||
class="rounded border-slate-300 text-blue-600 focus:ring-blue-500">
|
||||
<span>Ingat Saya</span>
|
||||
</label>
|
||||
|
||||
<button type="submit"
|
||||
class="inline-flex w-full items-center justify-center rounded-2xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-blue-700">
|
||||
Masuk
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const passwordInput = document.getElementById('password');
|
||||
const toggleButton = document.getElementById('toggle-password');
|
||||
const toggleIcon = document.getElementById('toggle-password-icon');
|
||||
|
||||
if (!passwordInput || !toggleButton) return;
|
||||
|
||||
toggleButton.addEventListener('click', function () {
|
||||
const isHidden = passwordInput.type === 'password';
|
||||
passwordInput.type = isHidden ? 'text' : 'password';
|
||||
toggleButton.setAttribute('aria-label', isHidden ? 'Sembunyikan password' : 'Tampilkan password');
|
||||
toggleButton.setAttribute('aria-pressed', isHidden ? 'true' : 'false');
|
||||
if (toggleIcon) {
|
||||
toggleIcon.className = isHidden ? 'fa-regular fa-eye-slash text-base' : 'fa-regular fa-eye text-base';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
@props(['href', 'icon', 'active' => false])
|
||||
|
||||
@php
|
||||
$isActive = $active ?? request()->fullUrlIs($href) || request()->is(trim(parse_url($href, PHP_URL_PATH) ?? '', '/'));
|
||||
$classes = $isActive
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-500'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900';
|
||||
|
||||
$faIcons = [
|
||||
'home' => 'fa-solid fa-house',
|
||||
'plus-circle' => 'fa-solid fa-circle-plus',
|
||||
'exclamation-triangle' => 'fa-solid fa-triangle-exclamation',
|
||||
'users' => 'fa-solid fa-users',
|
||||
'document-text' => 'fa-solid fa-file-lines',
|
||||
'tag' => 'fa-solid fa-tag',
|
||||
'list' => 'fa-solid fa-list',
|
||||
'chart-bar' => 'fa-solid fa-chart-simple',
|
||||
'refresh' => 'fa-solid fa-rotate',
|
||||
'cog' => 'fa-solid fa-gear',
|
||||
'users-cog' => 'fa-solid fa-user-gear',
|
||||
];
|
||||
$faClass = $faIcons[$icon] ?? 'fa-solid fa-circle';
|
||||
@endphp
|
||||
|
||||
<a href="{{ $href }}" {{ $attributes->merge(['class' => 'group flex items-center px-3 py-2 text-sm font-medium rounded-md ' . $classes]) }}>
|
||||
<i class="{{ $faClass }} mr-3 w-5 text-center text-sm flex-shrink-0"></i>
|
||||
{{ $slot }}
|
||||
</a>
|
||||
@@ -0,0 +1,144 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Dashboard')
|
||||
|
||||
@section('content')
|
||||
<div x-data="notifications()">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p class="text-sm text-gray-500">Ringkasan pelanggaran siswa</p>
|
||||
</div>
|
||||
|
||||
{{-- Stats Cards --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500">Pelanggaran Hari Ini</p>
|
||||
<p class="text-3xl font-bold text-blue-600 mt-1">{{ $stats['today_violations'] }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<i class="fa-solid fa-triangle-exclamation text-blue-600 text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500">Total Pelanggaran</p>
|
||||
<p class="text-3xl font-bold text-orange-600 mt-1">{{ $stats['total_violations'] }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<i class="fa-solid fa-list-check text-orange-600 text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500">Siswa Aktif</p>
|
||||
<p class="text-3xl font-bold text-blue-600 mt-1">{{ $stats['total_students'] }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<i class="fa-solid fa-users text-blue-600 text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500">SP Aktif (Draft)</p>
|
||||
<p class="text-3xl font-bold text-yellow-600 mt-1">{{ $stats['active_sp'] }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<i class="fa-solid fa-file-lines text-yellow-600 text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{{-- Recent Violations --}}
|
||||
<div class="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-5 py-4 border-b border-gray-100">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Pelanggaran Terbaru</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@forelse($recentViolations as $v)
|
||||
<a href="{{ route('violations.show', $v->id) }}" class="flex items-center justify-between px-5 py-3 hover:bg-gray-50 transition">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background-color: {{ $v->violationType?->category?->color ?? '#6b7280' }}"></span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $v->student->full_name ?? '-' }}</p>
|
||||
<p class="text-xs text-gray-500">{{ $v->violationType->name ?? '-' }} <span class="text-red-500 font-medium">+{{ $v->points }} poin</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right text-xs text-gray-400">
|
||||
<p>{{ $v->violation_date->format('d/m/Y') }}</p>
|
||||
<p>{{ $v->recorder->name ?? '-' }}</p>
|
||||
</div>
|
||||
</a>
|
||||
@empty
|
||||
<div class="px-5 py-8 text-center text-sm text-gray-500">Belum ada pelanggaran tercatat</div>
|
||||
@endforelse
|
||||
</div>
|
||||
<div class="px-5 py-3 border-t border-gray-100">
|
||||
<a href="{{ route('violations.index') }}" class="text-sm text-blue-600 hover:text-blue-800 font-medium">Lihat semua →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Top Students + SP Alerts --}}
|
||||
<div class="space-y-6">
|
||||
{{-- Notifikasi Ambang SP --}}
|
||||
@if($spThresholds->count() > 0)
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-5 py-4 border-b border-gray-100">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Ambang SP</h2>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
@foreach($spThresholds as $threshold)
|
||||
@php
|
||||
$studentsAtRisk = \App\Models\Student::where('is_active', true)->get()->filter(function($s) use ($threshold) {
|
||||
$pts = $s->total_points;
|
||||
$maxOk = $threshold->max_points ?? 9999;
|
||||
return $pts >= $threshold->min_points && $pts <= $maxOk;
|
||||
});
|
||||
@endphp
|
||||
<div class="flex items-center justify-between p-3 rounded-lg border" style="border-color: {{ $threshold->color }}20; background-color: {{ $threshold->color }}08">
|
||||
<div>
|
||||
<p class="text-sm font-semibold" style="color: {{ $threshold->color }}">{{ $threshold->name }}</p>
|
||||
<p class="text-xs text-gray-500">{{ $threshold->min_points }}-{{ $threshold->max_points ?? '~' }} poin</p>
|
||||
</div>
|
||||
<span class="text-lg font-bold" style="color: {{ $threshold->color }}">{{ $studentsAtRisk->count() }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Top 5 Siswa Poin Tertinggi --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-5 py-4 border-b border-gray-100">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Poin Tertinggi</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@forelse($topStudents as $student)
|
||||
<div class="flex items-center justify-between px-5 py-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="w-8 h-8 rounded-full bg-blue-100 text-red-700 flex items-center justify-center text-xs font-bold">{{ $loop->iteration }}</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $student->full_name }}</p>
|
||||
<p class="text-xs text-gray-500">{{ $student->class_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-blue-600">{{ $student->total_points }}</span>
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-5 py-8 text-center text-sm text-gray-500">Belum ada data</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,260 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id" class="h-full bg-gray-50">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
@php
|
||||
$schoolName = App\Models\Setting::getValue('school_name', 'Ketertiban');
|
||||
$appName = App\Models\Setting::getValue('app_name', 'Aplikasi Ketertiban');
|
||||
$logoPath = App\Models\Setting::getValue('school_logo');
|
||||
@endphp
|
||||
<title>{{ $schoolName }} - @yield('title', $appName)</title>
|
||||
<link rel="icon" type="image/png" href="{{ $logoPath ? asset('storage/' . $logoPath) : asset('favicon.ico') }}">
|
||||
<link rel="shortcut icon" type="image/png" href="{{ $logoPath ? asset('storage/' . $logoPath) : asset('favicon.ico') }}">
|
||||
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
@stack('styles')
|
||||
</head>
|
||||
<body class="h-full antialiased">
|
||||
<div x-data="{ sidebarOpen: false, showNotif: false, showUserMenu: false, notifications: [], notifCount: 0 }" class="min-h-screen flex">
|
||||
{{-- Sidebar --}}
|
||||
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'" class="fixed inset-y-0 left-0 z-50 w-64 bg-white border-r border-gray-200 lg:translate-x-0 lg:static lg:inset-auto transition-transform duration-200 ease-in-out flex flex-col">
|
||||
<div class="flex items-start justify-between h-auto px-4 pt-4 pb-3 border-b border-gray-200">
|
||||
<a href="{{ route('dashboard') }}" class="min-w-0">
|
||||
<div class="flex items-center gap-2.5 mb-2">
|
||||
<div class="w-8 h-8 rounded-xl bg-white border border-gray-200 flex items-center justify-center flex-shrink-0 shadow-sm overflow-hidden">
|
||||
@if($logoPath)
|
||||
<img src="{{ asset('storage/' . $logoPath) }}" alt="Logo" class="w-full h-full object-contain p-0.5">
|
||||
@else
|
||||
<div class="w-full h-full bg-gradient-to-br from-blue-500 to-sky-400 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-gray-800 leading-tight truncate max-w-[140px]">{{ $appName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</a>
|
||||
<button @click="sidebarOpen = false" class="lg:hidden text-gray-500 hover:text-gray-700 flex-shrink-0 ml-2 mt-0.5">
|
||||
<i class="fa-solid fa-xmark text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="mt-2 px-2 space-y-1">
|
||||
<x-nav-item href="{{ route('dashboard') }}" icon="home" :active="request()->routeIs('dashboard')">Dashboard</x-nav-item>
|
||||
<x-nav-item href="{{ route('violations.create') }}" icon="plus-circle" :active="request()->routeIs('violations.create')">Input Pelanggaran</x-nav-item>
|
||||
<x-nav-item href="{{ route('violations.index') }}" icon="exclamation-triangle" :active="request()->routeIs('violations.index')">Data Pelanggaran</x-nav-item>
|
||||
<x-nav-item href="{{ route('students.index') }}" icon="users" :active="request()->routeIs('students.index*')">Data Siswa</x-nav-item>
|
||||
<x-nav-item href="{{ route('sp-letters.index') }}" icon="document-text" :active="request()->routeIs('sp-letters.*')">Surat Peringatan</x-nav-item>
|
||||
|
||||
<div class="pt-4 mt-4 border-t border-gray-200">
|
||||
<p class="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Pengaturan</p>
|
||||
</div>
|
||||
<x-nav-item href="{{ route('settings.categories') }}" icon="tag" :active="request()->routeIs('settings.categories*')">Kategori Pelanggaran</x-nav-item>
|
||||
<x-nav-item href="{{ route('settings.violation-types') }}" icon="list" :active="request()->routeIs('settings.violation-types*')">Jenis Pelanggaran</x-nav-item>
|
||||
<x-nav-item href="{{ route('settings.thresholds') }}" icon="chart-bar" :active="request()->routeIs('settings.thresholds*')">Ambang SP</x-nav-item>
|
||||
<x-nav-item href="{{ route('settings.sync') }}" icon="refresh" :active="request()->routeIs('settings.sync*')">Sinkronisasi</x-nav-item>
|
||||
<x-nav-item href="{{ route('users.index') }}" icon="users-cog" :active="request()->routeIs('users.*')">Manajemen User</x-nav-item>
|
||||
<x-nav-item href="{{ route('settings.reset') }}" icon="refresh" :active="request()->routeIs('settings.reset*')">Reset Aplikasi</x-nav-item>
|
||||
<x-nav-item href="{{ route('settings.index') }}" icon="cog" :active="request()->routeIs('settings.index')">Pengaturan</x-nav-item>
|
||||
</nav>
|
||||
|
||||
{{-- Footer --}}
|
||||
<div class="mt-auto px-4 py-3 border-t border-gray-100">
|
||||
<div class="text-center">
|
||||
<p class="text-[10px] text-gray-400">Developed by</p>
|
||||
<a href="https://noctkj.net/" target="_blank" rel="noopener noreferrer"
|
||||
class="text-xs font-bold text-blue-500 hover:text-blue-700 transition block mt-0.5">
|
||||
NOCTKJ.net
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{{-- Overlay for mobile --}}
|
||||
<div @click="sidebarOpen = false" x-show="sidebarOpen" class="fixed inset-0 z-40 bg-gray-600 bg-opacity-50 lg:hidden" style="display: none;"></div>
|
||||
|
||||
{{-- Main Content --}}
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
{{-- Topbar --}}
|
||||
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-4 lg:px-6">
|
||||
<button @click="sidebarOpen = true" class="lg:hidden text-gray-500 hover:text-gray-700">
|
||||
<i class="fa-solid fa-bars text-xl"></i>
|
||||
</button>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
{{-- Notifications --}}
|
||||
<div class="relative" @click.outside="showNotif = false">
|
||||
<button @click="showNotif = !showNotif" class="relative p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg">
|
||||
<i class="fa-solid fa-bell text-lg"></i>
|
||||
<span x-text="notifCount" x-show="notifCount > 0" class="absolute top-0 right-0 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white transform translate-x-1/2 -translate-y-1/2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
|
||||
{{-- Dropdown --}}
|
||||
<div x-show="showNotif" class="absolute right-0 mt-2 w-80 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden z-50" style="display: none;">
|
||||
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Notifikasi</h3>
|
||||
<button @click="markAllRead" class="text-xs text-blue-600 hover:text-blue-800">Tandai semua dibaca</button>
|
||||
</div>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<template x-for="notif in notifications" :key="notif.id">
|
||||
<a :href="notif.action_url || '#'" class="block px-4 py-3 hover:bg-gray-50 border-b border-gray-100 last:border-0">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div :class="'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ' + (notif.color ? 'bg-'+notif.color+'-100' : 'bg-blue-100')">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate" x-text="notif.title"></p>
|
||||
<p class="text-xs text-gray-500 truncate" x-text="notif.body"></p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
<div x-show="notifications.length === 0" class="px-4 py-8 text-center text-sm text-gray-500">
|
||||
Tidak ada notifikasi
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- User Dropdown --}}
|
||||
<div class="relative" @click.outside="showUserMenu = false">
|
||||
<button @click="showUserMenu = !showUserMenu"
|
||||
class="flex items-center gap-3 pl-3 pr-2 py-2 rounded-xl hover:bg-gray-50 transition group">
|
||||
<div class="hidden sm:block text-right">
|
||||
<p class="text-sm font-semibold text-gray-900 group-hover:text-blue-600 transition">{{ auth()->user()->name }}</p>
|
||||
<p class="text-xs text-gray-400 capitalize">{{ auth()->user()->role }}</p>
|
||||
</div>
|
||||
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500 to-sky-400 flex items-center justify-center text-white text-sm font-bold shadow-sm">
|
||||
{{ strtoupper(substr(auth()->user()->name, 0, 1)) }}
|
||||
</div>
|
||||
<svg class="w-3.5 h-3.5 text-gray-400 transition-transform duration-200" :class="{ 'rotate-180': showUserMenu }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{{-- Dropdown --}}
|
||||
<div x-show="showUserMenu"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 mt-2 w-56 bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden z-50" style="display: none;">
|
||||
<div class="px-4 py-3 border-b border-gray-100">
|
||||
<p class="text-sm font-semibold text-gray-900">{{ auth()->user()->name }}</p>
|
||||
<p class="text-xs text-gray-400 capitalize">{{ auth()->user()->role }}</p>
|
||||
</div>
|
||||
<div class="py-1">
|
||||
<a href="{{ route('profile.index') }}"
|
||||
class="flex items-center gap-3 px-4 py-2.5 text-sm text-gray-600 hover:text-blue-600 hover:bg-blue-50 transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
Pengaturan Akun
|
||||
</a>
|
||||
<hr class="mx-3 my-1 border-gray-100">
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-600 hover:text-blue-600 hover:bg-red-50 transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{-- Page Content --}}
|
||||
<main class="flex-1 p-4 lg:p-6">
|
||||
@if (session('success'))
|
||||
<div class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700 flex items-center justify-between">
|
||||
<span>{{ session('success') }}</span>
|
||||
<button onclick="this.parentElement.remove()" class="text-green-500 hover:text-green-700">×</button>
|
||||
</div>
|
||||
@endif
|
||||
@if (session('error'))
|
||||
<div class="mb-4 p-4 bg-red-50 border border-blue-200 rounded-lg text-sm text-red-700 flex items-center justify-between">
|
||||
<span>{{ session('error') }}</span>
|
||||
<button onclick="this.parentElement.remove()" class="text-red-500 hover:text-red-700">×</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@yield('content')
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@stack('scripts')
|
||||
|
||||
{{-- Reverb + Echo for realtime --}}
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('notifications', () => ({
|
||||
init() {
|
||||
this.fetchCount();
|
||||
this.fetchNotifications();
|
||||
this.connectReverb();
|
||||
},
|
||||
fetchCount() {
|
||||
fetch('{{ route("notifications.unread-count") }}')
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.count !== undefined) this.notifCount = d.count; });
|
||||
},
|
||||
fetchNotifications() {
|
||||
fetch('{{ route("notifications.recent") }}')
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.notifications) this.notifications = d.notifications; });
|
||||
},
|
||||
markAllRead() {
|
||||
fetch('{{ route("notifications.read-all") }}', { method: 'POST', headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content } });
|
||||
this.notifications = [];
|
||||
this.notifCount = 0;
|
||||
},
|
||||
connectReverb() {
|
||||
if (typeof Echo === 'undefined') {
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key: import.meta.env.VITE_REVERB_APP_KEY || '"' + REVERB_APP_KEY + '",
|
||||
wsHost: import.meta.env.VITE_REVERB_HOST || '"' + REVERB_HOST + '",
|
||||
wsPort: import.meta.env.VITE_REVERB_PORT ? parseInt(import.meta.env.VITE_REVERB_PORT) : 8080,
|
||||
wssPort: 443,
|
||||
forceTLS: false,
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
});
|
||||
}
|
||||
if (typeof Echo !== 'undefined') {
|
||||
Echo.channel('violations')
|
||||
.listen('.violation.recorded', (e) => {
|
||||
this.notifCount++;
|
||||
this.notifications.unshift({
|
||||
id: e.violation?.id || Date.now(),
|
||||
title: 'Pelanggaran Baru: ' + (e.data?.student_name || ''),
|
||||
body: (e.data?.violation_type || '') + ' (+' + (e.data?.points || 0) + ' poin)',
|
||||
action_url: e.data?.url || '#',
|
||||
color: (e.data?.category_color || '#ef4444').replace('#', ''),
|
||||
});
|
||||
if (this.notifications.length > 20) this.notifications.pop();
|
||||
});
|
||||
} else {
|
||||
// Fallback: poll every 30s if Echo not loaded
|
||||
setInterval(() => { this.fetchCount(); }, 30000);
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,130 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Pengaturan Akun')
|
||||
|
||||
@section('content')
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<nav class="flex items-center space-x-2 text-sm text-gray-400 mb-2">
|
||||
<span class="text-gray-700 font-medium">Pengaturan Akun</span>
|
||||
</nav>
|
||||
<h1 class="text-2xl font-bold text-gray-900 tracking-tight">Pengaturan Akun</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Ubah nama pengguna dan kata sandi</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{{-- Profile Info Sidebar --}}
|
||||
<div class="lg:col-span-1">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 text-center">
|
||||
<div class="w-20 h-20 rounded-2xl bg-gradient-to-br from-blue-500 to-sky-400 flex items-center justify-center text-white text-3xl font-bold shadow-sm mx-auto mb-4">
|
||||
{{ strtoupper(substr($user->name, 0, 1)) }}
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">{{ $user->name }}</h2>
|
||||
<p class="text-sm text-gray-400 capitalize">{{ $user->role }}</p>
|
||||
<div class="mt-4 pt-4 border-t border-gray-100">
|
||||
<div class="text-left space-y-3">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest">Username</p>
|
||||
<p class="text-sm font-medium text-gray-900 mt-0.5">{{ $user->username }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest">Email</p>
|
||||
<p class="text-sm text-gray-700 mt-0.5">{{ $user->email ?? '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest">Bergabung</p>
|
||||
<p class="text-sm text-gray-700 mt-0.5">{{ $user->created_at->format('d M Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Form --}}
|
||||
<div class="lg:col-span-2">
|
||||
<form method="POST" action="{{ route('profile.update') }}" class="bg-white rounded-2xl shadow-sm border border-gray-200">
|
||||
@csrf
|
||||
|
||||
{{-- Ubah Profil --}}
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900">Informasi Profil</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Nama Lengkap</label>
|
||||
<input type="text" name="name" value="{{ old('name', $user->name) }}" required
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
@error('name') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Username</label>
|
||||
<input type="text" name="username" value="{{ old('username', $user->username) }}" required
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
@error('username') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Email</label>
|
||||
<input type="email" name="email" value="{{ old('email', $user->email) }}"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
@error('email') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Ubah Password --}}
|
||||
<div class="px-6 py-4 border-t border-gray-100 border-b border-gray-100">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">Ganti Kata Sandi</h3>
|
||||
<p class="text-xs text-gray-400">Kosongkan jika tidak ingin mengubah</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Kata Sandi Saat Ini</label>
|
||||
<input type="password" name="current_password"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
@error('current_password') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Kata Sandi Baru</label>
|
||||
<input type="password" name="password"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
@error('password') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Konfirmasi Kata Sandi</label>
|
||||
<input type="password" name="password_confirmation"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Submit --}}
|
||||
<div class="px-6 py-4 border-t border-gray-100 bg-gray-50/50 rounded-b-2xl flex items-center justify-end">
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
Simpan Perubahan
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,301 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Kategori Pelanggaran')
|
||||
|
||||
@section('content')
|
||||
<div x-data="categoryManager()" class="">
|
||||
{{-- Header --}}
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Kategori Pelanggaran</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Kelompokkan jenis pelanggaran berdasarkan tingkat keparahan</p>
|
||||
</div>
|
||||
<button @click="openCreate()"
|
||||
class="inline-flex items-center px-4 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition shadow-sm">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Tambah Kategori
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Cards Grid --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
@forelse($categories as $cat)
|
||||
@php
|
||||
$typeCount = $cat->violationTypes()->count();
|
||||
@endphp
|
||||
<div class="group bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md transition-all duration-200 @if(!$cat->is_active) opacity-60 @endif">
|
||||
{{-- Color bar --}}
|
||||
<div class="h-2 rounded-t-xl" style="background-color: {{ $cat->color }}"></div>
|
||||
|
||||
<div class="p-5">
|
||||
{{-- Header row --}}
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="w-4 h-4 rounded-full flex-shrink-0 ring-2 ring-offset-1" style="background-color: {{ $cat->color }}; --tw-ring-color: {{ $cat->color }}40"></span>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">{{ $cat->name }}</h3>
|
||||
@if($cat->description)
|
||||
<p class="text-xs text-gray-500 mt-0.5">{{ $cat->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if(!$cat->is_active)
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-500 rounded-full">Nonaktif</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Stats row --}}
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500 mb-4">
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||
</svg>
|
||||
<span>{{ $typeCount }} jenis</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path>
|
||||
</svg>
|
||||
<span>Urutan #{{ $cat->sort_order }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Actions --}}
|
||||
<div class="flex items-center justify-between pt-3 border-t border-gray-100">
|
||||
<div class="flex space-x-2">
|
||||
<button @click="openEdit({{ $cat->id }}, '{{ $cat->name }}', '{{ $cat->color }}', '{{ $cat->description }}', {{ $cat->sort_order }}, {{ json_encode(!$cat->is_active) }})"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-gray-900 transition">
|
||||
<svg class="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
@if($cat->is_active)
|
||||
<form action="{{ route('settings.categories.update', $cat->id) }}" method="POST" class="inline">
|
||||
@csrf @method('PUT')
|
||||
<input type="hidden" name="name" value="{{ $cat->name }}">
|
||||
<input type="hidden" name="color" value="{{ $cat->color }}">
|
||||
<input type="hidden" name="description" value="{{ $cat->description }}">
|
||||
<input type="hidden" name="sort_order" value="{{ $cat->sort_order }}">
|
||||
<input type="hidden" name="is_active" value="0">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-yellow-600 bg-yellow-50 border border-yellow-200 rounded-lg hover:bg-yellow-100 transition">
|
||||
<svg class="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
|
||||
</svg>
|
||||
Nonaktifkan
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<form action="{{ route('settings.categories.update', $cat->id) }}" method="POST" class="inline">
|
||||
@csrf @method('PUT')
|
||||
<input type="hidden" name="name" value="{{ $cat->name }}">
|
||||
<input type="hidden" name="color" value="{{ $cat->color }}">
|
||||
<input type="hidden" name="description" value="{{ $cat->description }}">
|
||||
<input type="hidden" name="sort_order" value="{{ $cat->sort_order }}">
|
||||
<input type="hidden" name="is_active" value="1">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-green-600 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition">
|
||||
<svg class="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Aktifkan
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@if($typeCount === 0)
|
||||
<form action="{{ route('settings.categories.destroy', $cat->id) }}" method="POST"
|
||||
x-data x-on:submit.prevent="if(await window.confirmSwal({text:'Hapus kategori ini?'})) $el.submit()"">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-600 bg-red-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition">
|
||||
<svg class="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
Hapus
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="col-span-full">
|
||||
<div class="text-center py-12 bg-white rounded-xl border border-gray-200">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
|
||||
</svg>
|
||||
<p class="text-sm text-gray-500">Belum ada kategori pelanggaran.</p>
|
||||
<button @click="openCreate()" class="mt-3 text-sm font-medium text-blue-600 hover:text-blue-800">
|
||||
+ Tambah kategori pertama
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- Modal Create/Edit --}}
|
||||
<div x-show="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" style="display: none;">
|
||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
|
||||
{{-- Overlay --}}
|
||||
<div x-show="modalOpen" @click="modalOpen = false" class="fixed inset-0 bg-gray-500 bg-opacity-50 transition-opacity"></div>
|
||||
|
||||
{{-- Panel --}}
|
||||
<div x-show="modalOpen" @click.away="modalOpen = false"
|
||||
class="relative inline-block align-bottom bg-white rounded-2xl shadow-xl border border-gray-200 text-left overflow-hidden transform transition-all sm:align-middle sm:max-w-lg sm:w-full">
|
||||
{{-- Header --}}
|
||||
<div class="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-lg flex items-center justify-center" :class="isEditing ? 'bg-blue-100' : 'bg-blue-100'">
|
||||
<svg x-show="!isEditing" class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
<svg x-show="isEditing" class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900" x-text="isEditing ? 'Edit Kategori' : 'Tambah Kategori'"></h3>
|
||||
<p class="text-sm text-gray-500" x-text="isEditing ? 'Ubah detail kategori pelanggaran' : 'Buat kategori pelanggaran baru'"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="modalOpen = false" class="text-gray-400 hover:text-gray-600 transition">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Form --}}
|
||||
<form :action="isEditing ? `/settings/categories/${editId}` : '{{ route('settings.categories.store') }}'"
|
||||
method="POST" class="p-6 space-y-5">
|
||||
@csrf
|
||||
<input type="hidden" name="_method" :value="isEditing ? 'PUT' : 'POST'">
|
||||
|
||||
{{-- Nama --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Nama Kategori</label>
|
||||
<input type="text" x-model="formName" name="name" required
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition"
|
||||
placeholder="Contoh: Ringan, Sedang, Berat">
|
||||
</div>
|
||||
|
||||
{{-- Color Picker --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Warna</label>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="relative">
|
||||
<input type="color" x-model="formColor" name="color"
|
||||
class="w-12 h-12 rounded-xl border border-gray-300 cursor-pointer p-0.5">
|
||||
</div>
|
||||
<input type="text" x-model="formColor" name="color"
|
||||
class="flex-1 px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition"
|
||||
placeholder="#22c55e">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl border border-gray-200"
|
||||
:style="{ backgroundColor: formColor }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Sort Order --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Urutan Tampil</label>
|
||||
<input type="number" x-model="formSort" name="sort_order" min="0"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition"
|
||||
placeholder="0">
|
||||
</div>
|
||||
|
||||
{{-- Description --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Deskripsi <span class="text-gray-400 font-normal">(opsional)</span></label>
|
||||
<textarea x-model="formDesc" name="description" rows="2"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition resize-none"
|
||||
placeholder="Penjelasan singkat tentang kategori ini"></textarea>
|
||||
</div>
|
||||
|
||||
{{-- Status toggle for edit --}}
|
||||
<div x-show="isEditing" class="flex items-center justify-between p-4 bg-gray-50 rounded-xl border border-gray-100">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Status Aktif</p>
|
||||
<p class="text-xs text-gray-500">Nonaktifkan untuk menyembunyikan kategori ini</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" x-model="formActive" name="is_active" value="1" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:after:translate-x-full peer-checked:bg-blue-600 after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{-- Preview --}}
|
||||
<div class="p-4 bg-gray-50 rounded-xl border border-gray-100">
|
||||
<p class="text-xs font-medium text-gray-500 mb-2">Pratinjau</p>
|
||||
<div class="flex items-center space-x-2 p-2.5 bg-white rounded-lg border border-gray-200">
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0" :style="{ backgroundColor: formColor }"></span>
|
||||
<span class="text-sm font-medium text-gray-900" x-text="formName || 'Nama Kategori'"></span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs rounded-full font-medium"
|
||||
:style="{
|
||||
backgroundColor: formColor + '20',
|
||||
color: formColor
|
||||
}"
|
||||
x-text="formDesc || 'Deskripsi'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Buttons --}}
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<button type="button" @click="modalOpen = false"
|
||||
class="px-5 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-xl hover:bg-gray-50 transition">
|
||||
Batal
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-5 py-2.5 text-sm font-medium text-white rounded-xl transition shadow-sm"
|
||||
:class="isEditing ? 'bg-blue-600 hover:bg-blue-700' : 'bg-blue-600 hover:bg-blue-700'"
|
||||
x-text="isEditing ? 'Simpan Perubahan' : 'Tambah Kategori'">
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function categoryManager() {
|
||||
return {
|
||||
modalOpen: false,
|
||||
isEditing: false,
|
||||
editId: null,
|
||||
formName: '',
|
||||
formColor: '#22c55e',
|
||||
formDesc: '',
|
||||
formSort: 0,
|
||||
formActive: true,
|
||||
|
||||
openCreate() {
|
||||
this.isEditing = false;
|
||||
this.editId = null;
|
||||
this.formName = '';
|
||||
this.formColor = '#22c55e';
|
||||
this.formDesc = '';
|
||||
this.formSort = 0;
|
||||
this.formActive = true;
|
||||
this.modalOpen = true;
|
||||
},
|
||||
|
||||
openEdit(id, name, color, desc, sort, inactive) {
|
||||
this.isEditing = true;
|
||||
this.editId = id;
|
||||
this.formName = name;
|
||||
this.formColor = color;
|
||||
this.formDesc = desc || '';
|
||||
this.formSort = sort;
|
||||
this.formActive = !inactive;
|
||||
this.modalOpen = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@endsection
|
||||
@@ -0,0 +1,86 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Pengaturan')
|
||||
|
||||
@section('content')
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Pengaturan Sekolah</h1>
|
||||
<p class="text-sm text-gray-500">Informasi sekolah untuk kop surat</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<form action="{{ route('settings.update') }}" method="POST" enctype="multipart/form-data" class="p-6 space-y-4">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Nama Aplikasi</label>
|
||||
<input type="text" name="app_name" value="{{ old('app_name', $settings->get('app_name')?->value ?? 'Aplikasi Ketertiban') }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="text-xs text-gray-400 mt-1">Nama yang tampil di sidebar, login page, dan tab browser.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Nama Sekolah</label>
|
||||
<input type="text" name="school_name" value="{{ old('school_name', $settings->get('school_name')?->value ?? '') }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Alamat Sekolah</label>
|
||||
<textarea name="school_address" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">{{ old('school_address', $settings->get('school_address')?->value ?? '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">No. Telepon</label>
|
||||
<input type="text" name="school_phone" value="{{ old('school_phone', $settings->get('school_phone')?->value ?? '') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Logo Sekolah</label>
|
||||
@if($settings->get('school_logo')?->value)
|
||||
<div class="mb-2">
|
||||
<img src="{{ asset('storage/' . $settings->get('school_logo')->value) }}" class="h-16 object-contain">
|
||||
</div>
|
||||
@endif
|
||||
<input type="file" name="school_logo" accept="image/*"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm file:mr-3 file:py-1 file:px-3 file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-200">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Background Halaman Login</label>
|
||||
@if($settings->get('login_background')?->value)
|
||||
<div class="mb-2">
|
||||
<img src="{{ asset('storage/' . $settings->get('login_background')->value) }}" class="h-32 w-full object-cover rounded-lg border border-gray-200">
|
||||
</div>
|
||||
@endif
|
||||
<input type="file" name="login_background" accept="image/*"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm file:mr-3 file:py-1 file:px-3 file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||
<p class="text-xs text-gray-400 mt-1.5">Dimensi ideal: <strong class="text-gray-500">1200 × 800 px</strong> atau <strong class="text-gray-500">3:2</strong> (landscape). Maksimal <strong class="text-gray-500">2 MB</strong>. Format JPG/PNG/WebP.</p>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-200">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Nama Kepala Sekolah</label>
|
||||
<input type="text" name="kepala_sekolah_name" value="{{ old('kepala_sekolah_name', $settings->get('kepala_sekolah_name')?->value ?? '') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">NIP Kepala Sekolah</label>
|
||||
<input type="text" name="kepala_sekolah_nip" value="{{ old('kepala_sekolah_nip', $settings->get('kepala_sekolah_nip')?->value ?? '') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full px-4 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition">
|
||||
Simpan Pengaturan
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,115 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Reset Aplikasi')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-2xl mx-auto">
|
||||
{{-- Header --}}
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center">
|
||||
<i class="fa-solid fa-rotate-left text-red-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Reset Aplikasi</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Kembalikan aplikasi ke kondisi awal pabrik</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Warning Card --}}
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-5 mb-6">
|
||||
<div class="flex items-start space-x-3">
|
||||
<i class="fa-solid fa-triangle-exclamation text-red-600 mt-0.5"></i>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-red-800">⚠️ Peringatan Keras!</h3>
|
||||
<p class="text-sm text-red-700 mt-1">Semua data berikut akan <strong>DIHAPUS PERMANEN</strong>:</p>
|
||||
<ul class="mt-2 space-y-1 text-sm text-red-700">
|
||||
<li>✕ Pelanggaran & foto bukti</li>
|
||||
<li>✕ Surat Peringatan & notifikasi</li>
|
||||
<li>✕ Data siswa & kelas (hasil sinkron)</li>
|
||||
<li>✕ Kategori, jenis pelanggaran & ambang SP</li>
|
||||
<li>✕ User lain (selain admin utama)</li>
|
||||
<li>✕ Pengaturan sekolah</li>
|
||||
<li>✕ File foto yang sudah diupload</li>
|
||||
</ul>
|
||||
<p class="text-sm text-green-700 mt-3 font-medium">Yang <u>tetap</u> tersimpan:</p>
|
||||
<ul class="mt-1 space-y-0.5 text-sm text-green-700">
|
||||
<li>✓ Akun super admin (username: <strong>admin</strong>)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Stats Cards --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mb-6">
|
||||
<div class="px-5 py-4 border-b border-gray-100">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Data yang akan terhapus</h3>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div class="p-4 bg-red-50 rounded-xl border border-red-100 text-center">
|
||||
<p class="text-xs text-red-600 font-medium uppercase tracking-wider">Pelanggaran</p>
|
||||
<p class="text-2xl font-bold text-red-700 mt-1">{{ $stats['violations'] }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-orange-50 rounded-xl border border-orange-100 text-center">
|
||||
<p class="text-xs text-orange-600 font-medium uppercase tracking-wider">Foto</p>
|
||||
<p class="text-2xl font-bold text-orange-700 mt-1">{{ $stats['evidences'] }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-yellow-50 rounded-xl border border-yellow-100 text-center">
|
||||
<p class="text-xs text-yellow-600 font-medium uppercase tracking-wider">SP</p>
|
||||
<p class="text-2xl font-bold text-yellow-700 mt-1">{{ $stats['sp_letters'] }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-red-50 rounded-xl border border-red-100 text-center">
|
||||
<p class="text-xs text-red-600 font-medium uppercase tracking-wider">Notifikasi</p>
|
||||
<p class="text-2xl font-bold text-red-700 mt-1">{{ $stats['notifications'] }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-purple-50 rounded-xl border border-purple-100 text-center">
|
||||
<p class="text-xs text-purple-600 font-medium uppercase tracking-wider">Siswa</p>
|
||||
<p class="text-2xl font-bold text-purple-700 mt-1">{{ $stats['students'] }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-indigo-50 rounded-xl border border-indigo-100 text-center">
|
||||
<p class="text-xs text-indigo-600 font-medium uppercase tracking-wider">Kelas</p>
|
||||
<p class="text-2xl font-bold text-indigo-700 mt-1">{{ $stats['classes'] }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-teal-50 rounded-xl border border-teal-100 text-center">
|
||||
<p class="text-xs text-teal-600 font-medium uppercase tracking-wider">Settings</p>
|
||||
<p class="text-2xl font-bold text-teal-700 mt-1">{{ $stats['settings'] }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 rounded-xl border border-gray-100 text-center">
|
||||
<p class="text-xs text-gray-600 font-medium uppercase tracking-wider">User Lain</p>
|
||||
<p class="text-2xl font-bold text-gray-700 mt-1">{{ $stats['users_other'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Confirm Form --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<form action="{{ route('settings.reset.run') }}" method="POST" onsubmit="return confirm('⚠️ YAKIN INGIN RESET? Semua data kecuali akun admin akan hilang permanen!')">
|
||||
@csrf
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<i class="fa-solid fa-lock mr-1.5 text-gray-400"></i>
|
||||
Masukkan Password Admin untuk Konfirmasi
|
||||
</label>
|
||||
<input type="password" name="confirm_password" required
|
||||
placeholder="Password akun admin saat ini"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 py-4 bg-gray-50 border-t border-gray-100 rounded-b-xl flex justify-between items-center">
|
||||
<a href="{{ route('dashboard') }}" class="text-sm text-gray-500 hover:text-gray-700 transition">
|
||||
<i class="fa-solid fa-arrow-left mr-1"></i> Kembali
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-6 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm">
|
||||
<i class="fa-solid fa-bomb mr-2"></i>
|
||||
Reset Aplikasi
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,87 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Sinkronisasi Data Siswa')
|
||||
|
||||
@section('content')
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Sinkronisasi Data Siswa</h1>
|
||||
<p class="text-sm text-gray-500">Sinkronkan data siswa dari Database Kesiswaan</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<form action="{{ route('settings.sync.run') }}" method="POST" class="p-6 space-y-5">
|
||||
@csrf
|
||||
|
||||
{{-- URL Kesiswaan --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">URL Database Kesiswaan</label>
|
||||
<input type="url" name="base_url" value="{{ old('base_url', $baseUrl) }}" required
|
||||
placeholder="http://database-kesiswaan.local"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
@error('base_url') <p class="mt-1 text-sm text-blue-600">{{ $message }}</p> @enderror
|
||||
@if($baseUrl)
|
||||
<p class="mt-1.5 text-xs text-green-600 flex items-center">
|
||||
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
URL tersimpan: {{ $baseUrl }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Token --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Token Sinkronisasi</label>
|
||||
<div class="relative">
|
||||
<input type="password" name="token" value="{{ old('token', $hasToken ? '********' : '') }}"
|
||||
placeholder="Masukkan token akses"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition pr-24"
|
||||
id="tokenInput"
|
||||
@if($hasToken) readonly onfocus="this.removeAttribute('readonly'); this.value=''; this.type='password';" @endif>
|
||||
@if($hasToken)
|
||||
<button type="button" onclick="document.getElementById('tokenInput').removeAttribute('readonly'); document.getElementById('tokenInput').value=''; document.getElementById('tokenInput').focus();"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1 text-xs font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition">
|
||||
Ganti
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@error('token') <p class="mt-1 text-sm text-blue-600">{{ $message }}</p> @enderror
|
||||
@if($hasToken)
|
||||
<p class="mt-1.5 text-xs text-green-600 flex items-center">
|
||||
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
Token sudah tersimpan. Klik <strong>"Ganti"</strong> untuk memperbarui.
|
||||
</p>
|
||||
@else
|
||||
<p class="mt-1 text-xs text-gray-500">Token didapatkan dari menu Pengaturan → Sync Tokens di Database Kesiswaan.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Info siswa --}}
|
||||
@if($studentCount > 0)
|
||||
<div class="p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg class="w-5 h-5 text-blue-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<div class="text-sm text-blue-700">
|
||||
<strong>{{ $studentCount }}</strong> siswa sudah tersinkron. Sinkronisasi ulang akan memperbarui data yang sudah ada.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Tombol --}}
|
||||
<button type="submit"
|
||||
class="w-full px-4 py-3 text-sm font-medium text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm flex items-center justify-center space-x-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
<span>Jalankan Sinkronisasi</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,54 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Ambang Surat Peringatan')
|
||||
|
||||
@section('content')
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Ambang Surat Peringatan</h1>
|
||||
<p class="text-sm text-gray-500">Atur batas poin untuk setiap tingkat Surat Peringatan</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<form action="{{ route('settings.thresholds.update') }}" method="POST" class="p-6 space-y-6">
|
||||
@csrf @method('PUT')
|
||||
|
||||
@foreach($thresholds as $t)
|
||||
<input type="hidden" name="thresholds[{{ $loop->index }}][id]" value="{{ $t->id }}">
|
||||
<div class="p-4 rounded-lg border-2" style="border-color: {{ $t->color }}20; background-color: {{ $t->color }}08">
|
||||
<div class="flex items-center space-x-2 mb-3">
|
||||
<span class="w-3 h-3 rounded-full" style="background-color: {{ $t->color }}"></span>
|
||||
<h3 class="text-lg font-semibold" style="color: {{ $t->color }}">{{ $t->name }}</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Nama</label>
|
||||
<input type="text" name="thresholds[{{ $loop->index }}][name]" value="{{ $t->name }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Minimal Poin</label>
|
||||
<input type="number" name="thresholds[{{ $loop->index }}][min_points]" value="{{ $t->min_points }}" min="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Maksimal Poin (kosongkan = tak terbatas)</label>
|
||||
<input type="number" name="thresholds[{{ $loop->index }}][max_points]" value="{{ $t->max_points }}" min="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Deskripsi</label>
|
||||
<input type="text" name="thresholds[{{ $loop->index }}][default_description]" value="{{ $t->default_description }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<button type="submit" class="w-full px-4 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition">
|
||||
Simpan Perubahan
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,361 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Jenis Pelanggaran')
|
||||
|
||||
@section('content')
|
||||
<div x-data="violationTypeManager()">
|
||||
{{-- Header --}}
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Jenis Pelanggaran</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Daftar jenis pelanggaran beserta poin dan sanksi default</p>
|
||||
</div>
|
||||
<button @click="openCreate()"
|
||||
class="inline-flex items-center px-4 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition shadow-sm">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Tambah Jenis
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Filter & Search Bar --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mb-6">
|
||||
<form method="GET" class="p-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input type="text" name="search" value="{{ request('search') }}"
|
||||
placeholder="Cari jenis pelanggaran..."
|
||||
class="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
<select name="category_id"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
<option value="">Semua Kategori</option>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}" @selected(request('category_id') == $cat->id)>
|
||||
{{ $cat->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<div class="flex space-x-2">
|
||||
<button type="submit"
|
||||
class="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition">
|
||||
Filter
|
||||
</button>
|
||||
@if(request()->anyFilled(['search','category_id']))
|
||||
<a href="{{ route('settings.violation-types') }}"
|
||||
class="px-4 py-2.5 text-sm font-medium text-gray-600 bg-white border border-gray-300 rounded-xl hover:bg-gray-50 transition">
|
||||
Reset
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Table --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr class="bg-gray-50">
|
||||
<th class="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Kategori</th>
|
||||
<th class="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Nama Pelanggaran</th>
|
||||
<th class="px-5 py-3.5 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">Poin</th>
|
||||
<th class="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Sanksi Default</th>
|
||||
<th class="px-5 py-3.5 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-5 py-3.5 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($types as $type)
|
||||
<tr class="hover:bg-gray-50 transition {{ !$type->is_active ? 'opacity-60' : '' }}">
|
||||
{{-- Kategori --}}
|
||||
<td class="px-5 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: {{ $type->category?->color ?? '#6b7280' }}"></span>
|
||||
<span class="text-sm font-medium" style="color: {{ $type->category?->color ?? '#6b7280' }}">
|
||||
{{ $type->category?->name ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
{{-- Nama --}}
|
||||
<td class="px-5 py-4">
|
||||
<div class="text-sm font-medium text-gray-900 {{ !$type->is_active ? 'line-through' : '' }}">
|
||||
{{ $type->name }}
|
||||
</div>
|
||||
</td>
|
||||
{{-- Poin --}}
|
||||
<td class="px-5 py-4 text-center whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-1 text-sm font-bold rounded-lg
|
||||
@if($type->points >= 50) bg-blue-100 text-red-700
|
||||
@elseif($type->points >= 15) bg-yellow-100 text-yellow-700
|
||||
@else bg-green-100 text-green-700
|
||||
@endif">
|
||||
+{{ $type->points }}
|
||||
</span>
|
||||
</td>
|
||||
{{-- Sanksi --}}
|
||||
<td class="px-5 py-4">
|
||||
<span class="text-sm text-gray-600 {{ !$type->is_active ? 'line-through' : '' }}">
|
||||
{{ $type->default_sanction ?: '-' }}
|
||||
</span>
|
||||
</td>
|
||||
{{-- Status --}}
|
||||
<td class="px-5 py-4 text-center whitespace-nowrap">
|
||||
@if($type->is_active)
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||
<span class="w-1.5 h-1.5 bg-green-500 rounded-full mr-1.5"></span>
|
||||
Aktif
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-500 rounded-full">
|
||||
<span class="w-1.5 h-1.5 bg-gray-400 rounded-full mr-1.5"></span>
|
||||
Nonaktif
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
{{-- Aksi --}}
|
||||
<td class="px-5 py-4 text-right whitespace-nowrap">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<button @click="openEdit({{ $type->id }}, {{ $type->category_id }}, '{{ addslashes($type->name) }}', {{ $type->points }}, '{{ addslashes($type->default_sanction ?? '') }}', {{ $type->is_active ? 'true' : 'false' }})"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition">
|
||||
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
@if($type->is_active)
|
||||
<form action="{{ route('settings.violation-types.update', $type->id) }}" method="POST" class="inline">
|
||||
@csrf @method('PUT')
|
||||
<input type="hidden" name="category_id" value="{{ $type->category_id }}">
|
||||
<input type="hidden" name="name" value="{{ $type->name }}">
|
||||
<input type="hidden" name="points" value="{{ $type->points }}">
|
||||
<input type="hidden" name="default_sanction" value="{{ $type->default_sanction }}">
|
||||
<input type="hidden" name="is_active" value="0">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-yellow-600 bg-yellow-50 border border-yellow-200 rounded-lg hover:bg-yellow-100 transition">
|
||||
Nonaktifkan
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<form action="{{ route('settings.violation-types.update', $type->id) }}" method="POST" class="inline">
|
||||
@csrf @method('PUT')
|
||||
<input type="hidden" name="category_id" value="{{ $type->category_id }}">
|
||||
<input type="hidden" name="name" value="{{ $type->name }}">
|
||||
<input type="hidden" name="points" value="{{ $type->points }}">
|
||||
<input type="hidden" name="default_sanction" value="{{ $type->default_sanction }}">
|
||||
<input type="hidden" name="is_active" value="1">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-green-600 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition">
|
||||
Aktifkan
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
<form action="{{ route('settings.violation-types.destroy', $type->id) }}" method="POST" class="inline"
|
||||
x-data x-on:submit.prevent="if(await window.confirmSwal({text:'Hapus jenis pelanggaran ini?'})) $el.submit()"">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-600 bg-red-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="px-5 py-16 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
<p class="text-sm text-gray-500 mb-1">Tidak ada jenis pelanggaran ditemukan</p>
|
||||
<p class="text-xs text-gray-400">Coba ubah filter atau tambah jenis pelanggaran baru</p>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
@if($types->hasPages())
|
||||
<div class="px-5 py-3 border-t border-gray-100 bg-gray-50">
|
||||
{{ $types->appends(request()->query())->links() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Summary --}}
|
||||
<div class="px-5 py-3 border-t border-gray-100 bg-gray-50 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>Menampilkan {{ $types->firstItem() ?? 0 }}–{{ $types->lastItem() ?? 0 }} dari {{ $types->total() }} jenis</span>
|
||||
<span class="font-medium">{{ $types->total() }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Modal Tambah / Edit --}}
|
||||
<div x-show="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" style="display: none;">
|
||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
|
||||
<div x-show="modalOpen" @click="modalOpen = false" class="fixed inset-0 bg-gray-500 bg-opacity-50 transition-opacity"></div>
|
||||
|
||||
<div x-show="modalOpen" @click.away="modalOpen = false"
|
||||
class="relative inline-block align-bottom bg-white rounded-2xl shadow-xl border border-gray-200 text-left overflow-hidden transform transition-all sm:align-middle sm:max-w-xl sm:w-full">
|
||||
{{-- Header --}}
|
||||
<div class="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-lg flex items-center justify-center" :class="isEditing ? 'bg-blue-100' : 'bg-blue-100'">
|
||||
<svg x-show="!isEditing" class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
<svg x-show="isEditing" class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900" x-text="isEditing ? 'Edit Jenis Pelanggaran' : 'Tambah Jenis Pelanggaran'"></h3>
|
||||
<p class="text-sm text-gray-500" x-text="isEditing ? 'Ubah detail jenis pelanggaran' : 'Buat jenis pelanggaran baru'"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="modalOpen = false" class="text-gray-400 hover:text-gray-600 transition">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Form --}}
|
||||
<form :action="isEditing ? `/settings/violation-types/${editId}` : '{{ route('settings.violation-types.store') }}'"
|
||||
method="POST" class="p-6 space-y-5">
|
||||
@csrf
|
||||
<input type="hidden" name="_method" :value="isEditing ? 'PUT' : 'POST'">
|
||||
|
||||
{{-- Kategori --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Kategori <span class="text-red-500">*</span></label>
|
||||
<select x-model="formCategory" @change="updateCategoryColor(formCategory)" name="category_id" required
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
<option value="">-- Pilih Kategori --</option>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}">{{ $cat->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Nama --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Nama Pelanggaran <span class="text-red-500">*</span></label>
|
||||
<input type="text" x-model="formName" name="name" required
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition"
|
||||
placeholder="Contoh: Terlambat datang ke sekolah">
|
||||
</div>
|
||||
|
||||
{{-- Poin + Sanksi --}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Poin <span class="text-red-500">*</span></label>
|
||||
<input type="number" x-model="formPoints" name="points" required min="0" max="500"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Sanksi Default</label>
|
||||
<input type="text" x-model="formSanction" name="default_sanction"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition"
|
||||
placeholder="Contoh: Teguran lisan">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Status toggle (edit only) --}}
|
||||
<div x-show="isEditing" class="flex items-center justify-between p-4 bg-gray-50 rounded-xl border border-gray-100">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Status Aktif</p>
|
||||
<p class="text-xs text-gray-500">Nonaktifkan untuk menyembunyikan jenis pelanggaran ini</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" x-model="formActive" name="is_active" value="1" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:after:translate-x-full peer-checked:bg-blue-600 after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{-- Preview --}}
|
||||
<div class="p-4 bg-gray-50 rounded-xl border border-gray-100">
|
||||
<p class="text-xs font-medium text-gray-500 mb-2">Pratinjau</p>
|
||||
<div class="flex items-center justify-between p-3 bg-white rounded-lg border border-gray-200">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0"
|
||||
:style="{ backgroundColor: categoryColor }"></span>
|
||||
<span class="text-sm font-medium text-gray-900" x-text="formName || 'Nama Pelanggaran'"></span>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-blue-600" x-text="'+' + (formPoints || 0)"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1.5" x-text="formSanction ? 'Sanksi: ' + formSanction : ''"></p>
|
||||
</div>
|
||||
|
||||
{{-- Buttons --}}
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<button type="button" @click="modalOpen = false"
|
||||
class="px-5 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-xl hover:bg-gray-50 transition">
|
||||
Batal
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-5 py-2.5 text-sm font-medium text-white rounded-xl transition shadow-sm"
|
||||
:class="isEditing ? 'bg-blue-600 hover:bg-blue-700' : 'bg-blue-600 hover:bg-blue-700'"
|
||||
x-text="isEditing ? 'Simpan Perubahan' : 'Tambah Jenis'">
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function violationTypeManager() {
|
||||
return {
|
||||
modalOpen: false,
|
||||
isEditing: false,
|
||||
editId: null,
|
||||
formCategory: '',
|
||||
formName: '',
|
||||
formPoints: 0,
|
||||
formSanction: '',
|
||||
formActive: true,
|
||||
categoryColor: '#6b7280',
|
||||
|
||||
openCreate() {
|
||||
this.isEditing = false;
|
||||
this.editId = null;
|
||||
this.formCategory = '';
|
||||
this.formName = '';
|
||||
this.formPoints = 0;
|
||||
this.formSanction = '';
|
||||
this.formActive = true;
|
||||
this.categoryColor = '#6b7280';
|
||||
this.modalOpen = true;
|
||||
},
|
||||
|
||||
openEdit(id, catId, name, points, sanction, active) {
|
||||
this.isEditing = true;
|
||||
this.editId = id;
|
||||
this.formCategory = catId;
|
||||
this.formName = name;
|
||||
this.formPoints = points;
|
||||
this.formSanction = sanction;
|
||||
this.formActive = active;
|
||||
this.updateCategoryColor(catId);
|
||||
this.modalOpen = true;
|
||||
},
|
||||
|
||||
updateCategoryColor(catId) {
|
||||
const colors = @json($categories->pluck('color', 'id'));
|
||||
this.categoryColor = colors[catId] || '#6b7280';
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@endsection
|
||||
@@ -0,0 +1,67 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Surat Peringatan')
|
||||
|
||||
@section('content')
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Surat Peringatan</h1>
|
||||
<p class="text-sm text-gray-500">Daftar surat peringatan siswa</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">No. Surat</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Siswa</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Jenis SP</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Poin</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Tanggal</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($letters as $sp)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm font-mono text-gray-700">{{ $sp->letter_number }}</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ $sp->student->full_name ?? '-' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex px-2 py-1 text-xs font-medium rounded-full" style="color: {{ $sp->spThreshold->color ?? '#ef4444' }}; background-color: {{ $sp->spThreshold->color ?? '#ef4444' }}20;">
|
||||
{{ $sp->spThreshold->name ?? '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-gray-700">{{ $sp->total_points_at_time }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@php
|
||||
$statusColors = ['draft' => 'yellow', 'printed' => 'blue', 'signed' => 'green', 'delivered' => 'purple'];
|
||||
$color = $statusColors[$sp->status] ?? 'gray';
|
||||
@endphp
|
||||
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-{{ $color }}-100 text-{{ $color }}-800 capitalize">
|
||||
{{ $sp->status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{ $sp->created_at->format('d/m/Y') }}</td>
|
||||
<td class="px-4 py-3 text-right space-x-2">
|
||||
<a href="{{ route('sp-letters.show', $sp->id) }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium">Detail</a>
|
||||
<a href="{{ route('sp-letters.print', $sp->id) }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium" target="_blank">Cetak</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-12 text-center text-gray-500">
|
||||
<p>Belum ada surat peringatan yang digenerate</p>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($letters->hasPages())
|
||||
<div class="px-4 py-3 border-t border-gray-100">
|
||||
{{ $letters->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Cetak SP - {{ $spLetter->student->full_name }}</title>
|
||||
<style>
|
||||
@page { margin: 2cm; size: A4; }
|
||||
body { font-family: 'Times New Roman', Times, serif; font-size: 12pt; line-height: 1.5; color: #000; }
|
||||
.kop-surat { text-align: center; margin-bottom: 30px; }
|
||||
.kop-surat h1 { font-size: 16pt; font-weight: bold; margin: 0; text-transform: uppercase; }
|
||||
.kop-surat p { font-size: 11pt; margin: 2px 0; }
|
||||
.kop-surat .line { border-top: 2px solid #000; margin-top: 8px; }
|
||||
.kop-surat .sub-line { border-top: 1px solid #000; margin-top: 2px; }
|
||||
.letter-number { text-align: center; margin: 25px 0; font-weight: bold; font-size: 13pt; }
|
||||
.to-title { margin-bottom: 15px; }
|
||||
.body-text { text-align: justify; margin-bottom: 20px; }
|
||||
.violation-list { margin: 15px 0 15px 20px; }
|
||||
.violation-list li { margin-bottom: 3px; }
|
||||
.signature { margin-top: 50px; }
|
||||
.signature .date { text-align: center; margin-bottom: 5px; }
|
||||
.signature .title { text-align: center; margin-bottom: 60px; }
|
||||
.signature .name { text-align: center; font-weight: bold; }
|
||||
.signature .nip { text-align: center; font-size: 11pt; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="kop-surat">
|
||||
<h1>{{ $school['name'] }}</h1>
|
||||
<p>{{ $school['address'] }}</p>
|
||||
<p>Telp: {{ $school['phone'] }}</p>
|
||||
<div class="line"></div>
|
||||
<div class="sub-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="letter-number">
|
||||
<p>SURAT PERINGATAN</p>
|
||||
<p>Nomor: {{ $spLetter->letter_number }}</p>
|
||||
</div>
|
||||
|
||||
<div class="to-title">
|
||||
<p>Kepada Yth.</p>
|
||||
<p><strong>Saudara/i {{ $spLetter->student->full_name }}</strong></p>
|
||||
<p>Kelas: {{ $spLetter->student->class_name ?? '-' }}</p>
|
||||
<p>NISN: {{ $spLetter->student->nisn }}</p>
|
||||
<p>di tempat</p>
|
||||
</div>
|
||||
|
||||
<div class="body-text">
|
||||
<p>Berdasarkan pencatatan pelanggaran yang telah dilakukan, dengan ini kami menyampaikan Surat Peringatan {{ $spLetter->spThreshold->name }} kepada Saudara/i dengan rincian sebagai berikut:</p>
|
||||
</div>
|
||||
|
||||
<div class="body-text">
|
||||
<p>Total akumulasi poin pelanggaran: <strong>{{ $spLetter->total_points_at_time }} poin</strong></p>
|
||||
<p>Daftar pelanggaran:</p>
|
||||
<ul class="violation-list">
|
||||
@php $violations = is_array($spLetter->violations_included) ? $spLetter->violations_included : json_decode($spLetter->violations_included, true); @endphp
|
||||
@if($violations)
|
||||
@foreach($violations as $v)
|
||||
<li>{{ $v['violation_date'] ?? '-' }} - {{ $v['description'] ?? '-' }} ({{ $v['points'] ?? 0 }} poin)</li>
|
||||
@endforeach
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="body-text">
|
||||
<p>Dengan adanya Surat Peringatan ini, Saudara/i diharapkan dapat memperbaiki sikap dan perilaku serta tidak mengulangi pelanggaran yang telah dilakukan. Apabila Saudara/i kembali melakukan pelanggaran, maka pihak sekolah akan memberikan sanksi yang lebih tegas.</p>
|
||||
</div>
|
||||
|
||||
<div class="signature">
|
||||
<div class="date">
|
||||
<p>{{ \Carbon\Carbon::now()->locale('id')->isoFormat('D MMMM Y') }}</p>
|
||||
</div>
|
||||
<div class="title">
|
||||
<p>Kepala {{ $school['name'] }},</p>
|
||||
</div>
|
||||
<div class="name">
|
||||
<p><u><strong>{{ $school['kepala_sekolah'] }}</strong></u></p>
|
||||
</div>
|
||||
<div class="nip">
|
||||
<p>NIP. {{ $school['kepala_sekolah_nip'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.onload = function() { window.print(); };
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Detail SP')
|
||||
|
||||
@section('content')
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Detail Surat Peringatan</h1>
|
||||
<p class="text-sm text-gray-500">{{ $spLetter->letter_number }}</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<a href="{{ route('sp-letters.index') }}" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition">Kembali</a>
|
||||
<a href="{{ route('sp-letters.print', $spLetter->id) }}" target="_blank" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition">Cetak</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 uppercase tracking-wider">No. Surat</p>
|
||||
<p class="text-sm font-mono font-medium text-gray-900 mt-1">{{ $spLetter->letter_number }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 uppercase tracking-wider">Siswa</p>
|
||||
<p class="text-sm font-medium text-gray-900 mt-1">{{ $spLetter->student->full_name ?? '-' }}</p>
|
||||
<p class="text-xs text-gray-500">{{ $spLetter->student->nisn ?? '' }} - {{ $spLetter->student->class_name ?? '' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 uppercase tracking-wider">Jenis SP</p>
|
||||
<p class="text-sm font-medium text-gray-900 mt-1">{{ $spLetter->spThreshold->name ?? '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 uppercase tracking-wider">Total Poin Saat Generate</p>
|
||||
<p class="text-lg font-bold text-blue-600 mt-1">{{ $spLetter->total_points_at_time }} poin</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 uppercase tracking-wider">Status</p>
|
||||
<p class="text-sm font-medium text-gray-900 mt-1 capitalize">{{ $spLetter->status }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 uppercase tracking-wider">Dibuat Oleh</p>
|
||||
<p class="text-sm text-gray-900 mt-1">{{ $spLetter->generator->name ?? 'Sistem' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 uppercase tracking-wider">Dibuat Pada</p>
|
||||
<p class="text-sm text-gray-900 mt-1">{{ $spLetter->created_at->format('d F Y H:i') }}</p>
|
||||
</div>
|
||||
|
||||
@if($spLetter->violations_included)
|
||||
<div class="border-t border-gray-100 pt-6">
|
||||
<p class="text-xs text-gray-500 uppercase tracking-wider mb-3">Daftar Pelanggaran Terkait</p>
|
||||
<div class="bg-gray-50 p-3 rounded-lg">
|
||||
@php $violations = is_array($spLetter->violations_included) ? $spLetter->violations_included : json_decode($spLetter->violations_included, true); @endphp
|
||||
@if($violations)
|
||||
<ul class="space-y-1">
|
||||
@foreach($violations as $v)
|
||||
<li class="text-sm text-gray-700">• {{ $v['violation_date'] ?? '-' }} — {{ $v['description'] ?? '(deskripsi)' }} <span class="text-red-500 font-medium">(+{{ $v['points'] ?? 0 }})</span></li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@else
|
||||
<p class="text-sm text-gray-500">Data tidak tersedia</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($school['kepala_sekolah'])
|
||||
<div class="border-t border-gray-100 pt-6">
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-900">{{ $school['kepala_sekolah'] }}</p>
|
||||
<p class="text-xs text-gray-500">Kepala {{ $school['name'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,173 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Data Siswa')
|
||||
|
||||
@section('content')
|
||||
<div>
|
||||
{{-- Header --}}
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Data Siswa</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Daftar siswa aktif — sinkron dari Database Kesiswaan</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<span class="text-gray-500">{{ $students->total() }} siswa</span>
|
||||
@if(request()->anyFilled(['search','class_level','class_name','department']))
|
||||
<span class="inline-flex items-center px-2.5 py-1 text-xs font-medium bg-blue-100 text-red-700 rounded-full">
|
||||
{{ $students->total() }} ditemukan
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Filter Card --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mb-6">
|
||||
<form method="GET">
|
||||
<div class="p-5 space-y-4">
|
||||
{{-- Baris 1: Search --}}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">Pencarian</label>
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input type="text" name="search" value="{{ request('search') }}"
|
||||
placeholder="Cari NISN, NIS, atau Nama siswa..."
|
||||
class="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Baris 2: Dropdown --}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">Tingkat Kelas</label>
|
||||
<select name="class_level"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition bg-white">
|
||||
<option value="">Semua Tingkat</option>
|
||||
@foreach($classLevels as $level)
|
||||
<option value="{{ $level }}" @selected(request('class_level') == $level)>Kelas {{ $level }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">Kelas</label>
|
||||
<select name="class_name"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition bg-white">
|
||||
<option value="">Semua Kelas</option>
|
||||
@foreach($classNames as $cn)
|
||||
<option value="{{ $cn }}" @selected(request('class_name') == $cn)>{{ $cn }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">Jurusan</label>
|
||||
<select name="department"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition bg-white">
|
||||
<option value="">Semua Jurusan</option>
|
||||
@foreach($departments as $code => $name)
|
||||
<option value="{{ $code }}" @selected(request('department') == $code)>{{ $name }} ({{ $code }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end space-x-2">
|
||||
<button type="submit"
|
||||
class="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm flex items-center justify-center space-x-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<span>Cari</span>
|
||||
</button>
|
||||
@if(request()->anyFilled(['search','class_level','class_name','department']))
|
||||
<a href="{{ route('students.index') }}"
|
||||
class="px-4 py-2.5 text-sm font-medium text-gray-600 bg-white border border-gray-300 rounded-xl hover:bg-gray-50 transition flex items-center space-x-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
<span>Reset</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Table --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">NISN</th>
|
||||
<th class="px-4 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Nama</th>
|
||||
<th class="px-4 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Kelas</th>
|
||||
<th class="px-4 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Jurusan</th>
|
||||
<th class="px-4 py-3.5 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">Pelanggaran</th>
|
||||
<th class="px-4 py-3.5 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">Total Poin</th>
|
||||
<th class="px-4 py-3.5 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($students as $s)
|
||||
@php $pts = $s->total_points; @endphp
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="px-4 py-3.5 whitespace-nowrap text-sm font-mono text-gray-700">{{ $s->nisn }}</td>
|
||||
<td class="px-4 py-3.5">
|
||||
<div class="text-sm font-medium text-gray-900">{{ $s->full_name }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3.5 whitespace-nowrap text-sm text-gray-700">{{ $s->class_name ?? '-' }}</td>
|
||||
<td class="px-4 py-3.5 text-sm text-gray-500">{{ $s->department_code ?? '-' }}</td>
|
||||
<td class="px-4 py-3.5 text-center text-sm text-gray-700">
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">
|
||||
{{ $s->violations_count ?? 0 }}x
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3.5 text-center">
|
||||
@if($pts >= 100)
|
||||
<span class="inline-flex px-3 py-1 text-xs font-bold rounded-full bg-blue-100 text-blue-800">{{ $pts }}</span>
|
||||
@elseif($pts >= 50)
|
||||
<span class="inline-flex px-3 py-1 text-xs font-bold rounded-full bg-yellow-100 text-yellow-800">{{ $pts }}</span>
|
||||
@elseif($pts > 0)
|
||||
<span class="inline-flex px-3 py-1 text-xs font-bold rounded-full bg-orange-100 text-orange-700">{{ $pts }}</span>
|
||||
@else
|
||||
<span class="inline-flex px-3 py-1 text-xs rounded-full bg-green-100 text-green-700">0</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3.5 text-right">
|
||||
<a href="{{ route('students.show', $s->id) }}"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-blue-600 bg-red-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition">
|
||||
<svg class="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
Detail
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-16 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zM6 10h.01M18 10h.01"/>
|
||||
</svg>
|
||||
<p class="text-sm text-gray-500 mb-1">Tidak ada siswa ditemukan</p>
|
||||
<p class="text-xs text-gray-400">Coba ubah filter pencarian atau lakukan sinkronisasi data</p>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if($students->hasPages())
|
||||
<div class="px-4 py-3 border-t border-gray-100 bg-gray-50">
|
||||
{{ $students->appends(request()->query())->links() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="px-4 py-3 border-t border-gray-100 bg-gray-50 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>Menampilkan {{ $students->firstItem() ?? 0 }}–{{ $students->lastItem() ?? 0 }} dari {{ $students->total() }} siswa</span>
|
||||
<span class="font-medium">{{ $students->total() }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,447 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $student->full_name)
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.violation-timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 2px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.sp-progress-bar {
|
||||
background: linear-gradient(90deg, #dbeafe 0%, #93c5fd 40%, #60a5fa 70%, #3b82f6 100%);
|
||||
}
|
||||
.perspective-card {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.perspective-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.glass-stat {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.9), rgba(255,255,255,0.98));
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div>
|
||||
{{-- Header --}}
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<nav class="flex items-center space-x-2 text-sm text-gray-400 mb-2">
|
||||
<a href="{{ route('students.index') }}" class="hover:text-gray-600 transition">Data Siswa</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-700 font-medium truncate max-w-[200px]">{{ $student->full_name }}</span>
|
||||
</nav>
|
||||
<h1 class="text-2xl font-bold text-gray-900 tracking-tight">Profil Siswa</h1>
|
||||
</div>
|
||||
<a href="{{ route('students.index') }}"
|
||||
class="inline-flex items-center px-4 py-2.5 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 hover:border-gray-300 transition-all shadow-sm">
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
Kembali
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- ===== HERO CARD ===== --}}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden mb-6 perspective-card">
|
||||
<div class="bg-gradient-to-r from-blue-500 via-blue-400 to-sky-300 h-28 sm:h-36"></div>
|
||||
<div class="relative px-6 pb-6">
|
||||
{{-- Avatar --}}
|
||||
<div class="flex flex-col sm:flex-row sm:items-end -mt-14 sm:-mt-20 mb-4 gap-4">
|
||||
<div class="w-20 h-20 sm:w-24 sm:h-24 rounded-2xl bg-white shadow-lg border-2 border-white flex items-center justify-center flex-shrink-0">
|
||||
@if($student->photo_path)
|
||||
<img src="{{ Storage::url($student->photo_path) }}" alt="{{ $student->full_name }}"
|
||||
class="w-full h-full rounded-2xl object-cover">
|
||||
@else
|
||||
<span class="text-3xl sm:text-4xl font-bold text-blue-600">{{ strtoupper(substr($student->full_name, 0, 1)) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 pt-5 sm:pt-0">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div>
|
||||
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-gray-900 truncate leading-tight tracking-tight">{{ $student->full_name }}</h2>
|
||||
<div class="flex flex-wrap items-center gap-2 mt-3">
|
||||
<span class="inline-flex items-center gap-2 px-3.5 py-1.5 text-sm font-mono font-semibold bg-gray-50 text-blue-700 border border-blue-200 rounded-xl">
|
||||
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0"/></svg>
|
||||
{{ $student->nisn ?? '—' }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2 px-3.5 py-1.5 text-sm font-medium bg-blue-50 text-blue-700 border border-blue-200 rounded-xl">
|
||||
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
|
||||
{{ $student->class_name ?? '—' }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2 px-3.5 py-1.5 text-sm font-medium bg-blue-50 text-blue-700 border border-blue-200 rounded-xl">
|
||||
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
|
||||
{{ $student->department_name ?? $student->department_code ?? '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if($student->is_active)
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-200 rounded-full">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
Aktif
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-gray-100 text-gray-600 border border-gray-200 rounded-full">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
|
||||
Tidak Aktif
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SP Level Badge --}}
|
||||
@if($currentSpLevel)
|
||||
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold mb-4"
|
||||
style="background-color: {{ $currentSpLevel->color }}15; color: {{ $currentSpLevel->color }}; border: 1px solid {{ $currentSpLevel->color }}30;">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ $currentSpLevel->name }} — {{ $currentSpLevel->min_points }}+ poin
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Quick Stats --}}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div class="glass-stat rounded-xl border border-gray-100 p-4 text-center">
|
||||
<p class="text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">Total Poin</p>
|
||||
<p class="text-2xl font-bold {{ $totalPoints >= 100 ? 'text-blue-600' : ($totalPoints >= 50 ? 'text-blue-500' : ($totalPoints > 0 ? 'text-blue-400' : 'text-gray-400')) }}">
|
||||
{{ $totalPoints }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="glass-stat rounded-xl border border-gray-100 p-4 text-center">
|
||||
<p class="text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">Pelanggaran</p>
|
||||
<p class="text-2xl font-bold text-gray-800">{{ $violationCount }}<span class="text-sm font-medium text-gray-400">x</span></p>
|
||||
</div>
|
||||
<div class="glass-stat rounded-xl border border-gray-100 p-4 text-center">
|
||||
<p class="text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">SP Terbit</p>
|
||||
<p class="text-2xl font-bold {{ $activeSpLetters > 0 ? 'text-blue-600' : 'text-gray-400' }}">{{ $activeSpLetters }}</p>
|
||||
</div>
|
||||
<div class="glass-stat rounded-xl border border-gray-100 p-4 text-center">
|
||||
<p class="text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">Terakhir</p>
|
||||
<p class="text-sm font-semibold text-gray-700 mt-1">
|
||||
@if($lastViolation)
|
||||
{{ $lastViolation->violation_date->diffForHumans() }}
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ===== SP PROGRESS ===== --}}
|
||||
@if($spThresholds->count() > 0)
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 mb-6 perspective-card">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h3 class="text-base font-semibold text-gray-900">Progress Ambang Surat Peringatan</h3>
|
||||
<span class="text-xs text-gray-400">{{ $totalPoints }} / {{ $nextSpThreshold ? $nextSpThreshold->min_points : $spThresholds->last()->min_points }} poin</span>
|
||||
</div>
|
||||
|
||||
{{-- Progress Bar --}}
|
||||
@php
|
||||
$maxPoints = $nextSpThreshold ? $nextSpThreshold->min_points : $spThresholds->last()->min_points;
|
||||
if ($totalPoints >= $spThresholds->last()->min_points) {
|
||||
$progressPercent = 100;
|
||||
} else {
|
||||
$progressPercent = min(100, ($totalPoints / $maxPoints) * 100);
|
||||
}
|
||||
@endphp
|
||||
<div class="relative h-3 bg-gray-100 rounded-full overflow-hidden mb-4">
|
||||
<div class="sp-progress-bar h-full rounded-full transition-all duration-500 ease-out"
|
||||
style="width: {{ $progressPercent }}%"></div>
|
||||
</div>
|
||||
|
||||
{{-- Threshold Markers --}}
|
||||
<div class="relative flex justify-between">
|
||||
@foreach($spThresholds as $threshold)
|
||||
@php
|
||||
$tsReached = $totalPoints >= $threshold->min_points;
|
||||
@endphp
|
||||
<div class="flex flex-col items-center" style="flex:1">
|
||||
<div class="flex items-center gap-1.5 mb-1.5">
|
||||
<span class="w-3 h-3 rounded-full {{ $tsReached ? 'shadow-sm' : 'bg-gray-300' }}"
|
||||
style="background-color: {{ $tsReached ? $threshold->color : '' }}"></span>
|
||||
<span class="text-xs font-semibold {{ $tsReached ? '' : 'text-gray-400' }}"
|
||||
style="color: {{ $tsReached ? $threshold->color : '' }}">
|
||||
{{ $threshold->min_points }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-[10px] {{ $tsReached ? 'font-medium' : 'text-gray-400' }} leading-tight text-center"
|
||||
style="color: {{ $tsReached ? $threshold->color : '' }}">
|
||||
{{ $threshold->name }}
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Status --}}
|
||||
@if($currentSpLevel)
|
||||
<div class="mt-5 p-3.5 rounded-xl text-sm flex items-start gap-3"
|
||||
style="background-color: {{ $currentSpLevel->color }}10; border: 1px solid {{ $currentSpLevel->color }}20;">
|
||||
<svg class="w-5 h-5 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
style="color: {{ $currentSpLevel->color }}">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold" style="color: {{ $currentSpLevel->color }}">{{ $currentSpLevel->name }} sudah tercapai</p>
|
||||
<p class="text-gray-500 mt-0.5 text-xs">Siswa ini telah mencapai ambang minimal {{ $currentSpLevel->min_points }} poin. Surat peringatan telah diterbitkan.</p>
|
||||
</div>
|
||||
</div>
|
||||
@elseif($nextSpThreshold && $violationCount > 0)
|
||||
<div class="mt-5 p-3.5 rounded-xl bg-blue-50 border border-blue-100 text-sm flex items-start gap-3">
|
||||
<svg class="w-5 h-5 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold text-blue-700">Menuju {{ $nextSpThreshold->name }}</p>
|
||||
<p class="text-blue-600/70 mt-0.5 text-xs">{{ $nextSpThreshold->min_points - $totalPoints }} poin lagi untuk mencapai ambang {{ $nextSpThreshold->name }} ({{ $nextSpThreshold->min_points }} poin)</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ===== VIOLATIONS TIMELINE ===== --}}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">Riwayat Pelanggaran</h3>
|
||||
<p class="text-xs text-gray-400">{{ $violationCount }} catatan pelanggaran</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@forelse($student->violations as $v)
|
||||
<div class="violation-timeline relative pl-14 pr-6 py-4 {{ !$loop->last ? 'border-b border-gray-50' : '' }} hover:bg-gray-50/50 transition">
|
||||
{{-- Timeline dot --}}
|
||||
<div class="absolute left-[13px] top-[18px] w-[15px] h-[15px] rounded-full border-2 flex items-center justify-center"
|
||||
style="border-color: {{ $v->violationType?->category?->color ?? '#9ca3af' }}; background-color: white;">
|
||||
<span class="w-[7px] h-[7px] rounded-full" style="background-color: {{ $v->violationType?->category?->color ?? '#9ca3af' }}"></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-sm font-medium text-gray-900">{{ $v->violationType->name ?? '—' }}</span>
|
||||
@if($v->is_verified)
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium bg-blue-50 text-blue-600 border border-blue-200 rounded">✓ Terverifikasi</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($v->description)
|
||||
<p class="text-sm text-gray-500 mt-0.5 line-clamp-2">{{ $v->description }}</p>
|
||||
@endif
|
||||
<div class="flex items-center gap-3 mt-1.5 text-xs text-gray-400">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{{ $v->violation_date->format('d M Y') }}
|
||||
@if($v->violation_time)
|
||||
{{ $v->violation_time }}
|
||||
@endif
|
||||
</span>
|
||||
@if($v->location)
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
{{ $v->location }}
|
||||
</span>
|
||||
@endif
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
{{ $v->recorder->name ?? '—' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Evidence photos --}}
|
||||
@if($v->evidences && $v->evidences->count() > 0)
|
||||
<div class="flex gap-2 mt-2">
|
||||
@foreach($v->evidences->take(3) as $ev)
|
||||
<a href="{{ Storage::url($ev->file_path) }}" target="_blank"
|
||||
class="w-14 h-14 rounded-lg border border-gray-200 overflow-hidden hover:ring-2 hover:ring-blue-300 transition-all">
|
||||
<img src="{{ Storage::url($ev->file_path) }}" class="w-full h-full object-cover" alt="Bukti">
|
||||
</a>
|
||||
@endforeach
|
||||
@if($v->evidences->count() > 3)
|
||||
<div class="w-14 h-14 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center text-xs text-gray-400 font-medium">
|
||||
+{{ $v->evidences->count() - 3 }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end flex-shrink-0">
|
||||
<span class="text-sm font-bold {{ $v->points >= 50 ? 'text-blue-600' : ($v->points >= 15 ? 'text-blue-500' : 'text-blue-400') }}">
|
||||
+{{ $v->points }}
|
||||
</span>
|
||||
@if($v->sanction)
|
||||
<span class="text-[10px] text-gray-400 mt-0.5 line-clamp-1 max-w-[100px] text-right">{{ $v->sanction }}</span>
|
||||
@endif
|
||||
<a href="{{ route('violations.show', $v->id) }}" class="text-[10px] text-blue-500 hover:text-blue-700 font-medium mt-1 transition">Detail</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="py-16 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-blue-50 border border-blue-100 flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-7 h-7 text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="text-sm font-medium text-gray-500 mb-1">Belum Ada Pelanggaran</h4>
|
||||
<p class="text-xs text-gray-400">Siswa ini belum memiliki catatan pelanggaran</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- ===== SP LETTERS ===== --}}
|
||||
@if($student->spLetters->count() > 0)
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">Surat Peringatan</h3>
|
||||
<p class="text-xs text-gray-400">{{ $student->spLetters->count() }} surat diterbitkan</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-50">
|
||||
@foreach($student->spLetters as $sp)
|
||||
<div class="px-6 py-4 flex items-center justify-between hover:bg-gray-50/50 transition group">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style="background-color: {{ $sp->spThreshold?->color ?? '#3b82f6' }}15">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
style="color: {{ $sp->spThreshold?->color ?? '#3b82f6' }}">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">
|
||||
{{ $sp->spThreshold?->name ?? 'SP' }}
|
||||
@if($sp->status === 'draft')
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium bg-blue-50 text-blue-600 border border-blue-200 rounded ml-1.5">Draft</span>
|
||||
@elseif($sp->status === 'issued')
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium bg-emerald-50 text-emerald-600 border border-emerald-200 rounded ml-1.5">Terbit</span>
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ $sp->letter_number }} • {{ $sp->created_at->format('d M Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a href="{{ route('sp-letters.show', $sp->id) }}"
|
||||
class="px-3 py-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 hover:border-gray-300 transition-all">
|
||||
Lihat
|
||||
</a>
|
||||
<a href="{{ route('sp-letters.print', $sp->id) }}"
|
||||
class="px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition-all">
|
||||
Cetak
|
||||
</a>
|
||||
</div>
|
||||
{{-- Always show on mobile --}}
|
||||
<div class="flex items-center gap-2 flex-shrink-0 sm:hidden">
|
||||
<a href="{{ route('sp-letters.show', $sp->id) }}" class="text-xs text-blue-600 font-medium">Lihat</a>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ===== STUDENT INFO DETAILS ===== --}}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900">Informasi Lengkap</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-1.5">NISN</p>
|
||||
<p class="text-sm font-medium text-gray-900 font-mono">{{ $student->nisn ?? '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-1.5">NIS</p>
|
||||
<p class="text-sm font-medium text-gray-900 font-mono">{{ $student->student_number ?? '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-1.5">Jenis Kelamin</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $student->gender === 'L' ? 'Laki-laki' : ($student->gender === 'P' ? 'Perempuan' : '—') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-1.5">Tempat, Tanggal Lahir</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ $student->place_of_birth ?? '—' }}{{ $student->place_of_birth && $student->date_of_birth ? ', ' : '' }}
|
||||
{{ $student->date_of_birth ? $student->date_of_birth->format('d M Y') : '—' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-1.5">Kelas</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $student->class_name ?? '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-1.5">Jurusan</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $student->department_name ?? $student->department_code ?? '—' }}</p>
|
||||
</div>
|
||||
<div class="sm:col-span-2 lg:col-span-3">
|
||||
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-1.5">Alamat</p>
|
||||
<p class="text-sm text-gray-700">{{ $student->address ?? '—' }}</p>
|
||||
</div>
|
||||
@if($student->phone_number || $student->email)
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-1.5">Telepon</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $student->phone_number ?? '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-1.5">Email</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $student->email ?? '—' }}</p>
|
||||
</div>
|
||||
@endif
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-1.5">Tahun Akademik</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $student->academic_year_name ?? '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-1.5">Terakhir Sinkron</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $student->synced_at ? $student->synced_at->format('d M Y H:i') : '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,261 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Manajemen User')
|
||||
|
||||
@section('content')
|
||||
<div x-data="userManager()">
|
||||
{{-- Header --}}
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Manajemen User</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Kelola pengguna yang berhak mengakses aplikasi</p>
|
||||
</div>
|
||||
<button @click="openCreate()"
|
||||
class="inline-flex items-center px-4 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm">
|
||||
<i class="fa-solid fa-plus mr-2"></i>
|
||||
Tambah User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Filter --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mb-6">
|
||||
<form method="GET" class="p-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 text-sm"></i>
|
||||
<input type="text" name="search" value="{{ request('search') }}"
|
||||
placeholder="Cari nama, username, atau email..."
|
||||
class="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
<select name="role"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition bg-white">
|
||||
<option value="">Semua Role</option>
|
||||
<option value="admin" @selected(request('role') == 'admin')>Admin</option>
|
||||
<option value="bk" @selected(request('role') == 'bk')>BK</option>
|
||||
<option value="wali_kelas" @selected(request('role') == 'wali_kelas')>Wali Kelas</option>
|
||||
<option value="staff" @selected(request('role') == 'staff')>Staff</option>
|
||||
</select>
|
||||
<div class="flex space-x-2">
|
||||
<button type="submit" class="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition">
|
||||
<i class="fa-solid fa-filter mr-1.5"></i> Filter
|
||||
</button>
|
||||
@if(request()->anyFilled(['search','role']))
|
||||
<a href="{{ route('users.index') }}" class="px-4 py-2.5 text-sm font-medium text-gray-600 bg-white border border-gray-300 rounded-xl hover:bg-gray-50 transition">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Table --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">User</th>
|
||||
<th class="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Username</th>
|
||||
<th class="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Role</th>
|
||||
<th class="px-5 py-3.5 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-5 py-3.5 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($users as $user)
|
||||
<tr class="hover:bg-gray-50 transition {{ !$user->is_active ? 'opacity-60' : '' }}">
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0
|
||||
@if($user->role === 'admin') bg-blue-100 text-blue-600
|
||||
@elseif($user->role === 'bk') bg-blue-100 text-blue-600
|
||||
@elseif($user->role === 'wali_kelas') bg-green-100 text-green-600
|
||||
@else bg-gray-100 text-gray-600 @endif">
|
||||
{{ strtoupper(substr($user->name, 0, 1)) }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $user->name }}</p>
|
||||
<p class="text-xs text-gray-400">{{ $user->email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-sm text-gray-700 font-mono">{{ $user->username }}</td>
|
||||
<td class="px-5 py-4">
|
||||
<span class="inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-full
|
||||
@if($user->role === 'admin') bg-blue-100 text-red-700
|
||||
@elseif($user->role === 'bk') bg-blue-100 text-blue-700
|
||||
@elseif($user->role === 'wali_kelas') bg-green-100 text-green-700
|
||||
@else bg-gray-100 text-gray-700 @endif">
|
||||
@if($user->role === 'admin') <i class="fa-solid fa-shield-halved mr-1"></i>
|
||||
@elseif($user->role === 'bk') <i class="fa-solid fa-user-tie mr-1"></i>
|
||||
@elseif($user->role === 'wali_kelas') <i class="fa-solid fa-chalkboard-user mr-1"></i>
|
||||
@else <i class="fa-solid fa-user mr-1"></i> @endif
|
||||
{{ ucfirst(str_replace('_', ' ', $user->role)) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-center">
|
||||
@if($user->is_active)
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||
<span class="w-1.5 h-1.5 bg-green-500 rounded-full mr-1.5"></span>
|
||||
Aktif
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-500 rounded-full">
|
||||
<span class="w-1.5 h-1.5 bg-gray-400 rounded-full mr-1.5"></span>
|
||||
Nonaktif
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-5 py-4 text-right">
|
||||
<div class="flex items-center justify-end space-x-1">
|
||||
<button @click="openEdit({{ $user->id }}, '{{ addslashes($user->name) }}', '{{ $user->username }}', '{{ $user->email }}', '{{ $user->role }}', {{ $user->is_active ? 'true' : 'false' }})"
|
||||
class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</button>
|
||||
@if($user->id !== auth()->id())
|
||||
<form action="{{ route('users.destroy', $user->id) }}" method="POST" class="inline"
|
||||
x-data x-on:submit.prevent="if(await window.confirmSwal({text:'Hapus user ini?'})) $el.submit()"">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit"
|
||||
class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="px-5 py-16 text-center">
|
||||
<i class="fa-solid fa-users text-gray-300 text-4xl mb-3"></i>
|
||||
<p class="text-sm text-gray-500">Tidak ada user ditemukan</p>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($users->hasPages())
|
||||
<div class="px-5 py-3 border-t border-gray-100 bg-gray-50">
|
||||
{{ $users->appends(request()->query())->links() }}
|
||||
</div>
|
||||
@endif
|
||||
<div class="px-5 py-3 border-t border-gray-100 bg-gray-50 flex justify-between text-xs text-gray-500">
|
||||
<span>{{ $users->firstItem() ?? 0 }}–{{ $users->lastItem() ?? 0 }} dari {{ $users->total() }}</span>
|
||||
<span>{{ $users->total() }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Modal --}}
|
||||
<div x-show="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" style="display: none;">
|
||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
|
||||
<div @click="modalOpen = false" class="fixed inset-0 bg-gray-500 bg-opacity-50 transition-opacity"></div>
|
||||
<div @click.away="modalOpen = false"
|
||||
class="relative inline-block align-bottom bg-white rounded-2xl shadow-xl border border-gray-200 text-left overflow-hidden transform transition-all sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div class="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-lg flex items-center justify-center" :class="isEditing ? 'bg-blue-100' : 'bg-blue-100'">
|
||||
<i class="fa-solid" :class="isEditing ? 'fa-pen-to-square text-blue-600' : 'fa-user-plus text-blue-600'"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900" x-text="isEditing ? 'Edit User' : 'Tambah User'"></h3>
|
||||
<p class="text-sm text-gray-500" x-text="isEditing ? 'Ubah data pengguna' : 'Buat akun pengguna baru'"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="modalOpen = false" class="text-gray-400 hover:text-gray-600 transition">
|
||||
<i class="fa-solid fa-xmark text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Form --}}
|
||||
<form :action="isEditing ? `/users/${editId}` : '{{ route('users.store') }}'" method="POST" class="p-6 space-y-4">
|
||||
@csrf
|
||||
<input type="hidden" name="_method" :value="isEditing ? 'PUT' : 'POST'">
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Nama Lengkap</label>
|
||||
<input type="text" x-model="formName" name="name" required
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition"
|
||||
placeholder="Nama lengkap">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Username</label>
|
||||
<input type="text" x-model="formUsername" name="username" required
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition"
|
||||
placeholder="username">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Email</label>
|
||||
<input type="email" x-model="formEmail" name="email" required
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition"
|
||||
placeholder="email@sekolah.sch.id">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Password <span x-text="isEditing ? '(kosongkan jika tidak diubah)' : ''" class="text-gray-400 font-normal"></span>
|
||||
</label>
|
||||
<input type="password" x-model="formPassword" name="password" :required="!isEditing" minlength="6"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition"
|
||||
placeholder="Min. 6 karakter">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Role / Hak Akses</label>
|
||||
<select x-model="formRole" name="role" required
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition bg-white">
|
||||
<option value="bk">BK — Input & laporan</option>
|
||||
<option value="admin">Admin — Full akses</option>
|
||||
<option value="wali_kelas">Wali Kelas — Lihat kelas sendiri</option>
|
||||
<option value="staff">Staff — Terbatas</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="isEditing" class="flex items-center justify-between p-4 bg-gray-50 rounded-xl border border-gray-100">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Akun Aktif</p>
|
||||
<p class="text-xs text-gray-500">Nonaktifkan untuk menonaktifkan akun</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" x-model="formActive" name="is_active" value="1" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:after:translate-x-full peer-checked:bg-blue-600 after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<button type="button" @click="modalOpen = false"
|
||||
class="px-5 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-xl hover:bg-gray-50 transition">Batal</button>
|
||||
<button type="submit"
|
||||
class="px-5 py-2.5 text-sm font-medium text-white rounded-xl transition shadow-sm"
|
||||
:class="isEditing ? 'bg-blue-600 hover:bg-blue-700' : 'bg-blue-600 hover:bg-blue-700'"
|
||||
x-text="isEditing ? 'Simpan Perubahan' : 'Tambah User'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function userManager() {
|
||||
return {
|
||||
modalOpen: false, isEditing: false, editId: null,
|
||||
formName: '', formUsername: '', formEmail: '', formPassword: '', formRole: 'bk', formActive: true,
|
||||
openCreate() {
|
||||
this.isEditing = false; this.editId = null;
|
||||
this.formName = ''; this.formUsername = ''; this.formEmail = ''; this.formPassword = '';
|
||||
this.formRole = 'bk'; this.formActive = true; this.modalOpen = true;
|
||||
},
|
||||
openEdit(id, name, username, email, role, active) {
|
||||
this.isEditing = true; this.editId = id;
|
||||
this.formName = name; this.formUsername = username; this.formEmail = email;
|
||||
this.formPassword = ''; this.formRole = role; this.formActive = active; this.modalOpen = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@endsection
|
||||
@@ -0,0 +1,294 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Input Pelanggaran')
|
||||
|
||||
@section('content')
|
||||
<div x-data="violationForm()">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Input Pelanggaran</h1>
|
||||
<p class="text-sm text-gray-500">Catat pelanggaran siswa baru</p>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('violations.store') }}" method="POST" enctype="multipart/form-data" class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
@csrf
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
{{-- Search Siswa --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Siswa <span class="text-red-500">*</span></label>
|
||||
<div class="relative">
|
||||
<input type="text" x-model="searchQuery" @input.debounce="searchStudents"
|
||||
placeholder="Cari NISN atau Nama siswa..."
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
<input type="hidden" name="student_id" x-model="selectedStudentId">
|
||||
|
||||
<div x-show="results.length > 0 && !selectedStudentId"
|
||||
class="absolute z-10 mt-1 w-full bg-white shadow-lg border border-gray-200 rounded-xl max-h-48 overflow-y-auto">
|
||||
<template x-for="s in results" :key="s.id">
|
||||
<div @click="selectStudent(s)"
|
||||
class="px-4 py-2.5 hover:bg-red-50 cursor-pointer border-b border-gray-100 last:border-0 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900" x-text="s.full_name"></p>
|
||||
<p class="text-xs text-gray-500" x-text="s.nisn + ' - ' + (s.class_name || '')"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-show="selectedStudentId"
|
||||
class="mt-2 flex items-center justify-between p-3 bg-red-50 border border-blue-200 rounded-xl">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-sm font-bold text-blue-600" x-text="selectedStudentName.charAt(0)"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900" x-text="selectedStudentName"></p>
|
||||
<p class="text-xs text-gray-500" x-text="selectedStudentInfo"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" @click="clearStudent()" class="text-red-400 hover:text-blue-600 transition p-1">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@error('student_id') <p class="mt-1 text-sm text-blue-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
{{-- Jenis Pelanggaran + Poin & Sanksi in ONE scope --}}
|
||||
<div x-data="typeSearch({{ json_encode($typeGroups) }})">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Jenis Pelanggaran <span class="text-red-500">*</span></label>
|
||||
<div class="relative">
|
||||
<input type="text" x-model="q" @input="open=true" @focus="open=true"
|
||||
@click.away="open=false" @keydown.escape="open=false"
|
||||
placeholder="Ketik nama pelanggaran..."
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition"
|
||||
:class="selectedId ? 'border-green-300 bg-green-50' : ''">
|
||||
|
||||
<input type="hidden" name="violation_type_id" x-model="selectedId">
|
||||
|
||||
<div x-show="open && filtered.length > 0"
|
||||
class="absolute z-20 mt-1 w-full bg-white shadow-lg border border-gray-200 rounded-xl max-h-72 overflow-y-auto">
|
||||
<template x-for="(group, gi) in filtered" :key="gi">
|
||||
<div>
|
||||
<div class="sticky top-0 px-4 py-1.5 text-xs font-semibold uppercase tracking-wider border-b flex items-center space-x-1.5 z-10"
|
||||
:style="'background:' + group.color + '15; color:' + group.color + '; border-color:' + group.color + '30'">
|
||||
<span class="w-2 h-2 rounded-full" :style="'background:' + group.color"></span>
|
||||
<span x-text="group.label"></span>
|
||||
<span class="ml-1 opacity-60" x-text="'(' + group.types.length + ')'"></span>
|
||||
</div>
|
||||
<template x-for="t in group.types" :key="t.id">
|
||||
<div @click="pick(t)" @keydown.enter="pick(t)" tabindex="0"
|
||||
class="px-4 py-2.5 cursor-pointer border-b border-gray-50 flex items-center justify-between transition"
|
||||
:class="selectedId == t.id ? 'bg-red-50 border-l-2 border-l-red-500' : 'hover:bg-gray-50'">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate" x-text="t.name"></p>
|
||||
<p class="text-xs text-gray-400 truncate" x-text="t.sanction || '—'"></p>
|
||||
</div>
|
||||
<span class="ml-3 text-sm font-bold flex-shrink-0 px-2 py-0.5 rounded"
|
||||
:class="t.points >= 50 ? 'bg-blue-100 text-red-700' : (t.points >= 15 ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700')"
|
||||
x-text="'+' + t.points"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-show="open && filtered.length === 0 && q.length > 0"
|
||||
class="absolute z-20 mt-1 w-full bg-white shadow-lg border border-gray-200 rounded-xl p-5 text-center">
|
||||
<p class="text-sm text-gray-500">Tidak ditemukan untuk "<span x-text="q" class="font-medium"></span>"</p>
|
||||
</div>
|
||||
|
||||
<div x-show="selectedId && selectedName"
|
||||
class="mt-2 flex items-center justify-between p-3 rounded-xl border"
|
||||
:style="'border-color:' + selColor + '40; background:' + selColor + '08'">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<span class="w-3 h-3 rounded-full" :style="'background:' + selColor"></span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900" x-text="selectedName"></p>
|
||||
<p class="text-xs text-gray-500" x-text="selectedSanction ? 'Sanksi: ' + selectedSanction : ''"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-xl font-bold" :class="selectedPoints >= 50 ? 'text-blue-600' : (selectedPoints >= 15 ? 'text-yellow-600' : 'text-green-600')" x-text="'+' + selectedPoints"></span>
|
||||
<button @click="clear()" class="text-gray-300 hover:text-red-500 transition">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@error('violation_type_id') <p class="mt-1 text-sm text-blue-600">{{ $message }}</p> @enderror
|
||||
|
||||
{{-- Poin & Sanksi -- same scope as typeSearch --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Poin</label>
|
||||
<input type="number" name="points" x-model="selectedPoints" readonly
|
||||
class="w-full px-4 py-2.5 bg-gray-50 border border-gray-300 rounded-xl sm:text-sm text-gray-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Sanksi</label>
|
||||
<input type="text" name="sanction" x-model="selectedSanction"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Tanggal & Waktu --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Tanggal Pelanggaran <span class="text-red-500">*</span></label>
|
||||
<input type="date" name="violation_date" value="{{ date('Y-m-d') }}" required
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Waktu</label>
|
||||
<input type="time" name="violation_time" value="{{ date('H:i') }}"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Lokasi --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Lokasi</label>
|
||||
<input type="text" name="location" placeholder="Contoh: Depan kelas, Lapangan, dll"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition">
|
||||
</div>
|
||||
|
||||
{{-- Deskripsi --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Deskripsi / Catatan</label>
|
||||
<textarea name="description" rows="3" placeholder="Deskripsi pelanggaran..."
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
{{-- Foto Bukti --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Bukti Foto <span class="text-gray-400 font-normal">(maks. 5 foto)</span></label>
|
||||
<input type="file" name="evidences[]" id="evidences" multiple accept="image/*" capture="environment" @change="handleFiles" class="hidden">
|
||||
|
||||
<div class="grid grid-cols-3 md:grid-cols-5 gap-3 mb-3">
|
||||
<template x-for="(file, index) in files" :key="index">
|
||||
<div class="relative aspect-square rounded-xl overflow-hidden border border-gray-200 bg-gray-50 group">
|
||||
<img :src="file.url" class="w-full h-full object-cover">
|
||||
<button type="button" @click="removeFile(index)"
|
||||
class="absolute top-1.5 right-1.5 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-blue-700 transition opacity-0 group-hover:opacity-100">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent text-white text-xs px-2 py-1.5 truncate" x-text="file.name"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="files.length < 5">
|
||||
<div @click="document.getElementById('evidences').click()"
|
||||
class="aspect-square rounded-xl border-2 border-dashed border-gray-300 hover:border-red-400 bg-gray-50 flex flex-col items-center justify-center cursor-pointer transition hover:bg-red-50/30">
|
||||
<i class="fa-solid fa-camera text-gray-300 text-2xl mb-1"></i>
|
||||
<span class="text-xs text-gray-400">Tambah Foto</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<button type="button" @click="document.getElementById('evidences').click()" class="px-4 py-2 text-sm border border-gray-300 rounded-xl hover:bg-gray-50 transition flex items-center space-x-2">
|
||||
<i class="fa-solid fa-image text-gray-500"></i><span>Pilih File</span>
|
||||
</button>
|
||||
<button type="button" @click="captureCamera" class="px-4 py-2 text-sm border border-gray-300 rounded-xl hover:bg-gray-50 transition flex items-center space-x-2">
|
||||
<i class="fa-solid fa-camera text-gray-500"></i><span>Ambil Foto</span>
|
||||
</button>
|
||||
</div>
|
||||
@error('evidences.*') <p class="mt-1 text-sm text-blue-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Submit --}}
|
||||
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100 rounded-b-xl flex justify-end space-x-3">
|
||||
<a href="{{ route('violations.index') }}" class="px-5 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-xl hover:bg-gray-50 transition">Batal</a>
|
||||
<button type="submit" class="px-6 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition shadow-sm flex items-center space-x-2">
|
||||
<i class="fa-solid fa-floppy-disk"></i>
|
||||
<span>Simpan Pelanggaran</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function typeSearch(groups) {
|
||||
return {
|
||||
q: '', open: false,
|
||||
selectedId: '', selectedName: '',
|
||||
selectedPoints: 0, selectedSanction: '',
|
||||
selColor: '#6b7280',
|
||||
groups: groups,
|
||||
|
||||
get filtered() {
|
||||
if (!this.q || this.q.length < 1) return this.groups;
|
||||
const q = this.q.toLowerCase();
|
||||
return this.groups.map(g => ({
|
||||
...g,
|
||||
types: g.types.filter(t =>
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
(t.sanction && t.sanction.toLowerCase().includes(q))
|
||||
)
|
||||
})).filter(g => g.types.length > 0);
|
||||
},
|
||||
|
||||
pick(t) {
|
||||
this.selectedId = t.id;
|
||||
this.selectedName = t.name;
|
||||
this.selectedPoints = t.points;
|
||||
this.selectedSanction = t.sanction || '';
|
||||
this.selColor = this.groups.find(g => g.types.some(x => x.id === t.id))?.color || '#6b7280';
|
||||
this.q = t.name;
|
||||
this.open = false;
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.selectedId = ''; this.selectedName = '';
|
||||
this.selectedPoints = 0; this.selectedSanction = '';
|
||||
this.selColor = '#6b7280'; this.q = ''; this.open = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function violationForm() {
|
||||
return {
|
||||
searchQuery: '', results: [],
|
||||
selectedStudentId: null, selectedStudentName: '', selectedStudentInfo: '',
|
||||
files: [],
|
||||
|
||||
searchStudents() {
|
||||
if (this.searchQuery.length < 2) { this.results = []; return; }
|
||||
if (this.selectedStudentId) return;
|
||||
fetch('{{ route("api.students.search") }}?q=' + encodeURIComponent(this.searchQuery))
|
||||
.then(r => r.json()).then(data => { this.results = data; });
|
||||
},
|
||||
selectStudent(s) {
|
||||
this.selectedStudentId = s.id; this.selectedStudentName = s.full_name;
|
||||
this.selectedStudentInfo = s.nisn + ' - ' + (s.class_name || '');
|
||||
this.searchQuery = s.full_name; this.results = [];
|
||||
},
|
||||
clearStudent() {
|
||||
this.selectedStudentId = null; this.selectedStudentName = '';
|
||||
this.selectedStudentInfo = ''; this.searchQuery = '';
|
||||
},
|
||||
handleFiles(e) {
|
||||
const newFiles = Array.from(e.target.files);
|
||||
const toAdd = newFiles.slice(0, 5 - this.files.length);
|
||||
toAdd.forEach(file => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
this.files.push({ file, url: ev.target.result, name: file.name });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
removeFile(index) { this.files.splice(index, 1); },
|
||||
captureCamera() {
|
||||
const input = document.getElementById('evidences');
|
||||
input.setAttribute('capture', 'environment'); input.click();
|
||||
setTimeout(() => input.removeAttribute('capture'), 100);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@endsection
|
||||
@@ -0,0 +1,110 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Data Pelanggaran')
|
||||
|
||||
@section('content')
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Data Pelanggaran</h1>
|
||||
<p class="text-sm text-gray-500">Riwayat pelanggaran siswa</p>
|
||||
</div>
|
||||
<a href="{{ route('violations.create') }}" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition flex items-center space-x-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
||||
<span>Input Pelanggaran</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Filter --}}
|
||||
<form method="GET" class="bg-white p-4 rounded-xl shadow-sm border border-gray-200 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<input type="text" name="search" placeholder="Cari NISN/Nama..." value="{{ request('search') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<select name="category_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">Semua Kategori</option>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}" @selected(request('category_id') == $cat->id)>{{ $cat->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<input type="date" name="date_from" value="{{ request('date_from') }}" placeholder="Dari tanggal"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<input type="date" name="date_to" value="{{ request('date_to') }}" placeholder="Sampai tanggal"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||
<button type="submit" class="px-3 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700">Filter</button>
|
||||
@if(request()->anyFilled(['search','category_id','date_from','date_to']))
|
||||
<a href="{{ route('violations.index') }}" class="px-3 py-2 border border-gray-300 text-gray-600 text-sm rounded-lg hover:bg-gray-50">Reset</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Table --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Tanggal</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Siswa</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Pelanggaran</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Poin</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Verifikasi</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Oleh</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($violations as $v)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-700">{{ $v->violation_date->format('d/m/Y') }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-sm font-medium text-gray-900">{{ $v->student->full_name ?? '-' }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $v->student->nisn ?? '' }} - {{ $v->student->class_name ?? '' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background-color: {{ $v->violationType?->category?->color ?? '#6b7280' }}"></span>
|
||||
<span class="text-sm text-gray-700">{{ $v->violationType->name ?? '-' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
+{{ $v->points }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@if($v->is_verified)
|
||||
<span class="text-xs text-green-600 font-medium">✓ Terverifikasi</span>
|
||||
@else
|
||||
<span class="text-xs text-yellow-600 font-medium">Belum</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{ $v->recorder->name ?? '-' }}</td>
|
||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||
<a href="{{ route('violations.show', $v->id) }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium">Detail</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-12 text-center text-gray-500">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"></path></svg>
|
||||
<p>Belum ada pelanggaran tercatat</p>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($violations->hasPages())
|
||||
<div class="px-4 py-3 border-t border-gray-100">
|
||||
{{ $violations->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,220 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Detail Pelanggaran')
|
||||
|
||||
@section('content')
|
||||
<div>
|
||||
{{-- Header --}}
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<a href="{{ route('violations.index') }}" class="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition">
|
||||
<i class="fa-solid fa-arrow-left"></i>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Detail Pelanggaran</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Informasi lengkap pelanggaran siswa</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
@if(!$violation->is_verified)
|
||||
<form action="{{ route('violations.verify', $violation->id) }}" method="POST">
|
||||
@csrf
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-xl hover:bg-green-700 transition shadow-sm">
|
||||
<i class="fa-solid fa-check-circle mr-1.5"></i>
|
||||
Verifikasi
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
<form action="{{ route('violations.destroy', $violation->id) }}" method="POST" x-data onsubmit="event.preventDefault(); Swal.fire({title: 'Konfirmasi', text: 'Hapus pelanggaran ini?', icon: 'warning', showCancelButton: true, confirmButtonColor: '#dc2626', cancelButtonColor: '#6b7280', confirmButtonText: 'Ya, hapus!', cancelButtonText: 'Batal'}).then(r => { if(r.isConfirmed) { .target.submit(); } })">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-white border border-blue-200 rounded-xl hover:bg-blue-50 transition">
|
||||
<i class="fa-solid fa-trash-can mr-1.5"></i>
|
||||
Hapus
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Content Grid --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{{-- Left: Info Siswa + Pelanggaran --}}
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
{{-- Card Siswa --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="p-5 flex items-center space-x-4">
|
||||
<div class="w-14 h-14 rounded-full flex items-center justify-center text-lg font-bold flex-shrink-0"
|
||||
style="background-color: {{ $violation->violationType?->category?->color ?? '#6b7280' }}20; color: {{ $violation->violationType?->category?->color ?? '#6b7280' }}">
|
||||
{{ strtoupper(substr($violation->student->full_name ?? '?', 0, 1)) }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-lg font-bold text-gray-900 truncate">{{ $violation->student->full_name ?? '-' }}</h2>
|
||||
<p class="text-sm text-gray-500">{{ $violation->student->nisn ?? '' }} <span class="mx-1.5">•</span> {{ $violation->student->class_name ?? '' }}</p>
|
||||
<p class="text-xs text-gray-400">{{ $violation->student->department_name ?? '' }}</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 text-right">
|
||||
<p class="text-xs text-gray-500">Total Poin</p>
|
||||
<p class="text-2xl font-bold" style="color: {{ $violation->violationType?->category?->color ?? '#6b7280' }}">{{ $violation->student->total_points }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Card Detail Pelanggaran --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Detail Pelanggaran</h3>
|
||||
<span class="inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-full"
|
||||
style="background-color: {{ $violation->violationType?->category?->color ?? '#6b7280' }}15; color: {{ $violation->violationType?->category?->color ?? '#6b7280' }}">
|
||||
<span class="w-1.5 h-1.5 rounded-full mr-1.5" style="background-color: {{ $violation->violationType?->category?->color ?? '#6b7280' }}"></span>
|
||||
{{ $violation->violationType?->category?->name ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
{{-- Info bars --}}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-50 last:border-0">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-gavel text-blue-500 text-sm"></i>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600">Jenis Pelanggaran</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $violation->violationType->name ?? '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-50">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-star text-blue-500 text-sm"></i>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600">Poin</span>
|
||||
</div>
|
||||
<span class="text-lg font-bold text-blue-600">+{{ $violation->points }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-50">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-scale-balanced text-blue-500 text-sm"></i>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600">Sanksi</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900 text-right max-w-[50%]">{{ $violation->sanction ?? '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-50">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-calendar text-blue-500 text-sm"></i>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600">Tanggal</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $violation->violation_date->format('d F Y') }} @if($violation->violation_time){{ \Carbon\Carbon::parse($violation->violation_time)->format('H:i') }} WIB @endif</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-50">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-location-dot text-blue-500 text-sm"></i>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600">Lokasi</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $violation->location ?? '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<i class="fa-solid fa-user-pen text-blue-500 text-sm"></i>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600">Dicatat oleh</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $violation->recorder->name ?? '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Deskripsi --}}
|
||||
@if($violation->description)
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-5 py-4 border-b border-gray-100">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Deskripsi / Catatan</h3>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-sm text-gray-700 leading-relaxed">{{ $violation->description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Right Sidebar: Status + Evidences --}}
|
||||
<div class="space-y-6">
|
||||
{{-- Status Card --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-5 py-4 border-b border-gray-100">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Status</h3>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
{{-- Verifikasi --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">Verifikasi</span>
|
||||
@if($violation->is_verified)
|
||||
<span class="inline-flex items-center px-2.5 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||
<i class="fa-solid fa-check-circle mr-1"></i>
|
||||
Terverifikasi
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-2.5 py-1 text-xs font-medium bg-yellow-100 text-yellow-700 rounded-full">
|
||||
<i class="fa-solid fa-clock mr-1"></i>
|
||||
Belum
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($violation->is_verified)
|
||||
<div class="text-xs text-gray-400 flex items-center space-x-1">
|
||||
<i class="fa-solid fa-check text-green-500"></i>
|
||||
<span>oleh {{ $violation->verifier->name ?? '-' }} • {{ $violation->verified_at?->format('d/m/Y H:i') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Waktu input --}}
|
||||
<div class="pt-3 border-t border-gray-100">
|
||||
<p class="text-xs text-gray-400">Dibuat pada</p>
|
||||
<p class="text-sm text-gray-700">{{ $violation->created_at->format('d F Y H:i') }}</p>
|
||||
</div>
|
||||
@if($violation->updated_at != $violation->created_at)
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">Terakhir diubah</p>
|
||||
<p class="text-sm text-gray-700">{{ $violation->updated_at->format('d F Y H:i') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Evidence Photos --}}
|
||||
@if($violation->evidences->count() > 0)
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Bukti Foto</h3>
|
||||
<span class="text-xs text-gray-400">{{ $violation->evidences->count() }} file</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
@foreach($violation->evidences as $evidence)
|
||||
<a href="{{ $evidence->url }}" target="_blank"
|
||||
class="group relative block aspect-square rounded-xl overflow-hidden border border-gray-200 bg-gray-50 transition-all">
|
||||
<img src="{{ $evidence->url }}" class="w-full h-full object-cover scale-100 transition-transform duration-300" alt="{{ $evidence->original_name }}">
|
||||
<div class="absolute inset-0 bg-black/0 bg-black/0 transition-colors"></div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
|
||||
Broadcast::channel('violations', function () {
|
||||
return auth()->check();
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user