473 lines
20 KiB
PHP
473 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\AcademicYear;
|
|
use App\Models\SchoolClass;
|
|
use App\Models\Student;
|
|
use App\Models\StudentAcademicHistory;
|
|
use App\Models\StudentGraduation;
|
|
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');
|
|
$selectedClassId = $request->integer('class_id');
|
|
|
|
$academicYears = AcademicYear::orderByDesc('start_year')->orderByDesc('end_year')->get();
|
|
|
|
$availableClasses = collect();
|
|
$classSummaries = collect();
|
|
$selectedClass = null;
|
|
$students = collect();
|
|
|
|
if ($fromAcademicYearId) {
|
|
$availableClasses = SchoolClass::query()
|
|
->with(['department'])
|
|
->where('academic_year_id', $fromAcademicYearId)
|
|
->where('is_active', true)
|
|
->withCount([
|
|
'students as active_students_count' => fn ($query) => $query->where('status', 'active'),
|
|
])
|
|
->orderBy('level')
|
|
->orderBy('name')
|
|
->get()
|
|
->filter(fn (SchoolClass $classRoom) => $classRoom->active_students_count > 0)
|
|
->values();
|
|
|
|
$classSummaries = $availableClasses->map(function (SchoolClass $classRoom) use ($toAcademicYearId) {
|
|
$sampleStudent = Student::query()
|
|
->with(['classRoom'])
|
|
->where('academic_year_id', $classRoom->academic_year_id)
|
|
->where('class_id', $classRoom->id)
|
|
->where('status', 'active')
|
|
->orderBy('full_name')
|
|
->first();
|
|
|
|
$recommendation = $sampleStudent
|
|
? $this->buildPromotionRecommendation($sampleStudent, $toAcademicYearId)
|
|
: [
|
|
'label' => 'Belum ada siswa aktif',
|
|
'target_class_name' => null,
|
|
];
|
|
|
|
return (object) [
|
|
'id' => $classRoom->id,
|
|
'name' => $classRoom->name,
|
|
'level' => $classRoom->level,
|
|
'department_name' => $classRoom->department?->name,
|
|
'active_students_count' => $classRoom->active_students_count,
|
|
'recommendation_label' => $recommendation['label'] ?? '-',
|
|
'recommendation_target_name' => $recommendation['target_class_name'] ?? null,
|
|
];
|
|
})->values();
|
|
|
|
if ($selectedClassId) {
|
|
$selectedClass = $availableClasses->firstWhere('id', $selectedClassId);
|
|
|
|
if ($selectedClass) {
|
|
$students = Student::query()
|
|
->with(['academicYear', 'department', 'classRoom'])
|
|
->where('academic_year_id', $fromAcademicYearId)
|
|
->where('class_id', $selectedClassId)
|
|
->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,
|
|
'availableClasses' => $availableClasses,
|
|
'classSummaries' => $classSummaries,
|
|
'selectedClass' => $selectedClass,
|
|
'students' => $students,
|
|
'filters' => [
|
|
'from_academic_year_id' => $fromAcademicYearId,
|
|
'to_academic_year_id' => $toAcademicYearId,
|
|
'class_id' => $selectedClassId,
|
|
],
|
|
]);
|
|
}
|
|
|
|
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'],
|
|
'class_id' => ['nullable', 'exists:classes,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 (! empty($data['class_id']) && (int) $student->class_id !== (int) $data['class_id']) {
|
|
return back()
|
|
->withErrors(["promotions.$index.student_id" => "Siswa {$student->full_name} tidak berasal dari kelas 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.',
|
|
]);
|
|
|
|
StudentGraduation::updateOrCreate(
|
|
['student_id' => $student->id],
|
|
[
|
|
'student_number' => $student->student_number,
|
|
'nisn' => $student->nisn,
|
|
'full_name' => $student->full_name,
|
|
'academic_year_id' => $student->academic_year_id,
|
|
'department_id' => $student->department_id,
|
|
'class_id' => $student->class_id,
|
|
'entry_date' => $student->entry_date,
|
|
'graduation_date' => now()->toDateString(),
|
|
'graduation_note' => $promotion['notes'] ?? 'Diproses melalui modul promosi siswa.',
|
|
'status' => 'graduated',
|
|
]
|
|
);
|
|
|
|
$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'],
|
|
'class_id' => $data['class_id'] ?? null,
|
|
])
|
|
->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();
|
|
}
|
|
}
|