Add student achievements module and improve student admin workflows

This commit is contained in:
2026-04-13 06:50:37 +00:00
parent b29c591285
commit fb904f9389
21 changed files with 1111 additions and 180 deletions
@@ -7,6 +7,7 @@ use App\Models\Department;
use App\Models\DocumentType;
use App\Models\SchoolClass;
use App\Models\Student;
use App\Models\StudentAchievement;
use App\Models\StudentExit;
use App\Models\StudentTransfer;
use Illuminate\Support\Collection;
@@ -24,6 +25,23 @@ class DashboardController extends Controller
$completion = $this->calculateCompletion($students, $requiredDocumentTypeIds);
$studentGenderByAcademicYear = AcademicYear::query()
->orderByDesc('name')
->get(['id', 'name'])
->map(function (AcademicYear $academicYear) {
$maleCount = Student::where('academic_year_id', $academicYear->id)->where('gender', 'L')->count();
$femaleCount = Student::where('academic_year_id', $academicYear->id)->where('gender', 'P')->count();
return [
'name' => $academicYear->name,
'male' => $maleCount,
'female' => $femaleCount,
'total' => $maleCount + $femaleCount,
];
})
->filter(fn (array $item) => $item['total'] > 0)
->values();
return view('dashboard', [
'stats' => [
'academicYears' => AcademicYear::count(),
@@ -34,11 +52,14 @@ class DashboardController extends Controller
'studentsDropped' => StudentExit::count(),
'studentsTransferred' => StudentTransfer::count(),
'studentsGraduated' => Student::where('status', 'graduated')->count(),
'studentAchievements' => StudentAchievement::count(),
'studentsWithAchievements' => StudentAchievement::distinct('student_id')->count('student_id'),
'studentsCompleteDocuments' => $completion['complete'],
'studentsIncompleteDocuments' => $completion['incomplete'],
'studentsVerifiedDocuments' => $completion['verified_complete'],
'studentsUnverifiedDocuments' => $completion['verified_incomplete'],
],
'studentGenderByAcademicYear' => $studentGenderByAcademicYear,
]);
}
@@ -25,6 +25,7 @@ class PublicStudentLookupController extends Controller
'department',
'classRoom',
'documents.documentType',
'achievements',
])
->where(function ($builder) use ($query) {
$builder->where('student_number', 'like', "%{$query}%")
@@ -209,6 +209,41 @@ class SchoolClassController extends Controller
return redirect()->route('classes.index')->with('success', 'Kelas berhasil dihapus.');
}
public function bulkDestroy(Request $request): RedirectResponse
{
$validated = $request->validate([
'class_ids' => ['required', 'array', 'min:1'],
'class_ids.*' => ['integer', 'exists:classes,id'],
], [
'class_ids.required' => 'Pilih minimal satu kelas untuk dihapus.',
]);
$classes = SchoolClass::withCount(['students', 'academicHistories'])
->whereIn('id', $validated['class_ids'])
->get();
$blocked = $classes->filter(fn (SchoolClass $class) => $class->students_count > 0 || $class->academic_histories_count > 0);
if ($blocked->isNotEmpty()) {
Alert::error('Tidak bisa dihapus', 'Sebagian kelas masih dipakai oleh siswa aktif atau histori akademik.');
return redirect()->route('classes.index')->withErrors([
'delete' => 'Beberapa kelas tidak bisa dihapus karena masih dipakai oleh data siswa atau histori akademik.',
]);
}
$deletedCount = 0;
foreach ($classes as $class) {
$class->delete();
$deletedCount++;
}
Alert::success('Berhasil', $deletedCount . ' kelas berhasil dihapus.');
return redirect()->route('classes.index')->with('success', $deletedCount . ' kelas berhasil dihapus.');
}
protected function nextLevel(?string $level): ?string
{
return match (strtoupper((string) $level)) {
@@ -0,0 +1,169 @@
<?php
namespace App\Http\Controllers;
use App\Models\AcademicYear;
use App\Models\SchoolClass;
use App\Models\Student;
use App\Models\StudentAchievement;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class StudentAchievementController 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');
$achievements = StudentAchievement::query()
->with(['student.academicYear', 'student.department', 'student.classRoom'])
->when($search !== '', function ($query) use ($search) {
$query->where(function ($subQuery) use ($search) {
$subQuery->where('achievement_name', 'like', "%{$search}%")
->orWhere('category', 'like', "%{$search}%")
->orWhere('level', 'like', "%{$search}%")
->orWhereHas('student', function ($studentQuery) use ($search) {
$studentQuery->where('full_name', 'like', "%{$search}%")
->orWhere('student_number', 'like', "%{$search}%")
->orWhere('nisn', 'like', "%{$search}%");
});
});
})
->when($academicYearId, fn ($query) => $query->whereHas('student', fn ($studentQuery) => $studentQuery->where('academic_year_id', $academicYearId)))
->when($classId, fn ($query) => $query->whereHas('student', fn ($studentQuery) => $studentQuery->where('class_id', $classId)))
->latest('achievement_year')
->latest('achievement_date')
->latest('id')
->paginate(15)
->withQueryString();
return view('student-achievements.index', [
'achievements' => $achievements,
'search' => $search,
'filters' => [
'academic_year_id' => $academicYearId,
'class_id' => $classId,
],
'academicYears' => AcademicYear::orderBy('name')->pluck('name', 'id'),
'classes' => SchoolClass::orderBy('name')->pluck('name', 'id'),
'summary' => [
'total' => StudentAchievement::count(),
'filtered' => $achievements->total(),
],
]);
}
public function create(Request $request): View
{
$achievement = new StudentAchievement();
if ($request->filled('student_id')) {
$achievement->student_id = (int) $request->integer('student_id');
}
return view('student-achievements.create', [
'achievement' => $achievement,
'students' => Student::orderBy('full_name')->get(['id', 'full_name', 'student_number', 'nisn']),
'levelOptions' => $this->levelOptions(),
]);
}
public function store(Request $request): RedirectResponse
{
$data = $this->validateAchievement($request);
if ($request->hasFile('certificate_file')) {
$data['certificate_file'] = $request->file('certificate_file')->store('student-achievements/certificates', 'public');
}
if ($request->hasFile('activity_photo')) {
$data['activity_photo'] = $request->file('activity_photo')->store('student-achievements/activity-photos', 'public');
}
StudentAchievement::create($data);
return redirect()->route('student-achievements.index')->with('success', 'Data siswa berprestasi berhasil ditambahkan.');
}
public function edit(StudentAchievement $studentAchievement): View
{
return view('student-achievements.edit', [
'achievement' => $studentAchievement,
'students' => Student::orderBy('full_name')->get(['id', 'full_name', 'student_number', 'nisn']),
'levelOptions' => $this->levelOptions(),
]);
}
public function update(Request $request, StudentAchievement $studentAchievement): RedirectResponse
{
$data = $this->validateAchievement($request);
if ($request->hasFile('certificate_file')) {
if ($studentAchievement->certificate_file && Storage::disk('public')->exists($studentAchievement->certificate_file)) {
Storage::disk('public')->delete($studentAchievement->certificate_file);
}
$data['certificate_file'] = $request->file('certificate_file')->store('student-achievements/certificates', 'public');
}
if ($request->hasFile('activity_photo')) {
if ($studentAchievement->activity_photo && Storage::disk('public')->exists($studentAchievement->activity_photo)) {
Storage::disk('public')->delete($studentAchievement->activity_photo);
}
$data['activity_photo'] = $request->file('activity_photo')->store('student-achievements/activity-photos', 'public');
}
$studentAchievement->update($data);
return redirect()->route('student-achievements.index')->with('success', 'Data siswa berprestasi berhasil diperbarui.');
}
public function destroy(StudentAchievement $studentAchievement): RedirectResponse
{
if ($studentAchievement->certificate_file && Storage::disk('public')->exists($studentAchievement->certificate_file)) {
Storage::disk('public')->delete($studentAchievement->certificate_file);
}
if ($studentAchievement->activity_photo && Storage::disk('public')->exists($studentAchievement->activity_photo)) {
Storage::disk('public')->delete($studentAchievement->activity_photo);
}
$studentAchievement->delete();
return redirect()->route('student-achievements.index')->with('success', 'Data siswa berprestasi berhasil dihapus.');
}
protected function validateAchievement(Request $request): array
{
return $request->validate([
'student_id' => ['required', 'exists:students,id'],
'achievement_name' => ['required', 'string', 'max:150'],
'category' => ['nullable', 'string', 'max:100'],
'level' => ['nullable', 'string', 'max:50'],
'organizer' => ['nullable', 'string', 'max:150'],
'rank' => ['nullable', 'string', 'max:100'],
'achievement_date' => ['nullable', 'date'],
'achievement_year' => ['nullable', 'integer', 'min:2000', 'max:' . now()->year],
'description' => ['nullable', 'string'],
'certificate_file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png,webp', 'max:5120'],
'activity_photo' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
]);
}
protected function levelOptions(): array
{
return [
'Sekolah' => 'Sekolah',
'Kecamatan' => 'Kecamatan',
'Kabupaten/Kota' => 'Kabupaten/Kota',
'Provinsi' => 'Provinsi',
'Nasional' => 'Nasional',
'Internasional' => 'Internasional',
];
}
}
+21 -2
View File
@@ -65,7 +65,7 @@ class StudentController extends Controller
$students = Student::query()
->select('students.*')
->with(['academicYear', 'department', 'classRoom', 'documents.documentType'])
->with(['academicYear', 'department', 'classRoom', 'documents.documentType', 'achievements'])
->leftJoin('academic_years', 'academic_years.id', '=', 'students.academic_year_id')
->leftJoin('departments', 'departments.id', '=', 'students.department_id')
->leftJoin('classes', 'classes.id', '=', 'students.class_id')
@@ -128,6 +128,25 @@ class StudentController extends Controller
: 100;
$student->hover_photo_url = $photoDocument?->file_path ? asset('storage/' . $photoDocument->file_path) : null;
$achievementPriority = [
'Internasional' => 6,
'Nasional' => 5,
'Provinsi' => 4,
'Kabupaten/Kota' => 3,
'Kecamatan' => 2,
'Sekolah' => 1,
];
$topAchievement = $student->achievements
->sortByDesc(fn ($achievement) => $achievementPriority[$achievement->level] ?? 0)
->first();
$student->achievement_badge_level = $topAchievement?->level;
$student->achievement_badge_count = $student->achievements->count();
$student->achievement_badge_title = $topAchievement
? sprintf('Siswa berprestasi · %s · %s prestasi', $topAchievement->level ?: 'Prestasi', $student->achievement_badge_count)
: null;
return $student;
});
@@ -177,7 +196,7 @@ class StudentController extends Controller
public function show(Student $student): View
{
$student->load(['academicYear', 'department', 'classRoom', 'parents', 'documents.documentType', 'academicHistories.academicYear', 'academicHistories.department', 'academicHistories.classRoom']);
$student->load(['academicYear', 'department', 'classRoom', 'parents', 'documents.documentType', 'academicHistories.academicYear', 'academicHistories.department', 'academicHistories.classRoom', 'achievements']);
$requiredDocumentTypes = DocumentType::where('is_active', true)
->where('is_required', true)
+5
View File
@@ -144,4 +144,9 @@ class Student extends Model
{
return $this->hasOne(StudentTransfer::class);
}
public function achievements(): HasMany
{
return $this->hasMany(StudentAchievement::class)->latest('achievement_year')->latest('achievement_date');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StudentAchievement extends Model
{
use HasFactory;
protected $fillable = [
'student_id',
'achievement_name',
'category',
'level',
'organizer',
'rank',
'achievement_date',
'achievement_year',
'description',
'certificate_file',
'activity_photo',
];
protected function casts(): array
{
return [
'achievement_date' => 'date',
'achievement_year' => 'integer',
];
}
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
}