Initial commit
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Student;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GenerateStudentSelfServiceCodes extends Command
|
||||
{
|
||||
protected $signature = 'students:generate-self-service-codes {--reset : Generate ulang walau kode sudah ada}';
|
||||
protected $description = 'Generate kode akses self-service untuk siswa';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$students = Student::query()
|
||||
->whereNull('deleted_at')
|
||||
->when(! $this->option('reset'), fn ($query) => $query->whereNull('self_service_access_code'))
|
||||
->get();
|
||||
|
||||
if ($students->isEmpty()) {
|
||||
$this->info('Tidak ada siswa yang perlu dibuatkan kode akses.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$bar = $this->output->createProgressBar($students->count());
|
||||
$bar->start();
|
||||
|
||||
foreach ($students as $student) {
|
||||
$student->forceFill([
|
||||
'self_service_access_code' => $this->generateUniqueCode(),
|
||||
'self_service_code_generated_at' => now(),
|
||||
])->save();
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
$this->info('Kode akses self-service berhasil digenerate.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function generateUniqueCode(): string
|
||||
{
|
||||
do {
|
||||
$code = strtoupper(Str::random(8));
|
||||
} while (Student::where('self_service_access_code', $code)->exists());
|
||||
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ImportRegionsCommand extends Command
|
||||
{
|
||||
protected $signature = 'regions:import {--fresh : Kosongkan tabel wilayah sebelum import} {--download : Unduh ulang dataset CSV terbaru sebelum import}';
|
||||
protected $description = 'Import master wilayah Indonesia dari dataset publik dengan bulk insert dari CSV';
|
||||
|
||||
protected string $disk = 'local';
|
||||
protected string $datasetDir = 'regions';
|
||||
protected string $baseUrl = 'https://raw.githubusercontent.com/emsifa/api-wilayah-indonesia/master/data';
|
||||
protected int $chunkSize = 1000;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
if ($this->option('download') || ! $this->datasetExists()) {
|
||||
$this->downloadDatasets();
|
||||
}
|
||||
|
||||
if (! $this->datasetExists()) {
|
||||
$this->error('Dataset wilayah tidak ditemukan setelah proses download.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('fresh')) {
|
||||
$this->truncateTables();
|
||||
}
|
||||
|
||||
$this->importCsv('provinces', ['id', 'name'], ['id']);
|
||||
$this->importCsv('regencies', ['id', 'province_id', 'name'], ['id']);
|
||||
$this->importCsv('districts', ['id', 'regency_id', 'name'], ['id']);
|
||||
$this->importCsv('villages', ['id', 'district_id', 'name'], ['id']);
|
||||
|
||||
$counts = [
|
||||
'provinces' => DB::table('provinces')->count(),
|
||||
'regencies' => DB::table('regencies')->count(),
|
||||
'districts' => DB::table('districts')->count(),
|
||||
'villages' => DB::table('villages')->count(),
|
||||
];
|
||||
|
||||
$this->info('Import wilayah selesai.');
|
||||
foreach ($counts as $table => $count) {
|
||||
$this->line(sprintf('- %s: %s', $table, number_format($count, 0, ',', '.')));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Import wilayah gagal: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
protected function datasetExists(): bool
|
||||
{
|
||||
foreach (['provinces.csv', 'regencies.csv', 'districts.csv', 'villages.csv'] as $file) {
|
||||
if (! Storage::disk($this->disk)->exists($this->datasetDir . '/' . $file)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function downloadDatasets(): void
|
||||
{
|
||||
Storage::disk($this->disk)->makeDirectory($this->datasetDir);
|
||||
|
||||
$http = Http::timeout(120)->retry(3, 1000);
|
||||
|
||||
foreach (['provinces.csv', 'regencies.csv', 'districts.csv', 'villages.csv'] as $file) {
|
||||
$this->line('Mengunduh ' . $file . ' ...');
|
||||
$content = $http->get($this->baseUrl . '/' . $file)->throw()->body();
|
||||
Storage::disk($this->disk)->put($this->datasetDir . '/' . $file, $content);
|
||||
}
|
||||
}
|
||||
|
||||
protected function truncateTables(): void
|
||||
{
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=0');
|
||||
DB::table('villages')->truncate();
|
||||
DB::table('districts')->truncate();
|
||||
DB::table('regencies')->truncate();
|
||||
DB::table('provinces')->truncate();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=1');
|
||||
}
|
||||
|
||||
protected function importCsv(string $table, array $columns, array $uniqueBy): void
|
||||
{
|
||||
$path = Storage::disk($this->disk)->path($this->datasetDir . '/' . $table . '.csv');
|
||||
$handle = fopen($path, 'r');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('Tidak bisa membuka file dataset: ' . $path);
|
||||
}
|
||||
|
||||
$this->line('Import ' . $table . ' ...');
|
||||
|
||||
$rows = [];
|
||||
while (($data = fgetcsv($handle)) !== false) {
|
||||
if ($data === [null] || count($data) < count($columns)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = [];
|
||||
foreach ($columns as $index => $column) {
|
||||
$row[$column] = isset($data[$index]) ? trim((string) $data[$index]) : null;
|
||||
}
|
||||
|
||||
if (($row['id'] ?? null) === null || $row['id'] === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[$row['id']] = $row;
|
||||
|
||||
if (count($rows) >= $this->chunkSize) {
|
||||
DB::table($table)->upsert(array_values($rows), $uniqueBy, $columns);
|
||||
$rows = [];
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
if ($rows !== []) {
|
||||
DB::table($table)->upsert(array_values($rows), $uniqueBy, $columns);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AcademicYear;
|
||||
use App\Models\StudentAcademicHistory;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use RealRashid\SweetAlert\Facades\Alert;
|
||||
|
||||
class AcademicYearController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$search = trim((string) $request->get('search'));
|
||||
|
||||
$academicYears = AcademicYear::withCount(['classes', 'students'])
|
||||
->when($search !== '', function ($query) use ($search) {
|
||||
$query->where('name', 'like', "%{$search}%")
|
||||
->orWhere('start_year', 'like', "%{$search}%")
|
||||
->orWhere('end_year', 'like', "%{$search}%");
|
||||
})
|
||||
->latest()
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
return view('academic-years.index', compact('academicYears', 'search'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('academic-years.create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:50', 'unique:academic_years,name'],
|
||||
'start_year' => ['required', 'integer', 'digits:4'],
|
||||
'end_year' => ['required', 'integer', 'digits:4', 'gte:start_year'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
|
||||
if ($data['is_active']) {
|
||||
AcademicYear::query()->update(['is_active' => false]);
|
||||
}
|
||||
|
||||
AcademicYear::create($data);
|
||||
|
||||
return redirect()->route('academic-years.index')->with('success', 'Tahun angkatan berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
public function show(AcademicYear $academicYear): RedirectResponse
|
||||
{
|
||||
return redirect()->route('academic-years.edit', $academicYear);
|
||||
}
|
||||
|
||||
public function edit(AcademicYear $academicYear): View
|
||||
{
|
||||
return view('academic-years.edit', [
|
||||
'academicYear' => $academicYear,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, AcademicYear $academicYear): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:50', Rule::unique('academic_years', 'name')->ignore($academicYear->id)],
|
||||
'start_year' => ['required', 'integer', 'digits:4'],
|
||||
'end_year' => ['required', 'integer', 'digits:4', 'gte:start_year'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
|
||||
if ($data['is_active']) {
|
||||
AcademicYear::whereKeyNot($academicYear->id)->update(['is_active' => false]);
|
||||
}
|
||||
|
||||
$academicYear->update($data);
|
||||
|
||||
return redirect()->route('academic-years.index')->with('success', 'Tahun angkatan berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(AcademicYear $academicYear): RedirectResponse
|
||||
{
|
||||
$academicYear->loadCount('students');
|
||||
$classesWithStudentsCount = $academicYear->classes()->withCount('students')->get()->where('students_count', '>', 0)->count();
|
||||
$emptyClassesCount = $academicYear->classes()->withCount('students')->get()->where('students_count', 0)->count();
|
||||
|
||||
if ($academicYear->students_count > 0 || $classesWithStudentsCount > 0) {
|
||||
Alert::error('Tidak bisa dihapus', 'Tahun angkatan ini masih digunakan oleh data siswa aktif atau kelas yang masih berisi siswa.');
|
||||
|
||||
return redirect()->route('academic-years.index')->withErrors([
|
||||
'delete' => 'Tahun angkatan tidak bisa dihapus karena masih digunakan oleh data siswa aktif atau kelas yang masih berisi siswa.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($academicYear) {
|
||||
$classIds = $academicYear->classes()->pluck('id');
|
||||
|
||||
StudentAcademicHistory::query()
|
||||
->whereIn('class_id', $classIds)
|
||||
->delete();
|
||||
|
||||
$academicYear->classes()->delete();
|
||||
$academicYear->delete();
|
||||
});
|
||||
|
||||
$message = $emptyClassesCount > 0
|
||||
? "Tahun angkatan berhasil dihapus beserta {$emptyClassesCount} kelas kosong terkait."
|
||||
: 'Tahun angkatan berhasil dihapus.';
|
||||
|
||||
Alert::success('Berhasil', $message);
|
||||
|
||||
return redirect()->route('academic-years.index')->with('success', $message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\District;
|
||||
use App\Models\Regency;
|
||||
use App\Models\Village;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class RegionController extends Controller
|
||||
{
|
||||
public function regencies(string $provinceId): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
Regency::where('province_id', $provinceId)->orderBy('name')->get(['id', 'name'])
|
||||
);
|
||||
}
|
||||
|
||||
public function districts(string $regencyId): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
District::where('regency_id', $regencyId)->orderBy('name')->get(['id', 'name'])
|
||||
);
|
||||
}
|
||||
|
||||
public function villages(string $districtId): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
Village::where('district_id', $districtId)->orderBy('name')->get(['id', 'name'])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the login view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password view.
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $request->user()->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the email verification prompt.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse|View
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: view('auth.verify-email');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.reset-password', ['request' => $request]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $status == Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return $status == Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AppSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use RealRashid\SweetAlert\Facades\Alert;
|
||||
|
||||
class BrandingSettingController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('settings.branding', [
|
||||
'branding' => AppSetting::branding(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'app_name' => ['required', 'string', 'max:255'],
|
||||
'app_short_name' => ['required', 'string', 'max:20'],
|
||||
'app_logo' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp,svg', 'max:2048'],
|
||||
'app_favicon' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp,svg,ico', 'max:1024'],
|
||||
'public_portal_logo' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp,svg', 'max:2048'],
|
||||
'remove_app_logo' => ['nullable', 'boolean'],
|
||||
'remove_app_favicon' => ['nullable', 'boolean'],
|
||||
'remove_public_portal_logo' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
AppSetting::setValue('app_name', $data['app_name']);
|
||||
AppSetting::setValue('app_short_name', $data['app_short_name']);
|
||||
|
||||
$this->handleAssetUpdate($request, 'app_logo', 'branding/app-logo', (bool) ($data['remove_app_logo'] ?? false));
|
||||
$this->handleAssetUpdate($request, 'app_favicon', 'branding/app-favicon', (bool) ($data['remove_app_favicon'] ?? false));
|
||||
$this->handleAssetUpdate($request, 'public_portal_logo', 'branding/public-portal-logo', (bool) ($data['remove_public_portal_logo'] ?? false));
|
||||
|
||||
Alert::success('Berhasil', 'Pengaturan branding aplikasi berhasil diperbarui.');
|
||||
|
||||
return redirect()->route('settings.branding.index')->with('success', 'Pengaturan branding aplikasi berhasil diperbarui.');
|
||||
}
|
||||
|
||||
protected function handleAssetUpdate(Request $request, string $field, string $directory, bool $remove = false): void
|
||||
{
|
||||
$currentPath = AppSetting::getValue($field);
|
||||
|
||||
if ($remove) {
|
||||
if ($currentPath && Storage::disk('public')->exists($currentPath)) {
|
||||
Storage::disk('public')->delete($currentPath);
|
||||
}
|
||||
|
||||
AppSetting::setValue($field, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $request->hasFile($field)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($currentPath && Storage::disk('public')->exists($currentPath)) {
|
||||
Storage::disk('public')->delete($currentPath);
|
||||
}
|
||||
|
||||
$path = $request->file($field)->store($directory, 'public');
|
||||
AppSetting::setValue($field, $path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AcademicYear;
|
||||
use App\Models\Department;
|
||||
use App\Models\DocumentType;
|
||||
use App\Models\SchoolClass;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentExit;
|
||||
use App\Models\StudentTransfer;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(): View
|
||||
{
|
||||
$requiredDocumentTypeIds = DocumentType::where('is_active', true)
|
||||
->where('is_required', true)
|
||||
->pluck('id');
|
||||
|
||||
$students = Student::with('documents')->get();
|
||||
|
||||
$completion = $this->calculateCompletion($students, $requiredDocumentTypeIds);
|
||||
|
||||
return view('dashboard', [
|
||||
'stats' => [
|
||||
'academicYears' => AcademicYear::count(),
|
||||
'departments' => Department::count(),
|
||||
'classes' => SchoolClass::count(),
|
||||
'documentTypes' => DocumentType::count(),
|
||||
'students' => Student::count(),
|
||||
'studentsDropped' => StudentExit::count(),
|
||||
'studentsTransferred' => StudentTransfer::count(),
|
||||
'studentsGraduated' => Student::where('status', 'graduated')->count(),
|
||||
'studentsCompleteDocuments' => $completion['complete'],
|
||||
'studentsIncompleteDocuments' => $completion['incomplete'],
|
||||
'studentsVerifiedDocuments' => $completion['verified_complete'],
|
||||
'studentsUnverifiedDocuments' => $completion['verified_incomplete'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function calculateCompletion(Collection $students, Collection $requiredDocumentTypeIds): array
|
||||
{
|
||||
if ($requiredDocumentTypeIds->isEmpty()) {
|
||||
return [
|
||||
'complete' => $students->count(),
|
||||
'incomplete' => 0,
|
||||
'verified_complete' => $students->count(),
|
||||
'verified_incomplete' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$complete = 0;
|
||||
$verifiedComplete = 0;
|
||||
|
||||
foreach ($students as $student) {
|
||||
$requiredDocuments = $student->documents->whereIn('document_type_id', $requiredDocumentTypeIds);
|
||||
|
||||
$uploadedRequired = $requiredDocuments
|
||||
->pluck('document_type_id')
|
||||
->unique()
|
||||
->count();
|
||||
|
||||
$verifiedRequired = $requiredDocuments
|
||||
->where('verification_status', 'valid')
|
||||
->pluck('document_type_id')
|
||||
->unique()
|
||||
->count();
|
||||
|
||||
if ($uploadedRequired >= $requiredDocumentTypeIds->count()) {
|
||||
$complete++;
|
||||
}
|
||||
|
||||
if ($verifiedRequired >= $requiredDocumentTypeIds->count()) {
|
||||
$verifiedComplete++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'complete' => $complete,
|
||||
'incomplete' => max($students->count() - $complete, 0),
|
||||
'verified_complete' => $verifiedComplete,
|
||||
'verified_incomplete' => max($students->count() - $verifiedComplete, 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\StudentAcademicHistory;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use RealRashid\SweetAlert\Facades\Alert;
|
||||
|
||||
class DepartmentController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$search = trim((string) $request->get('search'));
|
||||
|
||||
$departments = Department::withCount(['classes', 'students'])
|
||||
->when($search !== '', function ($query) use ($search) {
|
||||
$query->where('code', 'like', "%{$search}%")
|
||||
->orWhere('name', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
})
|
||||
->latest()
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
return view('departments.index', compact('departments', 'search'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('departments.create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'code' => ['required', 'string', 'max:20', 'unique:departments,code'],
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$data['code'] = strtoupper($data['code']);
|
||||
|
||||
Department::create($data);
|
||||
|
||||
return redirect()->route('departments.index')->with('success', 'Program keahlian berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
public function show(Department $department): RedirectResponse
|
||||
{
|
||||
return redirect()->route('departments.edit', $department);
|
||||
}
|
||||
|
||||
public function edit(Department $department): View
|
||||
{
|
||||
return view('departments.edit', [
|
||||
'department' => $department,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Department $department): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'code' => ['required', 'string', 'max:20', Rule::unique('departments', 'code')->ignore($department->id)],
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
$data['code'] = strtoupper($data['code']);
|
||||
|
||||
$department->update($data);
|
||||
|
||||
return redirect()->route('departments.index')->with('success', 'Program keahlian berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(Department $department): RedirectResponse
|
||||
{
|
||||
$department->loadCount('students');
|
||||
$classesWithStudentsCount = $department->classes()->withCount('students')->get()->where('students_count', '>', 0)->count();
|
||||
$emptyClassesCount = $department->classes()->withCount('students')->get()->where('students_count', 0)->count();
|
||||
|
||||
if ($department->students_count > 0 || $classesWithStudentsCount > 0) {
|
||||
Alert::error('Tidak bisa dihapus', 'Program keahlian ini masih digunakan oleh data siswa aktif atau kelas yang masih berisi siswa.');
|
||||
|
||||
return redirect()->route('departments.index')->withErrors([
|
||||
'delete' => 'Program keahlian tidak bisa dihapus karena masih digunakan oleh data siswa aktif atau kelas yang masih berisi siswa.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($department) {
|
||||
$classIds = $department->classes()->pluck('id');
|
||||
|
||||
StudentAcademicHistory::query()
|
||||
->whereIn('class_id', $classIds)
|
||||
->delete();
|
||||
|
||||
$department->classes()->delete();
|
||||
$department->delete();
|
||||
});
|
||||
|
||||
$message = $emptyClassesCount > 0
|
||||
? "Program keahlian berhasil dihapus beserta {$emptyClassesCount} kelas kosong terkait."
|
||||
: 'Program keahlian berhasil dihapus.';
|
||||
|
||||
Alert::success('Berhasil', $message);
|
||||
|
||||
return redirect()->route('departments.index')->with('success', $message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DocumentType;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use RealRashid\SweetAlert\Facades\Alert;
|
||||
|
||||
class DocumentTypeController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$search = trim((string) $request->get('search'));
|
||||
|
||||
$documentTypes = DocumentType::withCount('studentDocuments')
|
||||
->when($search !== '', function ($query) use ($search) {
|
||||
$query->where('code', 'like', "%{$search}%")
|
||||
->orWhere('name', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%")
|
||||
->orWhere('allowed_extensions', 'like', "%{$search}%");
|
||||
})
|
||||
->latest()
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
return view('document-types.index', compact('documentTypes', 'search'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('document-types.create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'code' => ['required', 'string', 'max:50', 'unique:document_types,code'],
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'max_file_size_kb' => ['nullable', 'integer', 'min:1', 'max:10240'],
|
||||
'allowed_extensions' => ['nullable', 'string', 'max:255'],
|
||||
'is_required' => ['nullable', 'boolean'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$data['code'] = strtoupper($data['code']);
|
||||
$data['allowed_extensions'] = $this->normalizeExtensions($data['allowed_extensions'] ?? null);
|
||||
$data['is_required'] = $request->boolean('is_required');
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
|
||||
DocumentType::create($data);
|
||||
|
||||
return redirect()->route('document-types.index')->with('success', 'Jenis dokumen berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
public function show(DocumentType $documentType): RedirectResponse
|
||||
{
|
||||
return redirect()->route('document-types.edit', $documentType);
|
||||
}
|
||||
|
||||
public function edit(DocumentType $documentType): View
|
||||
{
|
||||
return view('document-types.edit', [
|
||||
'documentType' => $documentType,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, DocumentType $documentType): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'code' => ['required', 'string', 'max:50', Rule::unique('document_types', 'code')->ignore($documentType->id)],
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'max_file_size_kb' => ['nullable', 'integer', 'min:1', 'max:10240'],
|
||||
'allowed_extensions' => ['nullable', 'string', 'max:255'],
|
||||
'is_required' => ['nullable', 'boolean'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$data['code'] = strtoupper($data['code']);
|
||||
$data['allowed_extensions'] = $this->normalizeExtensions($data['allowed_extensions'] ?? null);
|
||||
$data['is_required'] = $request->boolean('is_required');
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
|
||||
$documentType->update($data);
|
||||
|
||||
return redirect()->route('document-types.index')->with('success', 'Jenis dokumen berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(DocumentType $documentType): RedirectResponse
|
||||
{
|
||||
$documentType->loadCount('studentDocuments');
|
||||
|
||||
if ($documentType->student_documents_count > 0) {
|
||||
Alert::error('Tidak bisa dihapus', 'Jenis dokumen ini masih digunakan oleh data dokumen siswa.');
|
||||
|
||||
return redirect()->route('document-types.index')->withErrors([
|
||||
'delete' => "Jenis dokumen '{$documentType->name}' tidak bisa dihapus karena masih digunakan oleh {$documentType->student_documents_count} dokumen siswa.",
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$documentType->delete();
|
||||
} catch (QueryException $exception) {
|
||||
Alert::error('Tidak bisa dihapus', 'Jenis dokumen ini masih direferensikan oleh data lain di sistem.');
|
||||
|
||||
return redirect()->route('document-types.index')->withErrors([
|
||||
'delete' => "Jenis dokumen '{$documentType->name}' tidak bisa dihapus karena masih direferensikan oleh data lain di sistem. Untuk kasus ini, cek dokumen siswa yang masih memakai jenis dokumen tersebut terlebih dahulu.",
|
||||
]);
|
||||
}
|
||||
|
||||
Alert::success('Berhasil', 'Jenis dokumen berhasil dihapus.');
|
||||
|
||||
return redirect()->route('document-types.index')->with('success', 'Jenis dokumen berhasil dihapus.');
|
||||
}
|
||||
|
||||
protected function normalizeExtensions(?string $extensions): ?string
|
||||
{
|
||||
if (! $extensions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = collect(explode(',', $extensions))
|
||||
->map(fn ($item) => strtolower(trim($item)))
|
||||
->filter()
|
||||
->map(fn ($item) => ltrim($item, '.'))
|
||||
->unique()
|
||||
->implode(',');
|
||||
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AcademicYear;
|
||||
use App\Models\Department;
|
||||
use App\Models\DocumentType;
|
||||
use App\Models\SchoolClass;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentDocument;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use RealRashid\SweetAlert\Facades\Alert;
|
||||
|
||||
class MaintenanceController extends Controller
|
||||
{
|
||||
protected array $availableTargets = [
|
||||
'academic_years' => 'Tahun Angkatan',
|
||||
'departments' => 'Program Keahlian',
|
||||
'classes' => 'Kelas',
|
||||
'document_types' => 'Jenis Dokumen',
|
||||
'students' => 'Data Siswa',
|
||||
'student_parents' => 'Orang Tua / Wali',
|
||||
'student_documents' => 'Dokumen Siswa + file storage',
|
||||
'student_academic_histories' => 'Histori Akademik Siswa',
|
||||
];
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
return view('maintenance.reset-data', [
|
||||
'stats' => [
|
||||
'academicYears' => AcademicYear::count(),
|
||||
'departments' => Department::count(),
|
||||
'classes' => SchoolClass::count(),
|
||||
'documentTypes' => DocumentType::count(),
|
||||
'students' => Student::withTrashed()->count(),
|
||||
'studentParents' => DB::table('student_parents')->count(),
|
||||
'studentDocuments' => StudentDocument::withTrashed()->count(),
|
||||
'studentAcademicHistories' => DB::table('student_academic_histories')->count(),
|
||||
],
|
||||
'targets' => $this->availableTargets,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function reset(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'targets' => ['required', 'array', 'min:1'],
|
||||
'targets.*' => ['string'],
|
||||
'admin_password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if (! Hash::check($data['admin_password'], (string) $request->user()->password)) {
|
||||
return back()->withErrors([
|
||||
'admin_password' => 'Password admin tidak valid.',
|
||||
])->withInput();
|
||||
}
|
||||
|
||||
$targets = collect($data['targets'])
|
||||
->filter(fn ($target) => array_key_exists($target, $this->availableTargets))
|
||||
->values();
|
||||
|
||||
if ($targets->isEmpty()) {
|
||||
return back()->withErrors([
|
||||
'targets' => 'Pilih minimal satu kelompok data yang ingin dikosongkan.',
|
||||
])->withInput();
|
||||
}
|
||||
|
||||
$resolvedTargets = $this->resolveTargets($targets->all());
|
||||
|
||||
DB::transaction(function () use ($resolvedTargets) {
|
||||
if (in_array('students', $resolvedTargets, true)) {
|
||||
Student::withTrashed()->get()->each->forceDelete();
|
||||
}
|
||||
|
||||
if (in_array('student_documents', $resolvedTargets, true)) {
|
||||
StudentDocument::withTrashed()->get()->each(function (StudentDocument $document) {
|
||||
if ($document->file_path && Storage::disk('public')->exists($document->file_path)) {
|
||||
Storage::disk('public')->delete($document->file_path);
|
||||
}
|
||||
});
|
||||
DB::table('student_documents')->delete();
|
||||
}
|
||||
|
||||
if (in_array('student_parents', $resolvedTargets, true)) {
|
||||
DB::table('student_parents')->delete();
|
||||
}
|
||||
|
||||
if (in_array('student_academic_histories', $resolvedTargets, true)) {
|
||||
DB::table('student_academic_histories')->delete();
|
||||
}
|
||||
|
||||
if (in_array('students', $resolvedTargets, true)) {
|
||||
DB::table('students')->delete();
|
||||
}
|
||||
|
||||
if (in_array('classes', $resolvedTargets, true)) {
|
||||
DB::table('classes')->delete();
|
||||
}
|
||||
|
||||
if (in_array('document_types', $resolvedTargets, true)) {
|
||||
DB::table('document_types')->delete();
|
||||
}
|
||||
|
||||
if (in_array('departments', $resolvedTargets, true)) {
|
||||
DB::table('departments')->delete();
|
||||
}
|
||||
|
||||
if (in_array('academic_years', $resolvedTargets, true)) {
|
||||
DB::table('academic_years')->delete();
|
||||
}
|
||||
});
|
||||
|
||||
if (in_array('student_documents', $resolvedTargets, true)) {
|
||||
Storage::disk('public')->deleteDirectory('student-documents');
|
||||
}
|
||||
|
||||
$labels = collect($resolvedTargets)
|
||||
->map(fn ($target) => $this->availableTargets[$target] ?? $target)
|
||||
->implode(', ');
|
||||
|
||||
Alert::success('Berhasil', 'Data berhasil dikosongkan: ' . $labels . '.');
|
||||
|
||||
return redirect()->route('settings.reset-data.index')->with('success', 'Data berhasil dikosongkan: ' . $labels . '.');
|
||||
}
|
||||
|
||||
protected function resolveTargets(array $targets): array
|
||||
{
|
||||
$resolved = collect($targets);
|
||||
|
||||
if ($resolved->contains('students')) {
|
||||
$resolved = $resolved->merge(['student_documents', 'student_parents', 'student_academic_histories']);
|
||||
}
|
||||
|
||||
if ($resolved->contains('classes')) {
|
||||
$resolved = $resolved->merge(['students', 'student_academic_histories']);
|
||||
}
|
||||
|
||||
if ($resolved->contains('document_types')) {
|
||||
$resolved = $resolved->merge(['student_documents']);
|
||||
}
|
||||
|
||||
if ($resolved->contains('departments')) {
|
||||
$resolved = $resolved->merge(['classes', 'students', 'student_academic_histories']);
|
||||
}
|
||||
|
||||
if ($resolved->contains('academic_years')) {
|
||||
$resolved = $resolved->merge(['classes', 'students', 'student_academic_histories']);
|
||||
}
|
||||
|
||||
return $resolved->unique()->values()->all();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ProfileUpdateRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the user's profile form.
|
||||
*/
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DocumentType;
|
||||
use App\Models\Student;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PublicStudentLookupController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = trim((string) $request->get('q'));
|
||||
$students = collect();
|
||||
$selectedStudent = null;
|
||||
$requiredDocumentSummary = null;
|
||||
$matchedBy = null;
|
||||
$selectedStudentId = $request->integer('student');
|
||||
|
||||
if ($query !== '') {
|
||||
$students = Student::query()
|
||||
->with([
|
||||
'academicYear',
|
||||
'department',
|
||||
'classRoom',
|
||||
'documents.documentType',
|
||||
])
|
||||
->where(function ($builder) use ($query) {
|
||||
$builder->where('student_number', 'like', "%{$query}%")
|
||||
->orWhere('nisn', 'like', "%{$query}%")
|
||||
->orWhere('full_name', 'like', "%{$query}%");
|
||||
})
|
||||
->orderByRaw('CASE WHEN student_number = ? THEN 0 WHEN nisn = ? THEN 1 WHEN full_name = ? THEN 2 ELSE 3 END', [$query, $query, $query])
|
||||
->orderBy('full_name')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$selectedStudent = $students->firstWhere('id', $selectedStudentId) ?? $students->first();
|
||||
|
||||
if ($selectedStudent) {
|
||||
$requiredDocumentSummary = $this->buildRequiredDocumentSummary($selectedStudent);
|
||||
$matchedBy = $this->detectMatchedBy($selectedStudent, $query);
|
||||
}
|
||||
|
||||
$students->each(function (Student $student) {
|
||||
$student->public_photo_url = $this->resolvePhotoUrl($student);
|
||||
});
|
||||
}
|
||||
|
||||
return view('public.student-lookup', [
|
||||
'query' => $query,
|
||||
'students' => $students,
|
||||
'student' => $selectedStudent,
|
||||
'requiredDocumentSummary' => $requiredDocumentSummary,
|
||||
'matchedBy' => $matchedBy,
|
||||
'selectedStudentId' => $selectedStudent?->id,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function detectMatchedBy(Student $student, string $query): ?string
|
||||
{
|
||||
if ($student->student_number && strcasecmp($student->student_number, $query) === 0) {
|
||||
return 'NIS';
|
||||
}
|
||||
|
||||
if ($student->nisn && strcasecmp($student->nisn, $query) === 0) {
|
||||
return 'NISN';
|
||||
}
|
||||
|
||||
return 'Nama';
|
||||
}
|
||||
|
||||
protected function resolvePhotoUrl(Student $student): ?string
|
||||
{
|
||||
$photoDocument = $student->documents
|
||||
->filter(function ($document) {
|
||||
$code = strtoupper((string) ($document->documentType?->code ?? ''));
|
||||
$name = strtolower((string) ($document->documentType?->name ?? ''));
|
||||
|
||||
return $code === 'PAS_FOTO'
|
||||
|| $code === 'PASFOTO'
|
||||
|| str_contains($name, 'pas foto')
|
||||
|| str_contains($name, 'pasfoto');
|
||||
})
|
||||
->sortByDesc('created_at')
|
||||
->first();
|
||||
|
||||
if (! $photoDocument?->file_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return asset('storage/' . $photoDocument->file_path);
|
||||
}
|
||||
|
||||
protected function buildRequiredDocumentSummary(Student $student): array
|
||||
{
|
||||
$requiredTypes = DocumentType::query()
|
||||
->where('is_active', true)
|
||||
->where('is_required', true)
|
||||
->get();
|
||||
|
||||
if ($requiredTypes->isEmpty()) {
|
||||
return [
|
||||
'total' => 0,
|
||||
'uploaded' => 0,
|
||||
'verified' => 0,
|
||||
'missing' => 0,
|
||||
'unverified' => 0,
|
||||
'percentage' => 100,
|
||||
'verification_percentage' => 100,
|
||||
'missing_items' => collect(),
|
||||
'unverified_items' => collect(),
|
||||
];
|
||||
}
|
||||
|
||||
$documents = $student->documents;
|
||||
|
||||
$uploaded = 0;
|
||||
$verified = 0;
|
||||
$missingItems = collect();
|
||||
$unverifiedItems = collect();
|
||||
|
||||
foreach ($requiredTypes as $type) {
|
||||
$matchingDocuments = $documents->where('document_type_id', $type->id);
|
||||
$latestDocument = $matchingDocuments->sortByDesc('created_at')->first();
|
||||
|
||||
if (! $latestDocument) {
|
||||
$missingItems->push($type->name);
|
||||
continue;
|
||||
}
|
||||
|
||||
$uploaded++;
|
||||
|
||||
if ($latestDocument->verification_status === 'valid') {
|
||||
$verified++;
|
||||
} else {
|
||||
$unverifiedItems->push($type->name);
|
||||
}
|
||||
}
|
||||
|
||||
$total = $requiredTypes->count();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'uploaded' => $uploaded,
|
||||
'verified' => $verified,
|
||||
'missing' => max($total - $uploaded, 0),
|
||||
'unverified' => max($total - $verified, 0),
|
||||
'percentage' => $total > 0 ? (int) round(($uploaded / $total) * 100) : 100,
|
||||
'verification_percentage' => $total > 0 ? (int) round(($verified / $total) * 100) : 100,
|
||||
'missing_items' => $missingItems,
|
||||
'unverified_items' => $unverifiedItems,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Province;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentParent;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PublicStudentSelfServiceController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('public.student-self-service.verify');
|
||||
}
|
||||
|
||||
public function verify(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'identity_number' => ['required', 'string', 'max:30'],
|
||||
'access_code' => ['required', 'string', 'max:20'],
|
||||
], [
|
||||
'identity_number.required' => 'NIS atau NISN wajib diisi.',
|
||||
'access_code.required' => 'Kode akses wajib diisi.',
|
||||
]);
|
||||
|
||||
$student = Student::query()
|
||||
->where('self_service_access_code', strtoupper(trim($data['access_code'])))
|
||||
->where(function ($query) use ($data) {
|
||||
$query->where('student_number', $data['identity_number'])
|
||||
->orWhere('nisn', $data['identity_number']);
|
||||
})
|
||||
->first();
|
||||
|
||||
if (! $student) {
|
||||
return back()
|
||||
->withInput()
|
||||
->withErrors([
|
||||
'identity_number' => 'Data siswa tidak ditemukan atau kode akses tidak cocok.',
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('student_self_service.student_id', $student->id);
|
||||
$request->session()->put('student_self_service.verified_at', now()->toIso8601String());
|
||||
|
||||
$student->forceFill([
|
||||
'self_service_last_access_at' => now(),
|
||||
'self_service_status' => $student->self_service_status === 'submitted' ? 'submitted' : 'draft',
|
||||
])->save();
|
||||
|
||||
return redirect()->route('public.student-self-service.edit');
|
||||
}
|
||||
|
||||
public function edit(Request $request): View|RedirectResponse
|
||||
{
|
||||
$student = $this->resolveVerifiedStudent($request);
|
||||
|
||||
if (! $student) {
|
||||
return redirect()->route('public.student-self-service.index')
|
||||
->withErrors([
|
||||
'identity_number' => 'Sesi pelengkapan data tidak valid atau sudah berakhir. Silakan verifikasi ulang.',
|
||||
]);
|
||||
}
|
||||
|
||||
$student->load(['parents', 'academicYear', 'department', 'classRoom']);
|
||||
|
||||
return view('public.student-self-service.edit', [
|
||||
'student' => $student,
|
||||
'genders' => [
|
||||
'L' => 'Laki-laki',
|
||||
'P' => 'Perempuan',
|
||||
],
|
||||
'religions' => [
|
||||
'Islam' => 'Islam',
|
||||
'Kristen' => 'Kristen',
|
||||
'Katolik' => 'Katolik',
|
||||
'Hindu' => 'Hindu',
|
||||
'Buddha' => 'Buddha',
|
||||
'Khonghucu' => 'Khonghucu',
|
||||
'Lainnya' => 'Lainnya',
|
||||
],
|
||||
'provinces' => Province::orderBy('name')->pluck('name', 'id'),
|
||||
'parentRelationLabels' => [
|
||||
'father' => 'Ayah',
|
||||
'mother' => 'Ibu',
|
||||
'guardian' => 'Wali',
|
||||
],
|
||||
'educationOptions' => [
|
||||
'Tidak Sekolah' => 'Tidak Sekolah',
|
||||
'Tidak Tamat SD' => 'Tidak Tamat SD',
|
||||
'SD' => 'SD',
|
||||
'SMP' => 'SMP',
|
||||
'SLTA' => 'SLTA',
|
||||
'D1' => 'D1',
|
||||
'D2' => 'D2',
|
||||
'D3' => 'D3',
|
||||
'D4' => 'D4',
|
||||
'S1' => 'S1',
|
||||
'S2' => 'S2',
|
||||
'S3' => 'S3',
|
||||
'Lainnya' => 'Lainnya',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$student = $this->resolveVerifiedStudent($request);
|
||||
|
||||
if (! $student) {
|
||||
return redirect()->route('public.student-self-service.index')
|
||||
->withErrors([
|
||||
'identity_number' => 'Sesi pelengkapan data tidak valid atau sudah berakhir. Silakan verifikasi ulang.',
|
||||
]);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'full_name' => ['required', 'string', 'max:150'],
|
||||
'gender' => ['required', Rule::in(['L', 'P'])],
|
||||
'place_of_birth' => ['nullable', 'string', 'max:100'],
|
||||
'date_of_birth' => ['nullable', 'date'],
|
||||
'religion' => ['nullable', 'string', 'max:50'],
|
||||
'nationality' => ['nullable', 'string', 'max:50'],
|
||||
'family_card_number' => ['nullable', 'string', 'max:30'],
|
||||
'child_order' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
'number_of_siblings' => ['nullable', 'integer', 'min:0', 'max:50'],
|
||||
'phone_number' => ['nullable', 'string', 'max:30'],
|
||||
'email' => ['nullable', 'email', 'max:100'],
|
||||
'address' => ['nullable', 'string'],
|
||||
'province_id' => ['nullable', 'exists:provinces,id'],
|
||||
'regency_id' => ['nullable', 'exists:regencies,id'],
|
||||
'district_id' => ['nullable', 'exists:districts,id'],
|
||||
'village_id' => ['nullable', 'exists:villages,id'],
|
||||
'postal_code' => ['nullable', 'string', 'max:10'],
|
||||
'parents.father.name' => ['nullable', 'string', 'max:150'],
|
||||
'parents.father.nik' => ['nullable', 'string', 'max:30'],
|
||||
'parents.father.birth_year' => ['nullable', 'integer', 'min:1900', 'max:' . now()->year],
|
||||
'parents.father.education' => ['nullable', Rule::in(['Tidak Sekolah', 'Tidak Tamat SD', 'SD', 'SMP', 'SLTA', 'D1', 'D2', 'D3', 'D4', 'S1', 'S2', 'S3', 'Lainnya'])],
|
||||
'parents.father.occupation' => ['nullable', 'string', 'max:100'],
|
||||
'parents.father.phone_number' => ['nullable', 'string', 'max:30'],
|
||||
'parents.father.address' => ['nullable', 'string'],
|
||||
'parents.father.is_alive' => ['nullable', 'boolean'],
|
||||
'parents.mother.name' => ['nullable', 'string', 'max:150'],
|
||||
'parents.mother.nik' => ['nullable', 'string', 'max:30'],
|
||||
'parents.mother.birth_year' => ['nullable', 'integer', 'min:1900', 'max:' . now()->year],
|
||||
'parents.mother.education' => ['nullable', Rule::in(['Tidak Sekolah', 'Tidak Tamat SD', 'SD', 'SMP', 'SLTA', 'D1', 'D2', 'D3', 'D4', 'S1', 'S2', 'S3', 'Lainnya'])],
|
||||
'parents.mother.occupation' => ['nullable', 'string', 'max:100'],
|
||||
'parents.mother.phone_number' => ['nullable', 'string', 'max:30'],
|
||||
'parents.mother.address' => ['nullable', 'string'],
|
||||
'parents.mother.is_alive' => ['nullable', 'boolean'],
|
||||
'parents.guardian.name' => ['nullable', 'string', 'max:150'],
|
||||
'parents.guardian.nik' => ['nullable', 'string', 'max:30'],
|
||||
'parents.guardian.birth_year' => ['nullable', 'integer', 'min:1900', 'max:' . now()->year],
|
||||
'parents.guardian.education' => ['nullable', Rule::in(['Tidak Sekolah', 'Tidak Tamat SD', 'SD', 'SMP', 'SLTA', 'D1', 'D2', 'D3', 'D4', 'S1', 'S2', 'S3', 'Lainnya'])],
|
||||
'parents.guardian.occupation' => ['nullable', 'string', 'max:100'],
|
||||
'parents.guardian.phone_number' => ['nullable', 'string', 'max:30'],
|
||||
'parents.guardian.address' => ['nullable', 'string'],
|
||||
'parents.guardian.is_alive' => ['nullable', 'boolean'],
|
||||
'submit_action' => ['required', Rule::in(['draft', 'submit'])],
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($student, $data) {
|
||||
$student->fill([
|
||||
'full_name' => $data['full_name'],
|
||||
'gender' => $data['gender'],
|
||||
'place_of_birth' => $data['place_of_birth'] ?? null,
|
||||
'date_of_birth' => $data['date_of_birth'] ?? null,
|
||||
'religion' => $data['religion'] ?? null,
|
||||
'nationality' => $data['nationality'] ?? 'Indonesia',
|
||||
'family_card_number' => $data['family_card_number'] ?? null,
|
||||
'child_order' => $data['child_order'] ?? null,
|
||||
'number_of_siblings' => $data['number_of_siblings'] ?? null,
|
||||
'phone_number' => $data['phone_number'] ?? null,
|
||||
'email' => $data['email'] ?? null,
|
||||
'address' => $data['address'] ?? null,
|
||||
'province_id' => $data['province_id'] ?? null,
|
||||
'regency_id' => $data['regency_id'] ?? null,
|
||||
'district_id' => $data['district_id'] ?? null,
|
||||
'village_id' => $data['village_id'] ?? null,
|
||||
'postal_code' => $data['postal_code'] ?? null,
|
||||
'province' => ! empty($data['province_id']) ? DB::table('provinces')->where('id', $data['province_id'])->value('name') : null,
|
||||
'city' => ! empty($data['regency_id']) ? DB::table('regencies')->where('id', $data['regency_id'])->value('name') : null,
|
||||
'district' => ! empty($data['district_id']) ? DB::table('districts')->where('id', $data['district_id'])->value('name') : null,
|
||||
'village' => ! empty($data['village_id']) ? DB::table('villages')->where('id', $data['village_id'])->value('name') : null,
|
||||
'self_service_status' => $data['submit_action'] === 'submit' ? 'submitted' : 'draft',
|
||||
'self_service_last_access_at' => now(),
|
||||
'self_service_submitted_at' => $data['submit_action'] === 'submit' ? now() : null,
|
||||
])->save();
|
||||
|
||||
foreach (['father', 'mother', 'guardian'] as $relationType) {
|
||||
$parentData = $data['parents'][$relationType] ?? [];
|
||||
$hasMeaningfulValue = collect($parentData)
|
||||
->except('is_alive')
|
||||
->filter(fn ($value) => filled($value))
|
||||
->isNotEmpty();
|
||||
|
||||
$existingParent = $student->parents()->where('relation_type', $relationType)->first();
|
||||
|
||||
if (! $hasMeaningfulValue) {
|
||||
if ($existingParent) {
|
||||
$existingParent->delete();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'name' => $parentData['name'] ?? '-',
|
||||
'nik' => $parentData['nik'] ?? null,
|
||||
'birth_year' => $parentData['birth_year'] ?? null,
|
||||
'education' => $parentData['education'] ?? null,
|
||||
'occupation' => $parentData['occupation'] ?? null,
|
||||
'phone_number' => $parentData['phone_number'] ?? null,
|
||||
'address' => $parentData['address'] ?? null,
|
||||
'is_alive' => (bool) ($parentData['is_alive'] ?? false),
|
||||
];
|
||||
|
||||
if ($existingParent) {
|
||||
$existingParent->update($payload);
|
||||
} else {
|
||||
$student->parents()->create([
|
||||
'relation_type' => $relationType,
|
||||
...$payload,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('public.student-self-service.edit')
|
||||
->with('success', $data['submit_action'] === 'submit'
|
||||
? 'Data berhasil dikirim. Silakan tunggu pemeriksaan admin sekolah.'
|
||||
: 'Draft berhasil disimpan. Anda bisa melanjutkan lagi nanti selama sesi masih aktif.');
|
||||
}
|
||||
|
||||
protected function resolveVerifiedStudent(Request $request): ?Student
|
||||
{
|
||||
$studentId = $request->session()->get('student_self_service.student_id');
|
||||
|
||||
if (! $studentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Student::find($studentId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AcademicYear;
|
||||
use App\Models\Department;
|
||||
use App\Models\SchoolClass;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use RealRashid\SweetAlert\Facades\Alert;
|
||||
|
||||
class SchoolClassController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$search = trim((string) $request->get('search'));
|
||||
|
||||
$classes = SchoolClass::with(['academicYear', 'department'])
|
||||
->withCount(['students', 'academicHistories'])
|
||||
->when($search !== '', function ($query) use ($search) {
|
||||
$query->where('name', 'like', "%{$search}%")
|
||||
->orWhere('level', 'like', "%{$search}%")
|
||||
->orWhere('homeroom_teacher', 'like', "%{$search}%");
|
||||
})
|
||||
->latest()
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
return view('classes.index', [
|
||||
'classes' => $classes,
|
||||
'search' => $search,
|
||||
'academicYears' => AcademicYear::orderByDesc('start_year')->orderByDesc('end_year')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('classes.create', [
|
||||
'academicYears' => AcademicYear::where('is_active', true)->orderBy('name')->pluck('name', 'id'),
|
||||
'departments' => Department::where('is_active', true)->orderBy('name')->pluck('name', 'id'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'academic_year_id' => ['required', Rule::exists('academic_years', 'id')->where(fn ($query) => $query->where('is_active', true))],
|
||||
'department_id' => ['required', Rule::exists('departments', 'id')->where(fn ($query) => $query->where('is_active', true))],
|
||||
'name' => ['required', 'string', 'max:100', Rule::unique('classes', 'name')->where(fn ($query) => $query->where('academic_year_id', $request->academic_year_id))],
|
||||
'level' => ['required', 'string', 'max:10'],
|
||||
'homeroom_teacher' => ['nullable', 'string', 'max:100'],
|
||||
'capacity' => ['nullable', 'integer', 'min:1'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
|
||||
SchoolClass::create($data);
|
||||
|
||||
return redirect()->route('classes.index')->with('success', 'Kelas berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
public function generate(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'source_academic_year_id' => ['required', 'exists:academic_years,id', 'different:target_academic_year_id'],
|
||||
'target_academic_year_id' => ['required', 'exists:academic_years,id'],
|
||||
'generation_mode' => ['required', Rule::in(['same_level', 'promote_level'])],
|
||||
]);
|
||||
|
||||
$sourceClasses = SchoolClass::query()
|
||||
->where('academic_year_id', $data['source_academic_year_id'])
|
||||
->orderBy('department_id')
|
||||
->orderBy('level')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
if ($sourceClasses->isEmpty()) {
|
||||
Alert::error('Gagal', 'Tidak ada kelas pada tahun ajaran sumber yang bisa digenerate.');
|
||||
|
||||
return redirect()->route('classes.index')->withErrors([
|
||||
'generate' => 'Tidak ada kelas pada tahun ajaran sumber yang bisa digenerate.',
|
||||
]);
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
DB::transaction(function () use ($sourceClasses, $data, &$created, &$skipped) {
|
||||
foreach ($sourceClasses as $sourceClass) {
|
||||
$targetLevel = $data['generation_mode'] === 'promote_level'
|
||||
? $this->nextLevel($sourceClass->level)
|
||||
: $sourceClass->level;
|
||||
|
||||
if ($targetLevel === null) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetName = $this->transformClassName($sourceClass->name, $sourceClass->level, $targetLevel);
|
||||
|
||||
$exists = SchoolClass::query()
|
||||
->where('academic_year_id', $data['target_academic_year_id'])
|
||||
->where('name', $targetName)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
SchoolClass::create([
|
||||
'academic_year_id' => $data['target_academic_year_id'],
|
||||
'department_id' => $sourceClass->department_id,
|
||||
'name' => $targetName,
|
||||
'level' => $targetLevel,
|
||||
'homeroom_teacher' => $sourceClass->homeroom_teacher,
|
||||
'capacity' => $sourceClass->capacity,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$created++;
|
||||
}
|
||||
});
|
||||
|
||||
Alert::success('Berhasil', "Generate kelas selesai. {$created} kelas dibuat, {$skipped} dilewati.");
|
||||
|
||||
return redirect()->route('classes.index')->with('success', "Generate kelas selesai. {$created} kelas dibuat, {$skipped} dilewati.");
|
||||
}
|
||||
|
||||
public function show(SchoolClass $class): RedirectResponse
|
||||
{
|
||||
return redirect()->route('classes.edit', $class);
|
||||
}
|
||||
|
||||
public function edit(SchoolClass $class): View
|
||||
{
|
||||
$class->loadMissing(['academicYear', 'department']);
|
||||
|
||||
$academicYears = AcademicYear::query()
|
||||
->where(function ($query) use ($class) {
|
||||
$query->where('is_active', true)
|
||||
->orWhere('id', $class->academic_year_id);
|
||||
})
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id');
|
||||
|
||||
$departments = Department::query()
|
||||
->where(function ($query) use ($class) {
|
||||
$query->where('is_active', true)
|
||||
->orWhere('id', $class->department_id);
|
||||
})
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id');
|
||||
|
||||
return view('classes.edit', [
|
||||
'class' => $class,
|
||||
'academicYears' => $academicYears,
|
||||
'departments' => $departments,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, SchoolClass $class): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'academic_year_id' => ['required', Rule::exists('academic_years', 'id')->where(fn ($query) => $query->where('is_active', true)->orWhere('id', $class->academic_year_id))],
|
||||
'department_id' => ['required', Rule::exists('departments', 'id')->where(fn ($query) => $query->where('is_active', true)->orWhere('id', $class->department_id))],
|
||||
'name' => ['required', 'string', 'max:100', Rule::unique('classes', 'name')->ignore($class->id)->where(fn ($query) => $query->where('academic_year_id', $request->academic_year_id))],
|
||||
'level' => ['required', 'string', 'max:10'],
|
||||
'homeroom_teacher' => ['nullable', 'string', 'max:100'],
|
||||
'capacity' => ['nullable', 'integer', 'min:1'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$data['is_active'] = $request->boolean('is_active');
|
||||
|
||||
$class->update($data);
|
||||
|
||||
return redirect()->route('classes.index')->with('success', 'Kelas berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(SchoolClass $class): RedirectResponse
|
||||
{
|
||||
$class->loadCount(['students', 'academicHistories']);
|
||||
|
||||
if ($class->students_count > 0) {
|
||||
Alert::error('Tidak bisa dihapus', 'Kelas ini masih digunakan oleh data siswa aktif.');
|
||||
|
||||
return redirect()->route('classes.index')->withErrors([
|
||||
'delete' => 'Kelas tidak bisa dihapus karena masih digunakan oleh data siswa aktif.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($class->academic_histories_count > 0) {
|
||||
Alert::error('Tidak bisa dihapus', 'Kelas ini masih tercatat pada histori akademik siswa.');
|
||||
|
||||
return redirect()->route('classes.index')->withErrors([
|
||||
'delete' => 'Kelas tidak bisa dihapus karena masih tercatat pada histori akademik siswa. Hapus atau ubah histori terkait terlebih dahulu.',
|
||||
]);
|
||||
}
|
||||
|
||||
Alert::success('Berhasil', 'Kelas berhasil dihapus.');
|
||||
|
||||
$class->delete();
|
||||
|
||||
return redirect()->route('classes.index')->with('success', 'Kelas berhasil dihapus.');
|
||||
}
|
||||
|
||||
protected function nextLevel(?string $level): ?string
|
||||
{
|
||||
return match (strtoupper((string) $level)) {
|
||||
'X' => 'XI',
|
||||
'XI' => 'XII',
|
||||
'XII' => null,
|
||||
default => $level,
|
||||
};
|
||||
}
|
||||
|
||||
protected function transformClassName(string $name, ?string $fromLevel, ?string $toLevel): string
|
||||
{
|
||||
if (! $fromLevel || ! $toLevel || strtoupper($fromLevel) === strtoupper($toLevel)) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return preg_replace('/^' . preg_quote($fromLevel, '/') . '\b/i', $toLevel, $name) ?: $name;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DocumentType;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentDocument;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use RealRashid\SweetAlert\Facades\Alert;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class StudentDocumentController extends Controller
|
||||
{
|
||||
public function create(Student $student): View
|
||||
{
|
||||
return view('student-documents.create', [
|
||||
'student' => $student,
|
||||
'documentTypes' => DocumentType::where('is_active', true)->orderBy('name')->get(),
|
||||
'verificationStatuses' => $this->verificationStatuses(),
|
||||
'defaultMaxFileSizeKb' => 5120,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Student $student): RedirectResponse
|
||||
{
|
||||
$data = $this->validateDocument($request);
|
||||
$uploadedFile = $request->file('document_file');
|
||||
$path = $uploadedFile->store("student-documents/{$student->id}", 'public');
|
||||
|
||||
StudentDocument::create([
|
||||
'student_id' => $student->id,
|
||||
'document_type_id' => $data['document_type_id'],
|
||||
'file_name' => $uploadedFile->getClientOriginalName(),
|
||||
'file_path' => $path,
|
||||
'file_extension' => $uploadedFile->getClientOriginalExtension(),
|
||||
'mime_type' => $uploadedFile->getMimeType(),
|
||||
'file_size' => $uploadedFile->getSize(),
|
||||
'document_number' => $data['document_number'] ?? null,
|
||||
'issued_date' => $data['issued_date'] ?? null,
|
||||
'expired_date' => $data['expired_date'] ?? null,
|
||||
'description' => $data['description'] ?? null,
|
||||
'verification_status' => $data['verification_status'],
|
||||
'verification_note' => $data['verification_note'] ?? null,
|
||||
'uploaded_by' => auth()->id(),
|
||||
'verified_by' => in_array($data['verification_status'], ['valid', 'invalid', 'revision']) ? auth()->id() : null,
|
||||
'verified_at' => in_array($data['verification_status'], ['valid', 'invalid', 'revision']) ? now() : null,
|
||||
]);
|
||||
|
||||
Alert::success('Berhasil', 'Dokumen siswa berhasil diunggah.');
|
||||
|
||||
return redirect()->route('students.show', $student)->with('success', 'Dokumen siswa berhasil diunggah.');
|
||||
}
|
||||
|
||||
public function edit(Student $student, StudentDocument $document): View
|
||||
{
|
||||
abort_unless($document->student_id === $student->id, 404);
|
||||
|
||||
return view('student-documents.edit', [
|
||||
'student' => $student,
|
||||
'document' => $document,
|
||||
'documentTypes' => DocumentType::where('is_active', true)->orderBy('name')->get(),
|
||||
'verificationStatuses' => $this->verificationStatuses(),
|
||||
'defaultMaxFileSizeKb' => 5120,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Student $student, StudentDocument $document): RedirectResponse
|
||||
{
|
||||
abort_unless($document->student_id === $student->id, 404);
|
||||
|
||||
$data = $this->validateDocument($request, false);
|
||||
|
||||
if ($request->hasFile('document_file')) {
|
||||
if ($document->file_path && Storage::disk('public')->exists($document->file_path)) {
|
||||
Storage::disk('public')->delete($document->file_path);
|
||||
}
|
||||
|
||||
$uploadedFile = $request->file('document_file');
|
||||
$path = $uploadedFile->store("student-documents/{$student->id}", 'public');
|
||||
|
||||
$document->file_name = $uploadedFile->getClientOriginalName();
|
||||
$document->file_path = $path;
|
||||
$document->file_extension = $uploadedFile->getClientOriginalExtension();
|
||||
$document->mime_type = $uploadedFile->getMimeType();
|
||||
$document->file_size = $uploadedFile->getSize();
|
||||
}
|
||||
|
||||
$document->fill([
|
||||
'document_type_id' => $data['document_type_id'],
|
||||
'document_number' => $data['document_number'] ?? null,
|
||||
'issued_date' => $data['issued_date'] ?? null,
|
||||
'expired_date' => $data['expired_date'] ?? null,
|
||||
'description' => $data['description'] ?? null,
|
||||
'verification_status' => $data['verification_status'],
|
||||
'verification_note' => $data['verification_note'] ?? null,
|
||||
]);
|
||||
|
||||
if (in_array($data['verification_status'], ['valid', 'invalid', 'revision'])) {
|
||||
$document->verified_by = auth()->id();
|
||||
$document->verified_at = now();
|
||||
} else {
|
||||
$document->verified_by = null;
|
||||
$document->verified_at = null;
|
||||
}
|
||||
|
||||
$document->save();
|
||||
|
||||
Alert::success('Berhasil', 'Dokumen siswa berhasil diperbarui.');
|
||||
|
||||
return redirect()->route('students.show', $student)->with('success', 'Dokumen siswa berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(Student $student, StudentDocument $document): RedirectResponse
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
abort_unless($document->student_id === $student->id, 404);
|
||||
|
||||
if ($document->file_path && Storage::disk('public')->exists($document->file_path)) {
|
||||
Storage::disk('public')->delete($document->file_path);
|
||||
}
|
||||
|
||||
$document->delete();
|
||||
|
||||
Alert::success('Berhasil', 'Dokumen siswa berhasil dihapus.');
|
||||
|
||||
return redirect()->route('students.show', $student)->with('success', 'Dokumen siswa berhasil dihapus.');
|
||||
}
|
||||
|
||||
public function preview(StudentDocument $document): Response|View
|
||||
{
|
||||
abort_unless($document->file_path && Storage::disk('public')->exists($document->file_path), 404);
|
||||
|
||||
$mimeType = $document->mime_type ?: Storage::disk('public')->mimeType($document->file_path);
|
||||
$url = asset('storage/' . $document->file_path);
|
||||
$isImage = str_starts_with((string) $mimeType, 'image/');
|
||||
$isPdf = $mimeType === 'application/pdf';
|
||||
|
||||
return view('student-documents.preview', [
|
||||
'document' => $document,
|
||||
'fileUrl' => $url,
|
||||
'mimeType' => $mimeType,
|
||||
'isImage' => $isImage,
|
||||
'isPdf' => $isPdf,
|
||||
]);
|
||||
}
|
||||
|
||||
public function quickVerify(Request $request, Student $student, StudentDocument $document): RedirectResponse
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
abort_unless($document->student_id === $student->id, 404);
|
||||
|
||||
$data = $request->validate([
|
||||
'verification_status' => ['required', Rule::in(['valid', 'revision', 'invalid'])],
|
||||
'verification_note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$document->update([
|
||||
'verification_status' => $data['verification_status'],
|
||||
'verification_note' => $data['verification_note'] ?? null,
|
||||
'verified_by' => auth()->id(),
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
|
||||
Alert::success('Berhasil', 'Status verifikasi dokumen berhasil diperbarui.');
|
||||
|
||||
return redirect()->route('students.show', $student)->with('success', 'Status verifikasi dokumen berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function download(StudentDocument $document): StreamedResponse
|
||||
{
|
||||
abort_unless($document->file_path && Storage::disk('public')->exists($document->file_path), 404);
|
||||
|
||||
return Storage::disk('public')->download($document->file_path, $document->file_name);
|
||||
}
|
||||
|
||||
public function publicPreview(StudentDocument $document): Response|View
|
||||
{
|
||||
abort_unless($document->file_path && Storage::disk('public')->exists($document->file_path), 404);
|
||||
|
||||
$mimeType = $document->mime_type ?: Storage::disk('public')->mimeType($document->file_path);
|
||||
$url = asset('storage/' . $document->file_path);
|
||||
$isImage = str_starts_with((string) $mimeType, 'image/');
|
||||
$isPdf = $mimeType === 'application/pdf';
|
||||
|
||||
return view('public.document-preview', [
|
||||
'document' => $document,
|
||||
'fileUrl' => $url,
|
||||
'mimeType' => $mimeType,
|
||||
'isImage' => $isImage,
|
||||
'isPdf' => $isPdf,
|
||||
]);
|
||||
}
|
||||
|
||||
public function publicDownload(StudentDocument $document): StreamedResponse
|
||||
{
|
||||
return $this->download($document);
|
||||
}
|
||||
|
||||
protected function validateDocument(Request $request, bool $fileRequired = true): array
|
||||
{
|
||||
$documentType = DocumentType::findOrFail($request->input('document_type_id'));
|
||||
$maxFileSizeKb = $documentType->max_file_size_kb ?: 5120;
|
||||
$allowedExtensions = $documentType->allowed_extensions
|
||||
? collect(explode(',', $documentType->allowed_extensions))->map(fn ($item) => trim($item))->filter()->implode(',')
|
||||
: 'jpg,jpeg,png,pdf,webp';
|
||||
|
||||
return $request->validate([
|
||||
'document_type_id' => ['required', 'exists:document_types,id'],
|
||||
'document_number' => ['nullable', 'string', 'max:100'],
|
||||
'issued_date' => ['nullable', 'date'],
|
||||
'expired_date' => ['nullable', 'date', 'after_or_equal:issued_date'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'verification_status' => ['required', Rule::in(array_keys($this->verificationStatuses()))],
|
||||
'verification_note' => ['nullable', 'string', 'max:1000'],
|
||||
'document_file' => [$fileRequired ? 'required' : 'nullable', 'file', 'mimes:' . $allowedExtensions, 'max:' . $maxFileSizeKb],
|
||||
], [
|
||||
'document_file.max' => 'Ukuran file melebihi batas maksimal untuk jenis dokumen ini (' . number_format($maxFileSizeKb / 1024, 2) . ' MB).',
|
||||
'document_file.mimes' => 'Format file tidak sesuai. Format yang diizinkan untuk jenis dokumen ini: ' . strtoupper(str_replace(',', ', ', $allowedExtensions)) . '.',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function verificationStatuses(): array
|
||||
{
|
||||
return [
|
||||
'pending' => 'Pending',
|
||||
'valid' => 'Valid',
|
||||
'invalid' => 'Invalid',
|
||||
'revision' => 'Perlu Revisi',
|
||||
];
|
||||
}
|
||||
|
||||
protected function ensureAdmin(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->isAdmin(), 403, 'Aksi ini hanya boleh dilakukan administrator.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AcademicYear;
|
||||
use App\Models\Department;
|
||||
use App\Models\SchoolClass;
|
||||
use App\Models\StudentExit;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use RealRashid\SweetAlert\Facades\Alert;
|
||||
|
||||
class StudentExitController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$search = trim((string) $request->get('search'));
|
||||
$academicYearId = $request->get('academic_year_id');
|
||||
$departmentId = $request->get('department_id');
|
||||
$classId = $request->get('class_id');
|
||||
|
||||
$studentExits = StudentExit::query()
|
||||
->with(['academicYear', 'department', 'classRoom'])
|
||||
->when($search !== '', function ($query) use ($search) {
|
||||
$query->where(function ($subQuery) use ($search) {
|
||||
$subQuery->where('full_name', 'like', "%{$search}%")
|
||||
->orWhere('student_number', 'like', "%{$search}%")
|
||||
->orWhere('nisn', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->when($academicYearId, fn ($query) => $query->where('academic_year_id', $academicYearId))
|
||||
->when($departmentId, fn ($query) => $query->where('department_id', $departmentId))
|
||||
->when($classId, fn ($query) => $query->where('class_id', $classId))
|
||||
->latest('exit_date')
|
||||
->latest('id')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
return view('student-exits.index', [
|
||||
'studentExits' => $studentExits,
|
||||
'search' => $search,
|
||||
'filters' => [
|
||||
'academic_year_id' => $academicYearId,
|
||||
'department_id' => $departmentId,
|
||||
'class_id' => $classId,
|
||||
],
|
||||
'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'),
|
||||
'departments' => Department::orderBy('name')->pluck('name', 'id'),
|
||||
'classes' => SchoolClass::orderBy('name')->pluck('name', 'id'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(StudentExit $studentExit): View
|
||||
{
|
||||
$studentExit->load(['academicYear', 'department', 'classRoom']);
|
||||
|
||||
return view('student-exits.show', [
|
||||
'studentExit' => $studentExit,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, StudentExit $studentExit): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'exit_date' => ['nullable', 'date'],
|
||||
'exit_reason' => ['nullable', 'string'],
|
||||
'exit_letter' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:2048'],
|
||||
], [
|
||||
'exit_letter.mimes' => 'Surat keluar harus berupa file PDF, JPG, JPEG, atau PNG.',
|
||||
'exit_letter.max' => 'Ukuran surat keluar maksimal 2 MB.',
|
||||
]);
|
||||
|
||||
$studentExit->exit_date = $validated['exit_date'] ?? $studentExit->exit_date;
|
||||
$studentExit->exit_reason = $validated['exit_reason'] ?? $studentExit->exit_reason;
|
||||
|
||||
if ($request->hasFile('exit_letter')) {
|
||||
if ($studentExit->exit_letter_file_path && Storage::disk('public')->exists($studentExit->exit_letter_file_path)) {
|
||||
Storage::disk('public')->delete($studentExit->exit_letter_file_path);
|
||||
}
|
||||
|
||||
$file = $request->file('exit_letter');
|
||||
$path = $file->store("student-exit-letters/{$studentExit->student_id}", 'public');
|
||||
|
||||
$studentExit->exit_letter_file_name = $file->getClientOriginalName();
|
||||
$studentExit->exit_letter_file_path = $path;
|
||||
$studentExit->exit_letter_mime_type = $file->getMimeType();
|
||||
$studentExit->exit_letter_file_size = $file->getSize();
|
||||
}
|
||||
|
||||
$studentExit->save();
|
||||
|
||||
Alert::success('Berhasil', 'Arsip siswa keluar berhasil diperbarui.');
|
||||
|
||||
return redirect()->route('student-exits.show', $studentExit)->with('success', 'Arsip siswa keluar berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function previewLetter(StudentExit $studentExit): View
|
||||
{
|
||||
abort_unless($studentExit->exit_letter_file_path && Storage::disk('public')->exists($studentExit->exit_letter_file_path), 404);
|
||||
|
||||
$mimeType = $studentExit->exit_letter_mime_type ?: Storage::disk('public')->mimeType($studentExit->exit_letter_file_path);
|
||||
$fileUrl = asset('storage/' . $studentExit->exit_letter_file_path);
|
||||
$isImage = str_starts_with((string) $mimeType, 'image/');
|
||||
$isPdf = $mimeType === 'application/pdf';
|
||||
|
||||
return view('student-exits.preview-letter', [
|
||||
'studentExit' => $studentExit,
|
||||
'fileUrl' => $fileUrl,
|
||||
'mimeType' => $mimeType,
|
||||
'isImage' => $isImage,
|
||||
'isPdf' => $isPdf,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentParent;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use RealRashid\SweetAlert\Facades\Alert;
|
||||
|
||||
class StudentParentController extends Controller
|
||||
{
|
||||
public function create(Student $student): View
|
||||
{
|
||||
return view('student-parents.create', [
|
||||
'student' => $student,
|
||||
'relations' => $this->relationOptions(),
|
||||
'educationOptions' => $this->educationOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Student $student): RedirectResponse
|
||||
{
|
||||
$data = $this->validateParent($request, $student);
|
||||
$data['student_id'] = $student->id;
|
||||
|
||||
StudentParent::create($data);
|
||||
|
||||
Alert::success('Berhasil', 'Data orang tua/wali berhasil ditambahkan.');
|
||||
|
||||
return redirect()->route('students.show', $student)->with('success', 'Data orang tua/wali berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
public function edit(Student $student, StudentParent $parent): View
|
||||
{
|
||||
abort_unless($parent->student_id === $student->id, 404);
|
||||
|
||||
return view('student-parents.edit', [
|
||||
'student' => $student,
|
||||
'parent' => $parent,
|
||||
'relations' => $this->relationOptions(),
|
||||
'educationOptions' => $this->educationOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Student $student, StudentParent $parent): RedirectResponse
|
||||
{
|
||||
abort_unless($parent->student_id === $student->id, 404);
|
||||
|
||||
$data = $this->validateParent($request, $student, $parent);
|
||||
$parent->update($data);
|
||||
|
||||
Alert::success('Berhasil', 'Data orang tua/wali berhasil diperbarui.');
|
||||
|
||||
return redirect()->route('students.show', $student)->with('success', 'Data orang tua/wali berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(Student $student, StudentParent $parent): RedirectResponse
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
abort_unless($parent->student_id === $student->id, 404);
|
||||
|
||||
$parent->delete();
|
||||
|
||||
Alert::success('Berhasil', 'Data orang tua/wali berhasil dihapus.');
|
||||
|
||||
return redirect()->route('students.show', $student)->with('success', 'Data orang tua/wali berhasil dihapus.');
|
||||
}
|
||||
|
||||
protected function validateParent(Request $request, Student $student, ?StudentParent $parent = null): array
|
||||
{
|
||||
return $request->validate([
|
||||
'relation_type' => ['required', Rule::in(array_keys($this->relationOptions())), Rule::unique('student_parents', 'relation_type')->ignore($parent?->id)->where(fn ($query) => $query->where('student_id', $student->id))],
|
||||
'name' => ['required', 'string', 'max:150'],
|
||||
'nik' => ['nullable', 'string', 'max:30'],
|
||||
'birth_year' => ['nullable', 'integer', 'digits:4'],
|
||||
'education' => ['nullable', Rule::in(array_keys($this->educationOptions()))],
|
||||
'occupation' => ['nullable', 'string', 'max:100'],
|
||||
'phone_number' => ['nullable', 'string', 'max:30'],
|
||||
'address' => ['nullable', 'string'],
|
||||
'is_alive' => ['nullable', 'boolean'],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function relationOptions(): array
|
||||
{
|
||||
return [
|
||||
'father' => 'Ayah',
|
||||
'mother' => 'Ibu',
|
||||
'guardian' => 'Wali',
|
||||
];
|
||||
}
|
||||
|
||||
protected function educationOptions(): array
|
||||
{
|
||||
return [
|
||||
'Tidak Sekolah' => 'Tidak Sekolah',
|
||||
'Tidak Tamat SD' => 'Tidak Tamat SD',
|
||||
'SD' => 'SD',
|
||||
'SMP' => 'SMP',
|
||||
'SLTA' => 'SLTA',
|
||||
'D1' => 'D1',
|
||||
'D2' => 'D2',
|
||||
'D3' => 'D3',
|
||||
'D4' => 'D4',
|
||||
'S1' => 'S1',
|
||||
'S2' => 'S2',
|
||||
'S3' => 'S3',
|
||||
'Lainnya' => 'Lainnya',
|
||||
];
|
||||
}
|
||||
|
||||
protected function ensureAdmin(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->isAdmin(), 403, 'Aksi ini hanya boleh dilakukan administrator.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AcademicYear;
|
||||
use App\Models\SchoolClass;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentAcademicHistory;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
use RealRashid\SweetAlert\Facades\Alert;
|
||||
|
||||
class StudentPromotionController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$fromAcademicYearId = $request->integer('from_academic_year_id') ?: AcademicYear::where('is_active', true)->value('id');
|
||||
$toAcademicYearId = $request->integer('to_academic_year_id');
|
||||
|
||||
$academicYears = AcademicYear::orderByDesc('start_year')->orderByDesc('end_year')->get();
|
||||
|
||||
$students = collect();
|
||||
|
||||
if ($fromAcademicYearId) {
|
||||
$students = Student::query()
|
||||
->with(['academicYear', 'department', 'classRoom'])
|
||||
->where('academic_year_id', $fromAcademicYearId)
|
||||
->where('status', 'active')
|
||||
->orderBy('full_name')
|
||||
->get()
|
||||
->map(function (Student $student) use ($toAcademicYearId) {
|
||||
$student->promotion_recommendation = $this->buildPromotionRecommendation($student, $toAcademicYearId);
|
||||
|
||||
return $student;
|
||||
});
|
||||
}
|
||||
|
||||
return view('student-promotions.index', [
|
||||
'academicYears' => $academicYears,
|
||||
'students' => $students,
|
||||
'filters' => [
|
||||
'from_academic_year_id' => $fromAcademicYearId,
|
||||
'to_academic_year_id' => $toAcademicYearId,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'from_academic_year_id' => ['required', 'exists:academic_years,id'],
|
||||
'to_academic_year_id' => ['required', 'exists:academic_years,id', 'different:from_academic_year_id'],
|
||||
'promotions' => ['required', 'array', 'min:1'],
|
||||
'promotions.*.student_id' => ['required', 'exists:students,id'],
|
||||
'promotions.*.target_class_id' => ['nullable', 'exists:classes,id'],
|
||||
'promotions.*.action' => ['required', 'in:promote,repeat,graduate,skip'],
|
||||
'promotions.*.notes' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$students = Student::query()
|
||||
->with(['classRoom'])
|
||||
->whereIn('id', collect($data['promotions'])->pluck('student_id')->all())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$targetClassIds = collect($data['promotions'])
|
||||
->pluck('target_class_id')
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$targetClasses = SchoolClass::query()
|
||||
->whereIn('id', $targetClassIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$plannedClassAssignments = collect($data['promotions'])
|
||||
->filter(fn (array $promotion) => in_array($promotion['action'] ?? 'skip', ['promote', 'repeat'], true) && ! empty($promotion['target_class_id']))
|
||||
->countBy('target_class_id');
|
||||
|
||||
foreach ($data['promotions'] as $index => $promotion) {
|
||||
if (in_array($promotion['action'], ['promote', 'repeat'], true) && empty($promotion['target_class_id'])) {
|
||||
return back()
|
||||
->withErrors(["promotions.$index.target_class_id" => 'Kelas tujuan wajib dipilih untuk aksi naik kelas atau tinggal kelas.'])
|
||||
->withInput();
|
||||
}
|
||||
|
||||
$student = $students->get($promotion['student_id']);
|
||||
|
||||
if (! $student) {
|
||||
return back()
|
||||
->withErrors(["promotions.$index.student_id" => 'Data siswa tidak ditemukan.'])
|
||||
->withInput();
|
||||
}
|
||||
|
||||
if ((int) $student->academic_year_id !== (int) $data['from_academic_year_id']) {
|
||||
return back()
|
||||
->withErrors(["promotions.$index.student_id" => "Siswa {$student->full_name} tidak berasal dari tahun ajaran asal yang dipilih."])
|
||||
->withInput();
|
||||
}
|
||||
|
||||
if ($student->status !== 'active') {
|
||||
return back()
|
||||
->withErrors(["promotions.$index.student_id" => "Siswa {$student->full_name} tidak berstatus aktif sehingga tidak bisa diproses promosi."])
|
||||
->withInput();
|
||||
}
|
||||
|
||||
if (($promotion['action'] ?? 'skip') === 'graduate') {
|
||||
if (strtoupper((string) $student->classRoom?->level) !== 'XII') {
|
||||
return back()
|
||||
->withErrors(["promotions.$index.action" => "Siswa {$student->full_name} hanya bisa diluluskan jika berada di kelas XII."])
|
||||
->withInput();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($promotion['action'] ?? 'skip') === 'skip') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetClass = $targetClasses->get($promotion['target_class_id']);
|
||||
|
||||
if (! $targetClass) {
|
||||
return back()
|
||||
->withErrors(["promotions.$index.target_class_id" => "Kelas tujuan untuk {$student->full_name} tidak ditemukan."])
|
||||
->withInput();
|
||||
}
|
||||
|
||||
if ((int) $targetClass->academic_year_id !== (int) $data['to_academic_year_id']) {
|
||||
return back()
|
||||
->withErrors(["promotions.$index.target_class_id" => "Kelas tujuan {$student->full_name} harus berasal dari tahun ajaran tujuan yang dipilih."])
|
||||
->withInput();
|
||||
}
|
||||
|
||||
if (! $targetClass->is_active) {
|
||||
return back()
|
||||
->withErrors(["promotions.$index.target_class_id" => "Kelas tujuan {$student->full_name} tidak aktif."])
|
||||
->withInput();
|
||||
}
|
||||
|
||||
if ((int) $student->department_id !== (int) $targetClass->department_id) {
|
||||
return back()
|
||||
->withErrors(["promotions.$index.target_class_id" => "Jurusan kelas tujuan {$student->full_name} harus sama dengan jurusan siswa saat ini."])
|
||||
->withInput();
|
||||
}
|
||||
|
||||
if (! $this->isTargetClassValidForAction($student, $targetClass, $promotion['action'])) {
|
||||
return back()
|
||||
->withErrors(["promotions.$index.target_class_id" => "Level kelas tujuan {$student->full_name} tidak sesuai dengan aksi yang dipilih."])
|
||||
->withInput();
|
||||
}
|
||||
|
||||
$hasHistoryInTargetYear = StudentAcademicHistory::query()
|
||||
->where('student_id', $student->id)
|
||||
->where('academic_year_id', $data['to_academic_year_id'])
|
||||
->exists();
|
||||
|
||||
if ($hasHistoryInTargetYear) {
|
||||
return back()
|
||||
->withErrors(["promotions.$index.student_id" => "Siswa {$student->full_name} sudah memiliki histori akademik pada tahun ajaran tujuan."])
|
||||
->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
$capacityErrors = $this->validateTargetClassCapacities($plannedClassAssignments, $targetClasses);
|
||||
|
||||
if ($capacityErrors->isNotEmpty()) {
|
||||
return back()
|
||||
->withErrors($capacityErrors->all())
|
||||
->withInput();
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
|
||||
DB::transaction(function () use ($data, &$processed) {
|
||||
foreach ($data['promotions'] as $promotion) {
|
||||
if (($promotion['action'] ?? 'skip') === 'skip') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$student = Student::query()->findOrFail($promotion['student_id']);
|
||||
$action = $promotion['action'];
|
||||
|
||||
if ($action === 'graduate') {
|
||||
$student->update([
|
||||
'status' => 'graduated',
|
||||
]);
|
||||
|
||||
StudentAcademicHistory::query()
|
||||
->where('student_id', $student->id)
|
||||
->where('is_current', true)
|
||||
->update([
|
||||
'status' => 'graduated',
|
||||
'is_current' => false,
|
||||
'end_date' => now()->toDateString(),
|
||||
'notes' => $promotion['notes'] ?? 'Diproses melalui modul promosi siswa.',
|
||||
]);
|
||||
|
||||
$processed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetClass = SchoolClass::query()->findOrFail($promotion['target_class_id']);
|
||||
|
||||
StudentAcademicHistory::query()
|
||||
->where('student_id', $student->id)
|
||||
->where('is_current', true)
|
||||
->update([
|
||||
'is_current' => false,
|
||||
'end_date' => now()->toDateString(),
|
||||
'status' => $action === 'repeat' ? 'repeated' : 'promoted',
|
||||
'notes' => $promotion['notes'] ?? 'Diproses melalui modul promosi siswa.',
|
||||
]);
|
||||
|
||||
$student->update([
|
||||
'academic_year_id' => $data['to_academic_year_id'],
|
||||
'department_id' => $targetClass->department_id,
|
||||
'class_id' => $targetClass->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
StudentAcademicHistory::create([
|
||||
'student_id' => $student->id,
|
||||
'academic_year_id' => $data['to_academic_year_id'],
|
||||
'department_id' => $targetClass->department_id,
|
||||
'class_id' => $targetClass->id,
|
||||
'status' => 'active',
|
||||
'start_date' => now()->toDateString(),
|
||||
'end_date' => null,
|
||||
'notes' => $promotion['notes'] ?? 'Hasil proses promosi siswa.',
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
$processed++;
|
||||
}
|
||||
});
|
||||
|
||||
Alert::success('Berhasil', $processed . ' siswa berhasil diproses melalui modul promosi.');
|
||||
|
||||
return redirect()
|
||||
->route('student-promotions.index', [
|
||||
'from_academic_year_id' => $data['from_academic_year_id'],
|
||||
'to_academic_year_id' => $data['to_academic_year_id'],
|
||||
])
|
||||
->with('success', $processed . ' siswa berhasil diproses melalui modul promosi.');
|
||||
}
|
||||
|
||||
protected function buildPromotionRecommendation(Student $student, ?int $toAcademicYearId): array
|
||||
{
|
||||
$currentLevel = strtoupper((string) ($student->classRoom?->level ?? ''));
|
||||
$nextLevel = match ($currentLevel) {
|
||||
'X' => 'XI',
|
||||
'XI' => 'XII',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($currentLevel === 'XII') {
|
||||
return [
|
||||
'action' => 'graduate',
|
||||
'label' => 'Lulus',
|
||||
'target_class_id' => null,
|
||||
'target_class_name' => 'Siswa kelas XII direkomendasikan lulus.',
|
||||
'available_classes' => [
|
||||
'promote' => collect(),
|
||||
'repeat' => collect(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$promoteClasses = $toAcademicYearId && $nextLevel
|
||||
? $this->findClassesForLevel($student, $toAcademicYearId, $nextLevel)
|
||||
: collect();
|
||||
|
||||
$repeatClasses = $toAcademicYearId && $currentLevel
|
||||
? $this->findClassesForLevel($student, $toAcademicYearId, $currentLevel)
|
||||
: collect();
|
||||
|
||||
$recommendedPromoteClass = $this->pickRecommendedClass($promoteClasses, $student);
|
||||
$recommendedRepeatClass = $this->pickRecommendedClass($repeatClasses, $student);
|
||||
|
||||
$defaultAction = $recommendedPromoteClass ? 'promote' : ($recommendedRepeatClass ? 'repeat' : 'repeat');
|
||||
$defaultClass = $defaultAction === 'promote' ? $recommendedPromoteClass : $recommendedRepeatClass;
|
||||
|
||||
return [
|
||||
'action' => $defaultAction,
|
||||
'label' => $defaultAction === 'promote' ? 'Naik Kelas' : 'Tinggal Kelas',
|
||||
'target_class_id' => $defaultClass?->id,
|
||||
'target_class_name' => $defaultClass?->name,
|
||||
'available_classes' => [
|
||||
'promote' => $this->transformClassOptions($promoteClasses),
|
||||
'repeat' => $this->transformClassOptions($repeatClasses),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function findClassesForLevel(Student $student, int $academicYearId, string $level)
|
||||
{
|
||||
return SchoolClass::query()
|
||||
->where('academic_year_id', $academicYearId)
|
||||
->where('level', $level)
|
||||
->where('is_active', true)
|
||||
->orderByRaw('CASE WHEN department_id = ? THEN 0 ELSE 1 END', [$student->department_id])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->values();
|
||||
}
|
||||
|
||||
protected function pickRecommendedClass($classes, Student $student): ?SchoolClass
|
||||
{
|
||||
$sameDepartmentClasses = $classes->where('department_id', $student->department_id)->values();
|
||||
|
||||
return $sameDepartmentClasses->first(function (SchoolClass $classRoom) use ($student) {
|
||||
return str_contains(strtolower($classRoom->name), strtolower((string) $student->classRoom?->name));
|
||||
})
|
||||
?? $sameDepartmentClasses->first()
|
||||
?? $classes->first();
|
||||
}
|
||||
|
||||
protected function transformClassOptions(Collection $classes): Collection
|
||||
{
|
||||
return $classes->map(function (SchoolClass $classRoom) {
|
||||
$currentOccupancy = Student::query()
|
||||
->where('academic_year_id', $classRoom->academic_year_id)
|
||||
->where('class_id', $classRoom->id)
|
||||
->where('status', 'active')
|
||||
->count();
|
||||
|
||||
$capacity = $classRoom->capacity;
|
||||
$remainingCapacity = filled($capacity) ? max((int) $capacity - $currentOccupancy, 0) : null;
|
||||
$capacityLabel = filled($capacity)
|
||||
? "Kuota {$currentOccupancy}/{$capacity} • sisa {$remainingCapacity}"
|
||||
: 'Kuota belum diatur';
|
||||
|
||||
return (object) [
|
||||
'id' => $classRoom->id,
|
||||
'name' => $classRoom->name,
|
||||
'department_id' => $classRoom->department_id,
|
||||
'capacity' => $capacity,
|
||||
'current_occupancy' => $currentOccupancy,
|
||||
'remaining_capacity' => $remainingCapacity,
|
||||
'capacity_label' => $capacityLabel,
|
||||
'option_label' => "{$classRoom->name} ({$capacityLabel})",
|
||||
];
|
||||
})->values();
|
||||
}
|
||||
|
||||
protected function isTargetClassValidForAction(Student $student, SchoolClass $targetClass, string $action): bool
|
||||
{
|
||||
$currentLevel = strtoupper((string) $student->classRoom?->level);
|
||||
$targetLevel = strtoupper((string) $targetClass->level);
|
||||
|
||||
return match ($action) {
|
||||
'promote' => $targetLevel === match ($currentLevel) {
|
||||
'X' => 'XI',
|
||||
'XI' => 'XII',
|
||||
default => null,
|
||||
},
|
||||
'repeat' => $targetLevel === $currentLevel,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
protected function validateTargetClassCapacities(Collection $plannedClassAssignments, Collection $targetClasses): Collection
|
||||
{
|
||||
return $plannedClassAssignments->map(function (int $plannedCount, int|string $classId) use ($targetClasses) {
|
||||
/** @var SchoolClass|null $targetClass */
|
||||
$targetClass = $targetClasses->get($classId);
|
||||
|
||||
if (! $targetClass || blank($targetClass->capacity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currentOccupancy = Student::query()
|
||||
->where('academic_year_id', $targetClass->academic_year_id)
|
||||
->where('class_id', $targetClass->id)
|
||||
->where('status', 'active')
|
||||
->count();
|
||||
|
||||
$remainingCapacity = (int) $targetClass->capacity - $currentOccupancy;
|
||||
|
||||
if ($plannedCount > $remainingCapacity) {
|
||||
return "Kelas {$targetClass->name} melebihi kapasitas. Sisa kuota {$remainingCapacity}, tetapi alokasi promosi/repeat yang dipilih {$plannedCount}.";
|
||||
}
|
||||
|
||||
return null;
|
||||
})->filter()->values();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AcademicYear;
|
||||
use App\Models\SchoolClass;
|
||||
use App\Models\Student;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class StudentSelfServiceController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$search = trim((string) $request->get('search'));
|
||||
$academicYearId = $request->get('academic_year_id');
|
||||
$classId = $request->get('class_id');
|
||||
$status = $request->get('status');
|
||||
|
||||
$students = Student::query()
|
||||
->with(['academicYear', 'classRoom', 'department'])
|
||||
->when($search !== '', function ($query) use ($search) {
|
||||
$query->where(function ($subQuery) use ($search) {
|
||||
$subQuery->where('full_name', 'like', "%{$search}%")
|
||||
->orWhere('student_number', 'like', "%{$search}%")
|
||||
->orWhere('nisn', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->when($academicYearId, fn ($query) => $query->where('academic_year_id', $academicYearId))
|
||||
->when($classId, fn ($query) => $query->where('class_id', $classId))
|
||||
->when($status, fn ($query) => $query->where('self_service_status', $status))
|
||||
->latest()
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
$summaryBaseQuery = Student::query();
|
||||
|
||||
return view('student-self-service.index', [
|
||||
'students' => $students,
|
||||
'search' => $search,
|
||||
'filters' => [
|
||||
'academic_year_id' => $academicYearId,
|
||||
'class_id' => $classId,
|
||||
'status' => $status,
|
||||
],
|
||||
'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'),
|
||||
'classes' => SchoolClass::orderBy('name')->pluck('name', 'id'),
|
||||
'statuses' => [
|
||||
'not_started' => 'Belum Mulai',
|
||||
'draft' => 'Draft',
|
||||
'submitted' => 'Sudah Dikirim',
|
||||
],
|
||||
'summary' => [
|
||||
'total' => (clone $summaryBaseQuery)->count(),
|
||||
'not_started' => (clone $summaryBaseQuery)->where('self_service_status', 'not_started')->count(),
|
||||
'draft' => (clone $summaryBaseQuery)->where('self_service_status', 'draft')->count(),
|
||||
'submitted' => (clone $summaryBaseQuery)->where('self_service_status', 'submitted')->count(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AcademicYear;
|
||||
use App\Models\Department;
|
||||
use App\Models\SchoolClass;
|
||||
use App\Models\StudentTransfer;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use RealRashid\SweetAlert\Facades\Alert;
|
||||
|
||||
class StudentTransferController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$search = trim((string) $request->get('search'));
|
||||
$academicYearId = $request->get('academic_year_id');
|
||||
$departmentId = $request->get('department_id');
|
||||
$classId = $request->get('class_id');
|
||||
|
||||
$studentTransfers = StudentTransfer::query()
|
||||
->with(['academicYear', 'department', 'classRoom'])
|
||||
->when($search !== '', function ($query) use ($search) {
|
||||
$query->where(function ($subQuery) use ($search) {
|
||||
$subQuery->where('full_name', 'like', "%{$search}%")
|
||||
->orWhere('student_number', 'like', "%{$search}%")
|
||||
->orWhere('nisn', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->when($academicYearId, fn ($query) => $query->where('academic_year_id', $academicYearId))
|
||||
->when($departmentId, fn ($query) => $query->where('department_id', $departmentId))
|
||||
->when($classId, fn ($query) => $query->where('class_id', $classId))
|
||||
->latest('transfer_date')
|
||||
->latest('id')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
return view('student-transfers.index', [
|
||||
'studentTransfers' => $studentTransfers,
|
||||
'search' => $search,
|
||||
'filters' => [
|
||||
'academic_year_id' => $academicYearId,
|
||||
'department_id' => $departmentId,
|
||||
'class_id' => $classId,
|
||||
],
|
||||
'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'),
|
||||
'departments' => Department::orderBy('name')->pluck('name', 'id'),
|
||||
'classes' => SchoolClass::orderBy('name')->pluck('name', 'id'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(StudentTransfer $studentTransfer): View
|
||||
{
|
||||
$studentTransfer->load(['academicYear', 'department', 'classRoom']);
|
||||
|
||||
return view('student-transfers.show', [
|
||||
'studentTransfer' => $studentTransfer,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, StudentTransfer $studentTransfer): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'transfer_date' => ['nullable', 'date'],
|
||||
'transfer_reason' => ['nullable', 'string'],
|
||||
'transfer_letter' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:2048'],
|
||||
], [
|
||||
'transfer_letter.mimes' => 'Surat pindah harus berupa file PDF, JPG, JPEG, atau PNG.',
|
||||
'transfer_letter.max' => 'Ukuran surat pindah maksimal 2 MB.',
|
||||
]);
|
||||
|
||||
$studentTransfer->transfer_date = $validated['transfer_date'] ?? $studentTransfer->transfer_date;
|
||||
$studentTransfer->transfer_reason = $validated['transfer_reason'] ?? $studentTransfer->transfer_reason;
|
||||
|
||||
if ($request->hasFile('transfer_letter')) {
|
||||
if ($studentTransfer->transfer_letter_file_path && Storage::disk('public')->exists($studentTransfer->transfer_letter_file_path)) {
|
||||
Storage::disk('public')->delete($studentTransfer->transfer_letter_file_path);
|
||||
}
|
||||
|
||||
$file = $request->file('transfer_letter');
|
||||
$path = $file->store("student-transfer-letters/{$studentTransfer->student_id}", 'public');
|
||||
|
||||
$studentTransfer->transfer_letter_file_name = $file->getClientOriginalName();
|
||||
$studentTransfer->transfer_letter_file_path = $path;
|
||||
$studentTransfer->transfer_letter_mime_type = $file->getMimeType();
|
||||
$studentTransfer->transfer_letter_file_size = $file->getSize();
|
||||
}
|
||||
|
||||
$studentTransfer->save();
|
||||
|
||||
Alert::success('Berhasil', 'Arsip siswa mutasi berhasil diperbarui.');
|
||||
|
||||
return redirect()->route('student-transfers.show', $studentTransfer)->with('success', 'Arsip siswa mutasi berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function previewLetter(StudentTransfer $studentTransfer): View
|
||||
{
|
||||
abort_unless($studentTransfer->transfer_letter_file_path && Storage::disk('public')->exists($studentTransfer->transfer_letter_file_path), 404);
|
||||
|
||||
$mimeType = $studentTransfer->transfer_letter_mime_type ?: Storage::disk('public')->mimeType($studentTransfer->transfer_letter_file_path);
|
||||
$fileUrl = asset('storage/' . $studentTransfer->transfer_letter_file_path);
|
||||
$isImage = str_starts_with((string) $mimeType, 'image/');
|
||||
$isPdf = $mimeType === 'application/pdf';
|
||||
|
||||
return view('student-transfers.preview-letter', [
|
||||
'studentTransfer' => $studentTransfer,
|
||||
'fileUrl' => $fileUrl,
|
||||
'mimeType' => $mimeType,
|
||||
'isImage' => $isImage,
|
||||
'isPdf' => $isPdf,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use RealRashid\SweetAlert\Facades\Alert;
|
||||
|
||||
class UserManagementController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$search = trim((string) $request->string('search'));
|
||||
|
||||
$users = User::query()
|
||||
->when($search !== '', function ($query) use ($search) {
|
||||
$query->where(function ($subQuery) use ($search) {
|
||||
$subQuery->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->orderBy('name')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
return view('users.index', [
|
||||
'users' => $users,
|
||||
'search' => $search,
|
||||
'roleOptions' => $this->roleOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('users.create', [
|
||||
'roleOptions' => $this->roleOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $this->validateUser($request);
|
||||
|
||||
User::create($data);
|
||||
|
||||
Alert::success('Berhasil', 'Akun pengguna berhasil ditambahkan.');
|
||||
|
||||
return redirect()->route('settings.users.index')->with('success', 'Akun pengguna berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
public function edit(User $user): View
|
||||
{
|
||||
return view('users.edit', [
|
||||
'user' => $user,
|
||||
'roleOptions' => $this->roleOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$data = $this->validateUser($request, $user);
|
||||
|
||||
if (blank($data['password'] ?? null)) {
|
||||
unset($data['password']);
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
Alert::success('Berhasil', 'Akun pengguna berhasil diperbarui.');
|
||||
|
||||
return redirect()->route('settings.users.index')->with('success', 'Akun pengguna berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
if ((int) $request->user()->id === (int) $user->id) {
|
||||
return back()->withErrors(['user' => 'Akun yang sedang dipakai login tidak bisa dihapus.']);
|
||||
}
|
||||
|
||||
if (User::query()->where('role', 'admin')->count() <= 1 && $user->role === 'admin') {
|
||||
return back()->withErrors(['user' => 'Minimal harus ada satu akun admin aktif di sistem.']);
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
Alert::success('Berhasil', 'Akun pengguna berhasil dihapus.');
|
||||
|
||||
return redirect()->route('settings.users.index')->with('success', 'Akun pengguna berhasil dihapus.');
|
||||
}
|
||||
|
||||
protected function validateUser(Request $request, ?User $user = null): array
|
||||
{
|
||||
$passwordRules = $user
|
||||
? ['nullable', 'string', 'min:8', 'confirmed']
|
||||
: ['required', 'string', 'min:8', 'confirmed'];
|
||||
|
||||
return $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:150', Rule::unique('users', 'email')->ignore($user?->id)],
|
||||
'role' => ['required', Rule::in(array_keys($this->roleOptions()))],
|
||||
'password' => $passwordRules,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function roleOptions(): array
|
||||
{
|
||||
return [
|
||||
'admin' => 'Administrator',
|
||||
'staff' => 'Staff',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureUserHasRole
|
||||
{
|
||||
public function handle(Request $request, Closure $next, string ...$roles): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user || ! in_array($user->role, $roles, true)) {
|
||||
abort(403, 'Anda tidak memiliki akses ke halaman ini.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout($this));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiting throttle key for the request.
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'lowercase',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class)->ignore($this->user()->id),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AcademicYear extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'start_year',
|
||||
'end_year',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function classes(): HasMany
|
||||
{
|
||||
return $this->hasMany(SchoolClass::class);
|
||||
}
|
||||
|
||||
public function students(): HasMany
|
||||
{
|
||||
return $this->hasMany(Student::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AppSetting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'value',
|
||||
];
|
||||
|
||||
public static function getValue(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return static::query()->where('key', $key)->value('value') ?? $default;
|
||||
}
|
||||
|
||||
public static function setValue(string $key, mixed $value): void
|
||||
{
|
||||
static::query()->updateOrCreate(
|
||||
['key' => $key],
|
||||
['value' => $value]
|
||||
);
|
||||
}
|
||||
|
||||
public static function brandingDefaults(): array
|
||||
{
|
||||
return [
|
||||
'app_name' => 'Database Kesiswaan',
|
||||
'app_short_name' => 'DK',
|
||||
'app_logo' => null,
|
||||
'app_favicon' => null,
|
||||
'public_portal_logo' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public static function branding(): array
|
||||
{
|
||||
$defaults = static::brandingDefaults();
|
||||
|
||||
foreach (array_keys($defaults) as $key) {
|
||||
$defaults[$key] = static::getValue($key, $defaults[$key]);
|
||||
}
|
||||
|
||||
return $defaults;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Department extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function classes(): HasMany
|
||||
{
|
||||
return $this->hasMany(SchoolClass::class);
|
||||
}
|
||||
|
||||
public function students(): HasMany
|
||||
{
|
||||
return $this->hasMany(Student::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class District extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = ['id', 'regency_id', 'name'];
|
||||
|
||||
public function regency(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Regency::class);
|
||||
}
|
||||
|
||||
public function villages(): HasMany
|
||||
{
|
||||
return $this->hasMany(Village::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class DocumentType extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'max_file_size_kb',
|
||||
'allowed_extensions',
|
||||
'is_required',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'max_file_size_kb' => 'integer',
|
||||
'is_required' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function studentDocuments(): HasMany
|
||||
{
|
||||
return $this->hasMany(StudentDocument::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Province extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = ['id', 'name'];
|
||||
|
||||
public function regencies(): HasMany
|
||||
{
|
||||
return $this->hasMany(Regency::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Regency extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = ['id', 'province_id', 'name'];
|
||||
|
||||
public function province(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Province::class);
|
||||
}
|
||||
|
||||
public function districts(): HasMany
|
||||
{
|
||||
return $this->hasMany(District::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SchoolClass extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'classes';
|
||||
|
||||
protected $fillable = [
|
||||
'academic_year_id',
|
||||
'department_id',
|
||||
'name',
|
||||
'level',
|
||||
'homeroom_teacher',
|
||||
'capacity',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'capacity' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function academicYear(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademicYear::class);
|
||||
}
|
||||
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class);
|
||||
}
|
||||
|
||||
public function students(): HasMany
|
||||
{
|
||||
return $this->hasMany(Student::class, 'class_id');
|
||||
}
|
||||
|
||||
public function academicHistories(): HasMany
|
||||
{
|
||||
return $this->hasMany(StudentAcademicHistory::class, 'class_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Student extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function (Student $student) {
|
||||
if ($student->isForceDeleting()) {
|
||||
$student->documents()->withTrashed()->get()->each->forceDelete();
|
||||
$student->parents()->delete();
|
||||
$student->academicHistories()->delete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$student->documents()->get()->each->delete();
|
||||
$student->parents()->delete();
|
||||
});
|
||||
|
||||
static::restoring(function (Student $student) {
|
||||
$student->documents()->onlyTrashed()->restore();
|
||||
});
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'student_number',
|
||||
'nisn',
|
||||
'full_name',
|
||||
'gender',
|
||||
'place_of_birth',
|
||||
'date_of_birth',
|
||||
'religion',
|
||||
'nationality',
|
||||
'family_card_number',
|
||||
'child_order',
|
||||
'number_of_siblings',
|
||||
'phone_number',
|
||||
'email',
|
||||
'address',
|
||||
'province_id',
|
||||
'regency_id',
|
||||
'district_id',
|
||||
'village_id',
|
||||
'village',
|
||||
'district',
|
||||
'city',
|
||||
'province',
|
||||
'postal_code',
|
||||
'academic_year_id',
|
||||
'department_id',
|
||||
'class_id',
|
||||
'entry_date',
|
||||
'status',
|
||||
'self_service_status',
|
||||
'self_service_last_access_at',
|
||||
'self_service_submitted_at',
|
||||
'self_service_access_code',
|
||||
'self_service_code_generated_at',
|
||||
'photo_path',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date_of_birth' => 'date',
|
||||
'entry_date' => 'date',
|
||||
'self_service_last_access_at' => 'datetime',
|
||||
'self_service_submitted_at' => 'datetime',
|
||||
'self_service_code_generated_at' => 'datetime',
|
||||
'child_order' => 'integer',
|
||||
'number_of_siblings' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function academicYear(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademicYear::class);
|
||||
}
|
||||
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class);
|
||||
}
|
||||
|
||||
public function classRoom(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SchoolClass::class, 'class_id');
|
||||
}
|
||||
|
||||
public function provinceRef(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Province::class, 'province_id');
|
||||
}
|
||||
|
||||
public function regencyRef(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Regency::class, 'regency_id');
|
||||
}
|
||||
|
||||
public function districtRef(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(District::class, 'district_id');
|
||||
}
|
||||
|
||||
public function villageRef(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Village::class, 'village_id');
|
||||
}
|
||||
|
||||
public function parents(): HasMany
|
||||
{
|
||||
return $this->hasMany(StudentParent::class);
|
||||
}
|
||||
|
||||
public function documents(): HasMany
|
||||
{
|
||||
return $this->hasMany(StudentDocument::class);
|
||||
}
|
||||
|
||||
public function academicHistories(): HasMany
|
||||
{
|
||||
return $this->hasMany(StudentAcademicHistory::class)->latest('academic_year_id');
|
||||
}
|
||||
|
||||
public function exitRecord(): HasOne
|
||||
{
|
||||
return $this->hasOne(StudentExit::class);
|
||||
}
|
||||
|
||||
public function transferRecord(): HasOne
|
||||
{
|
||||
return $this->hasOne(StudentTransfer::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class StudentAcademicHistory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'student_id',
|
||||
'academic_year_id',
|
||||
'department_id',
|
||||
'class_id',
|
||||
'status',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'notes',
|
||||
'is_current',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'is_current' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
|
||||
public function academicYear(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademicYear::class);
|
||||
}
|
||||
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class);
|
||||
}
|
||||
|
||||
public function classRoom(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SchoolClass::class, 'class_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StudentDocument extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'student_id',
|
||||
'document_type_id',
|
||||
'file_name',
|
||||
'file_path',
|
||||
'file_extension',
|
||||
'mime_type',
|
||||
'file_size',
|
||||
'document_number',
|
||||
'issued_date',
|
||||
'expired_date',
|
||||
'description',
|
||||
'verification_status',
|
||||
'verification_note',
|
||||
'uploaded_by',
|
||||
'verified_by',
|
||||
'verified_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'file_size' => 'integer',
|
||||
'issued_date' => 'date',
|
||||
'expired_date' => 'date',
|
||||
'verified_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
|
||||
public function documentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentType::class);
|
||||
}
|
||||
|
||||
public function uploadedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
|
||||
public function verifiedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'verified_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class StudentExit extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'student_id',
|
||||
'student_number',
|
||||
'nisn',
|
||||
'full_name',
|
||||
'academic_year_id',
|
||||
'department_id',
|
||||
'class_id',
|
||||
'entry_date',
|
||||
'exit_date',
|
||||
'exit_reason',
|
||||
'exit_letter_file_name',
|
||||
'exit_letter_file_path',
|
||||
'exit_letter_mime_type',
|
||||
'exit_letter_file_size',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'entry_date' => 'date',
|
||||
'exit_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
|
||||
public function academicYear(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademicYear::class);
|
||||
}
|
||||
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class);
|
||||
}
|
||||
|
||||
public function classRoom(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SchoolClass::class, 'class_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class StudentParent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'student_id',
|
||||
'relation_type',
|
||||
'name',
|
||||
'nik',
|
||||
'birth_year',
|
||||
'education',
|
||||
'occupation',
|
||||
'phone_number',
|
||||
'address',
|
||||
'is_alive',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'birth_year' => 'integer',
|
||||
'is_alive' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class StudentTransfer extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'student_id',
|
||||
'student_number',
|
||||
'nisn',
|
||||
'full_name',
|
||||
'academic_year_id',
|
||||
'department_id',
|
||||
'class_id',
|
||||
'entry_date',
|
||||
'transfer_date',
|
||||
'transfer_reason',
|
||||
'transfer_letter_file_name',
|
||||
'transfer_letter_file_path',
|
||||
'transfer_letter_mime_type',
|
||||
'transfer_letter_file_size',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'entry_date' => 'date',
|
||||
'transfer_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
|
||||
public function academicYear(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademicYear::class);
|
||||
}
|
||||
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class);
|
||||
}
|
||||
|
||||
public function classRoom(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SchoolClass::class, 'class_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'role', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Village extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = ['id', 'district_id', 'name'];
|
||||
|
||||
public function district(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(District::class);
|
||||
}
|
||||
}
|
||||
@@ -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,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AppLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.app');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GuestLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.guest');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user