student = model(Student::class); $this->studentClass = model(StudentClass::class); $this->config = model(Configuration::class); $this->classSection = model(ClassSection::class); $this->enrollment = model(Enrollment::class); $this->emergencyContact = model(EmergencyContact::class); $this->allergy = model(StudentAllergy::class); $this->condition = model(StudentMedicalCondition::class); $this->promotionQueue = model(PromotionQueue::class); $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); $this->semester = (string) ($this->config->getConfig('semester') ?? ''); } public function index() { $page = max(1, (int) ($this->request->getGet('page') ?? 1)); $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); $parentId = $this->request->getGet('parent_id'); $classId = $this->request->getGet('class_id'); $query = $this->student->newQuery(); if (!empty($parentId)) { $query->where('parent_id', $parentId); } if (!empty($classId)) { $studentIds = $this->studentClass->newQuery() ->where('class_section_id', $classId) ->pluck('student_id') ->toArray(); if (!empty($studentIds)) { $query->whereIn('id', $studentIds); } else { return $this->success([ 'data' => [], 'pagination' => [ 'current_page' => $page, 'per_page' => $perPage, 'total' => 0, 'total_pages' => 0, ], ], 'No students found'); } } $result = $this->paginate($query, $page, $perPage); $schoolYear = $this->config->getConfig('school_year'); foreach ($result['data'] as &$student) { $student['current_class'] = $this->studentClass->newQuery() ->select([ 'cs.*', 'c.class_name as class_name', ]) ->from('student_class as sc') ->join('class_sections as cs', 'cs.id', '=', 'sc.class_section_id') ->join('classes as c', 'c.id', '=', 'cs.class_id') ->where('sc.student_id', $student['id']) ->where('sc.school_year', $schoolYear) ->where('sc.semester', $this->config->getConfig('semester')) ->where('cs.school_year', $schoolYear) ->where('cs.semester', $this->config->getConfig('semester')) ->first(); } return $this->success($result, 'Students retrieved successfully'); } public function show($id = null) { $student = $this->student->find($id); if (!$student) { return $this->respondError('Student not found', Response::HTTP_NOT_FOUND); } $schoolYear = $this->config->getConfig('school_year'); $student['current_class'] = $this->studentClass->newQuery() ->select([ 'cs.*', 'c.class_name as class_name', ]) ->from('student_class as sc') ->join('class_sections as cs', 'cs.id', '=', 'sc.class_section_id') ->join('classes as c', 'c.id', '=', 'cs.class_id') ->where('sc.student_id', $id) ->where('sc.school_year', $schoolYear) ->where('sc.semester', $this->config->getConfig('semester')) ->where('cs.school_year', $schoolYear) ->where('cs.semester', $this->config->getConfig('semester')) ->first(); return $this->success($student, 'Student retrieved successfully'); } public function store() { $data = $this->payloadData(); if (empty($data)) { return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); } $rules = [ 'firstname' => 'required|min:2|max:100', 'lastname' => 'required|min:2|max:100', 'dob' => 'required|date', 'gender' => 'required|in:male,female', 'parent_id' => 'required|integer', ]; $errors = $this->validateRequest($data, $rules); if (!empty($errors)) { return $this->respondValidationError($errors); } $data['school_year'] = $this->config->getConfig('school_year'); $data['semester'] = $this->config->getConfig('semester'); $data['registration_date'] = now()->toDateTimeString(); try { $student = $this->student->create($data); return $this->success($student->toArray(), 'Student created successfully', Response::HTTP_CREATED); } catch (\Throwable $e) { Log::error('Student creation error: ' . $e->getMessage()); return $this->respondError('Failed to create student', Response::HTTP_INTERNAL_SERVER_ERROR); } } public function update($id = null) { $student = $this->student->find($id); if (!$student) { return $this->respondError('Student not found', Response::HTTP_NOT_FOUND); } $data = $this->payloadData(); if (empty($data)) { return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); } try { DB::beginTransaction(); // Build student data payload $studentData = []; $allowedFields = [ 'school_id', 'firstname', 'lastname', 'dob', 'age', 'gender', 'registration_grade', 'photo_consent', 'parent_id', 'registration_date', 'tuition_paid', 'year_of_registration', 'school_year', 'rfid_tag', 'semester', 'is_new' ]; foreach ($allowedFields as $field) { if (array_key_exists($field, $data) && $data[$field] !== null && $data[$field] !== '') { $value = $data[$field]; // Apply title case to names if (in_array($field, ['firstname', 'lastname'])) { $value = $this->titleCase((string) $value); } $studentData[$field] = $value; } } // Handle DOB and age calculation if (isset($data['dob'])) { try { $dob = Carbon::parse($data['dob']); $studentData['dob'] = $dob->format('Y-m-d'); $studentData['age'] = $dob->age; } catch (\Throwable $e) { DB::rollBack(); return $this->respondError('Invalid date of birth format', Response::HTTP_BAD_REQUEST); } } // Handle registration_date if (isset($data['registration_date']) && $data['registration_date'] !== '') { try { $regDate = Carbon::parse($data['registration_date']); $studentData['registration_date'] = $regDate->utc()->format('Y-m-d H:i:00'); } catch (\Throwable $e) { // Ignore invalid dates } } // Normalize boolean fields if (isset($data['photo_consent'])) { $studentData['photo_consent'] = (int) ($data['photo_consent'] === '1' || $data['photo_consent'] === true); } if (isset($data['tuition_paid'])) { $studentData['tuition_paid'] = (int) ($data['tuition_paid'] === '1' || $data['tuition_paid'] === true); } if (isset($data['is_new'])) { $studentData['is_new'] = (int) ($data['is_new'] === '1' || $data['is_new'] === true); } // Update student if there are changes if (!empty($studentData)) { $this->student->where('id', $id)->update($studentData); } // Handle health lists if provided $rawPost = $data; if (isset($rawPost['medical_conditions']) || isset($rawPost['medical_touched'])) { $medTouched = ($rawPost['medical_touched'] ?? '0') === '1'; $medConditions = $rawPost['medical_conditions'] ?? ''; if ($medTouched || (isset($rawPost['medical_conditions']) && trim($medConditions) !== '')) { $normConditions = $this->normalizeHealthList($medConditions, 100); $this->syncHealthList($id, $this->condition, 'condition_name', $normConditions); } } if (isset($rawPost['allergies']) || isset($rawPost['allergies_touched'])) { $allTouched = ($rawPost['allergies_touched'] ?? '0') === '1'; $allergies = $rawPost['allergies'] ?? ''; if ($allTouched || (isset($rawPost['allergies']) && trim($allergies) !== '')) { $normAllergies = $this->normalizeHealthList($allergies, 100); $this->syncHealthList($id, $this->allergy, 'allergy', $normAllergies); } } DB::commit(); $updated = $this->student->find($id); return $this->success($updated, 'Student updated successfully'); } catch (\Throwable $e) { DB::rollBack(); Log::error('Student update error: ' . $e->getMessage()); return $this->respondError('Failed to update student: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } public function destroy($id = null) { $student = $this->student->find($id); if (!$student) { return $this->respondError('Student not found', Response::HTTP_NOT_FOUND); } try { $this->student->delete($id); return $this->respondDeleted(null, 'Student deleted successfully'); } catch (\Throwable $e) { Log::error('Student deletion error: ' . $e->getMessage()); return $this->respondError('Failed to delete student', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * POST /api/v1/students/assign-class * Assign a student to a class section */ public function assignClassStudent(): JsonResponse { $data = $this->payloadData(); $studentId = (int) ($data['student_id'] ?? 0); $classSectionId = (int) ($data['class_section_id'] ?? 0); $userId = $this->getCurrentUserId(); $now = utc_now(); // Validate input if (!$studentId || !$classSectionId) { return $this->error('Missing required data (student/class section).', Response::HTTP_BAD_REQUEST); } // Check student $student = $this->student->find($studentId); if (!$student) { return $this->error('Student not found.', Response::HTTP_NOT_FOUND); } // Robust section lookup: allow id OR class_section_id $section = $this->classSection->newQuery() ->where(function ($q) use ($classSectionId) { $q->where('id', $classSectionId) ->orWhere('class_section_id', $classSectionId); }) ->first(); if (!$section) { return $this->error('Class/Section not found.', Response::HTTP_NOT_FOUND); } $displayName = $this->formatClassSectionDisplayName($section, $classSectionId); // Derive parent class_id from the section row $parentClassId = (int) ($section['class_id'] ?? 0); if (!$parentClassId) { $row = DB::table('classSection') ->select('class_id') ->where(function ($q) use ($section, $classSectionId) { $q->where('id', $section['id'] ?? $classSectionId) ->orWhere('class_section_id', $classSectionId); }) ->first(); if ($row && isset($row->class_id)) { $parentClassId = (int) $row->class_id; } } try { DB::beginTransaction(); // Upsert student_class $payload = [ 'student_id' => $studentId, 'class_section_id' => $classSectionId, 'semester' => $this->semester, 'school_year' => $this->schoolYear, 'updated_by' => $userId, 'updated_at' => $now, ]; $existing = $this->studentClass->newQuery() ->where('student_id', $studentId) ->where('semester', $this->semester) ->where('school_year', $this->schoolYear) ->first(); if ($existing) { $this->studentClass->where('id', $existing['id'])->update($payload); } else { $payload['created_at'] = $now; $this->studentClass->insert($payload); } // Update enrollment for current term (if exists) $enroll = $this->enrollment->newQuery() ->where('student_id', $studentId) ->where('school_year', $this->schoolYear) ->where('semester', $this->semester) ->first(); if ($enroll) { $this->enrollment->where('id', $enroll['id'])->update([ 'class_section_id' => $classSectionId, 'enrollment_status' => 'payment pending', 'admission_status' => 'accepted', 'updated_at' => $now, ]); } // Re-tag attendance rows $attnStats = $this->updateStudentAttendanceSection( $studentId, $classSectionId, $parentClassId ?: 0, $this->semester, $this->schoolYear, $userId ); // Re-tag score rows $scoreStats = $this->updateStudentScoresSection( $studentId, $classSectionId, $this->semester, $this->schoolYear, $userId ); DB::commit(); return $this->success([ 'student_id' => $studentId, 'class_section_id' => $classSectionId, 'class_section_name' => $displayName, 'attendance_updates' => $attnStats, 'score_updates' => $scoreStats, ], 'Assignment saved.'); } catch (\Throwable $e) { DB::rollBack(); Log::error('Unable to assign class: ' . $e->getMessage()); return $this->error('Unable to assign class: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * POST /api/v1/students/auto-distribute * Auto distribute students into lettered sections for a class */ public function autoDistributeSections(): JsonResponse { $data = $this->payloadData(); $classId = (int) ($data['class_id'] ?? 0); $classSectionId = (int) ($data['class_section_id'] ?? 0); $perSec = (int) ($data['students_per_section'] ?? 0); $year = trim((string) ($data['school_year'] ?? $this->schoolYear)); if ($classId <= 0 && $classSectionId > 0) { $cid = $this->classSection->getClassId($classSectionId); $classId = (int) ($cid ?? 0); } if ($classId <= 0 || $perSec <= 0) { return $this->error('Invalid class_id or students_per_section.', Response::HTTP_BAD_REQUEST); } try { // Candidates from promotion queue $cands = $this->promotionQueue->newQuery() ->select('promotion_queue.*', 'students.gender') ->join('students', 'students.id', '=', 'promotion_queue.student_id', 'left') ->where('promotion_queue.to_class_id', $classId) ->where('promotion_queue.school_year_to', $year) ->whereIn('promotion_queue.status', ['queued', 'assigned']) ->get() ->toArray(); if (empty($cands)) { return $this->error('No students found in promotion queue for selected class/year.', Response::HTTP_BAD_REQUEST); } // Filter to those with enrollment $studentIds = array_map(fn($r) => (int) $r['student_id'], $cands); $enrolledIds = []; if (!empty($studentIds)) { $rows = DB::table('enrollments') ->select('student_id') ->whereIn('student_id', $studentIds) ->where('school_year', $year) ->whereIn('enrollment_status', ['payment pending', 'enrolled']) ->groupBy('student_id') ->get() ->toArray(); $enrolledIds = array_map(fn($r) => (int) $r->student_id, $rows); } $cands = array_values(array_filter($cands, fn($r) => in_array((int) $r['student_id'], $enrolledIds, true))); if (empty($cands)) { return $this->error('No eligible enrolled students found to distribute.', Response::HTTP_BAD_REQUEST); } $total = count($cands); $sectionsNeeded = (int) ceil($total / $perSec); // Fetch lettered sections $letters = $this->classSection->getLetterSectionsByClassId($classId); if (empty($letters)) { return $this->error('No lettered sections found for the selected class.', Response::HTTP_BAD_REQUEST); } if (count($letters) < $sectionsNeeded) { return $this->error('Not enough sections available. Needed: ' . $sectionsNeeded . ', available: ' . count($letters), Response::HTTP_BAD_REQUEST); } $letters = array_slice($letters, 0, $sectionsNeeded); // Prepare buckets $buckets = []; foreach ($letters as $idx => $sec) { $buckets[$idx] = [ 'class_section_id' => (int) $sec['class_section_id'], 'assigned' => [], 'male' => 0, 'female' => 0, ]; } // Split by gender $males = []; $females = []; foreach ($cands as $r) { $g = strtolower((string) ($r['gender'] ?? '')); if ($g === 'female') { $females[] = $r; } else { $males[] = $r; } } // Helper to pick next bucket $pickBucket = function (string $gender) use (&$buckets, $perSec): ?int { $bestIdx = null; $bestCnt = PHP_INT_MAX; foreach ($buckets as $i => $b) { if (count($b['assigned']) >= $perSec) { continue; } $cnt = ($gender === 'female') ? $b['female'] : $b['male']; if ($cnt < $bestCnt) { $bestCnt = $cnt; $bestIdx = $i; } } return $bestIdx; }; // Assign males then females foreach ($males as $r) { $bi = $pickBucket('male'); if ($bi === null) { break; } $buckets[$bi]['assigned'][] = (int) $r['student_id']; $buckets[$bi]['male']++; } foreach ($females as $r) { $bi = $pickBucket('female'); if ($bi === null) { break; } $buckets[$bi]['assigned'][] = (int) $r['student_id']; $buckets[$bi]['female']++; } // Persist assignments $promoIdsBySid = []; foreach ($cands as $r) { $promoIdsBySid[(int) $r['student_id']] = (int) $r['id']; } $updatedBy = $this->getCurrentUserId(); $now = utc_now(); DB::beginTransaction(); foreach ($buckets as $b) { $secId = (int) $b['class_section_id']; foreach ($b['assigned'] as $sid) { // Update promotion queue if (isset($promoIdsBySid[$sid])) { $this->promotionQueue->where('id', $promoIdsBySid[$sid])->update([ 'to_class_section_id' => $secId, 'status' => 'assigned', 'updated_by' => $updatedBy, 'updated_at' => $now, ]); } // Upsert student_class $exists = $this->studentClass->newQuery() ->where('student_id', $sid) ->where('school_year', $year) ->where('semester', $this->semester) ->first(); $payload = [ 'student_id' => $sid, 'class_section_id' => $secId, 'school_year' => $year, 'semester' => $this->semester, 'updated_by' => $updatedBy, 'updated_at' => $now, ]; if ($exists) { $this->studentClass->where('id', $exists['id'])->update($payload); } else { $payload['created_at'] = $now; $this->studentClass->insert($payload); } } } DB::commit(); // Build summary $nameById = []; foreach ($letters as $secRow) { $nameById[(int) $secRow['class_section_id']] = (string) ($secRow['class_section_name'] ?? ''); } $summary = []; foreach ($buckets as $b) { $secId = (int) $b['class_section_id']; $summary[] = [ 'class_section_id' => $secId, 'class_section_name' => $nameById[$secId] ?? (string) $secId, 'total' => count($b['assigned']), 'male' => $b['male'], 'female' => $b['female'], ]; } return $this->success([ 'sections' => $summary, ], 'Auto distribution completed.'); } catch (\Throwable $e) { DB::rollBack(); Log::error('Auto distribution failed: ' . $e->getMessage()); return $this->error('Auto distribution failed: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * GET /api/v1/students/promotion-totals * Return totals per base class for promotion_queue in selected year */ public function promotionTotalsApi(): JsonResponse { try { $year = trim((string) ($this->laravelRequest->query('school_year') ?? $this->schoolYear)); // Fetch base sections $bases = $this->classSection->newQuery() ->whereRaw("class_section_name NOT LIKE '%-%'") ->orderBy('class_id', 'ASC') ->get() ->toArray(); $wanted = []; foreach ($bases as $r) { $nameRaw = (string) ($r['class_section_name'] ?? ''); $name = strtolower($nameRaw); if ($name === 'kg' || $name === 'youth') { $wanted[] = $r; continue; } if (ctype_digit($name)) { $num = (int) $name; if ($num >= 1 && $num <= 9) { $wanted[] = $r; } } } $out = []; foreach ($wanted as $r) { $classId = (int) $r['class_id']; $cands = DB::table('promotion_queue as pq') ->select('pq.student_id') ->join('enrollments as e', function ($join) use ($year) { $join->on('e.student_id', '=', 'pq.student_id') ->where('e.school_year', $year); }, 'left') ->where('pq.to_class_id', $classId) ->where('pq.school_year_to', $year) ->whereIn('pq.status', ['queued', 'assigned', 'applied']) ->whereIn('e.enrollment_status', ['payment pending', 'enrolled']) ->groupBy('pq.student_id') ->get() ->toArray(); $out[] = [ 'class_id' => $classId, 'class_section_id' => (int) ($r['class_section_id'] ?? 0), 'class_section_name' => (string) ($r['class_section_name'] ?? ''), 'total' => count($cands), ]; } return $this->success([ 'year' => $year, 'rows' => $out, ], 'Promotion totals retrieved'); } catch (\Throwable $e) { Log::error('Promotion totals error: ' . $e->getMessage()); return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Update attendance tables for a student's new class section */ private function updateStudentAttendanceSection( int $studentId, int $newClassSectionId, int $newClassId, string $semester, string $schoolYear, ?int $modifiedBy = null ): array { $now = utc_now(); // Update attendance_data $dataUpdated = DB::table('attendance_data') ->where('student_id', $studentId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->update([ 'class_section_id' => $newClassSectionId, 'class_id' => $newClassId, 'updated_at' => $now, 'modified_by' => $modifiedBy, ]); // Update attendance_record $recUpdated = DB::table('attendance_record') ->where('student_id', $studentId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->update([ 'class_section_id' => $newClassSectionId, 'updated_at' => $now, 'modified_by' => $modifiedBy, ]); return [ 'attendance_data_updated' => $dataUpdated, 'attendance_record_updated' => $recUpdated, ]; } /** * Update all score-related tables for a student to the new class_section_id */ private function updateStudentScoresSection( int $studentId, int $newClassSectionId, string $semester, string $schoolYear, ?int $modifiedBy = null ): array { $now = utc_now(); $tables = [ 'homework', 'quiz', 'project', 'participation', 'midterm_exam', 'final_exam', 'final_score', 'semester_scores', ]; $results = []; foreach ($tables as $tbl) { $updated = DB::table($tbl) ->where('student_id', $studentId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->update([ 'class_section_id' => $newClassSectionId, 'updated_at' => $now, 'updated_by' => $modifiedBy, ]); $results[$tbl . '_updated'] = $updated; } return $results; } /** * Build a display name from section row */ private function formatClassSectionDisplayName(array $row, int $fallbackId): string { if (!empty($row['class_section_name'])) { return (string) $row['class_section_name']; } if (!empty($row['section_name'])) { return (string) $row['section_name']; } if (!empty($row['name'])) { return (string) $row['name']; } if (!empty($row['title'])) { return (string) $row['title']; } $grade = $row['grade_name'] ?? $row['grade'] ?? $row['class_name'] ?? null; $letter = $row['section'] ?? $row['section_letter'] ?? $row['letter'] ?? null; if ($grade && $letter) { return "{$grade} - {$letter}"; } if ($grade) { return (string) $grade; } return 'Section #' . $fallbackId; } /** * Normalize a comma/semicolon/newline list */ private function normalizeHealthList(?string $s, int $maxLen = 100): array { $s = (string) $s; if ($s === '') { return []; } $parts = preg_split('/[,\n;]+/u', $s, -1, PREG_SPLIT_NO_EMPTY) ?: []; $outAssoc = []; foreach ($parts as $p) { $v = trim(preg_replace('/\s+/u', ' ', $p)); if ($v === '') { continue; } if (preg_match('/^(none|n\/a|na|null|nil|no)$/i', $v)) { continue; } $v = mb_substr($v, 0, $maxLen, 'UTF-8'); $outAssoc[mb_strtolower($v, 'UTF-8')] = $v; } return array_values($outAssoc); } /** * Diff-sync helper for health lists */ private function syncHealthList(int $studentId, $model, string $field, array $newValues): void { // Fetch current values $rows = $model->newQuery()->where('student_id', $studentId)->select("id, {$field}")->get()->toArray(); $current = []; $byId = []; foreach ($rows as $r) { $val = (string) ($r[$field] ?? ''); $key = mb_strtolower(trim($val), 'UTF-8'); if ($key === '') { continue; } $current[$key] = $val; $byId[$key] = (int) $r['id']; } // Build new set $incoming = []; foreach ($newValues as $v) { $key = mb_strtolower(trim($v), 'UTF-8'); if ($key === '') { continue; } $incoming[$key] = $v; } // Compute diffs $toDeleteKeys = array_diff(array_keys($current), array_keys($incoming)); $toInsertKeys = array_diff(array_keys($incoming), array_keys($current)); // Delete removed if (!empty($toDeleteKeys)) { $ids = array_map(fn($k) => $byId[$k], $toDeleteKeys); if (!empty($ids)) { $model->newQuery()->whereIn('id', $ids)->delete(); } } // Insert new if (!empty($toInsertKeys)) { $batch = []; foreach ($toInsertKeys as $k) { $batch[] = [ 'student_id' => $studentId, $field => $incoming[$k], ]; } if (!empty($batch)) { $model->newQuery()->insert($batch); } } } /** * Convert string to title case */ private function titleCase(string $s): string { $s = strip_tags($s); $s = mb_strtolower($s ?: '', 'UTF-8'); return mb_convert_case($s, MB_CASE_TITLE, 'UTF-8'); } }