Initial commit

This commit is contained in:
2026-04-12 06:24:30 +00:00
commit 007f107be6
213 changed files with 25576 additions and 0 deletions
@@ -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();
}
}