This commit is contained in:
@@ -73,30 +73,37 @@ class PrintRequests extends BaseController
|
||||
|
||||
public function admin_index()
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$query = $db->query("
|
||||
SELECT
|
||||
pr.*,
|
||||
u.firstname,
|
||||
u.lastname,
|
||||
$context = $this->resolveSchoolYearContext();
|
||||
$schoolYear = $context->yearName();
|
||||
|
||||
$printRequestsQuery = $this->printRequestModel
|
||||
->select('
|
||||
print_requests.*,
|
||||
u.firstname,
|
||||
u.lastname,
|
||||
cs.class_section_name,
|
||||
admins.firstname AS admin_firstname,
|
||||
admins.lastname AS admin_lastname
|
||||
FROM print_requests pr
|
||||
LEFT JOIN users u ON u.id = pr.teacher_id
|
||||
LEFT JOIN classSection cs ON cs.class_section_id = pr.class_id
|
||||
LEFT JOIN users admins ON admins.id = pr.admin_id
|
||||
ORDER BY
|
||||
')
|
||||
->join('users u', 'u.id = print_requests.teacher_id', 'left')
|
||||
->join('classSection cs', 'cs.class_section_id = print_requests.class_id', 'left')
|
||||
->join('users admins', 'admins.id = print_requests.admin_id', 'left');
|
||||
|
||||
$this->applyPrintRequestSchoolYearScope($printRequestsQuery, $schoolYear);
|
||||
|
||||
$data['print_requests'] = $printRequestsQuery
|
||||
->orderBy("
|
||||
CASE
|
||||
WHEN pr.status = 'not_assigned' THEN 1
|
||||
WHEN pr.status = 'assigned' THEN 2
|
||||
WHEN pr.status = 'done' THEN 3
|
||||
WHEN pr.status = 'delivered' THEN 4
|
||||
WHEN print_requests.status = 'not_assigned' THEN 1
|
||||
WHEN print_requests.status = 'assigned' THEN 2
|
||||
WHEN print_requests.status = 'done' THEN 3
|
||||
WHEN print_requests.status = 'delivered' THEN 4
|
||||
ELSE 5
|
||||
END ASC,
|
||||
pr.required_by ASC
|
||||
");
|
||||
$data['print_requests'] = $query->getResultArray();
|
||||
END
|
||||
", 'ASC', false)
|
||||
->orderBy('print_requests.required_by', 'ASC')
|
||||
->findAll();
|
||||
$data['isSchoolYearReadonly'] = $context->isReadonly();
|
||||
|
||||
return view('print_requests/admin_index', $data);
|
||||
}
|
||||
@@ -156,6 +163,7 @@ class PrintRequests extends BaseController
|
||||
|
||||
// Case 1: Admin status update
|
||||
if ($this->request->getPost('status')) {
|
||||
$this->assertSchoolYearWritable($this->resolveSchoolYearContext());
|
||||
$user_id = session()->get('user_id');
|
||||
$current_status = $request['status'];
|
||||
$new_status = $this->request->getPost('status');
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Models\AdminNotificationSubjectModel;
|
||||
use Doctrine\DBAL\Configuration;
|
||||
use App\Services\FeeCalculationService;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\StudentSectionDistributionDraftModel;
|
||||
use App\Controllers\View\EmailController;
|
||||
use App\Controllers\View\InvoiceController;
|
||||
use App\Models\StaffAttendanceModel;
|
||||
@@ -2159,6 +2160,10 @@ class AdministratorController extends BaseController
|
||||
|
||||
public function parentProfiles()
|
||||
{
|
||||
if ($redirect = $this->redirectWithoutLegacyTermFilters('administrator/parent_profiles')) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
// Fetch all users with their roles in one go
|
||||
$allUsers = $this->userModel->findAll();
|
||||
$parents = [];
|
||||
@@ -2215,6 +2220,27 @@ class AdministratorController extends BaseController
|
||||
return view('administrator/parent_profile', ['parents' => $parents]);
|
||||
}
|
||||
|
||||
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
|
||||
{
|
||||
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
|
||||
$query = $this->request->getGet();
|
||||
$hasLegacyTermFilter = false;
|
||||
|
||||
foreach ($legacyTermKeys as $key) {
|
||||
if (array_key_exists($key, $query)) {
|
||||
unset($query[$key]);
|
||||
$hasLegacyTermFilter = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasLegacyTermFilter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
|
||||
return redirect()->to($target);
|
||||
}
|
||||
|
||||
public function manageUsers()
|
||||
{// Fetch all users
|
||||
$users = $this->userModel->findAll();
|
||||
@@ -2298,8 +2324,8 @@ class AdministratorController extends BaseController
|
||||
public function showEnrollmentWithdrawalPage()
|
||||
{
|
||||
try {
|
||||
$schoolYears = $this->availableSchoolYears();
|
||||
$selectedYear = $this->selectedEnrollmentSchoolYear($schoolYears);
|
||||
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||
$selectedYear = $schoolYearContext->yearName();
|
||||
|
||||
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
|
||||
|
||||
@@ -2399,11 +2425,10 @@ class AdministratorController extends BaseController
|
||||
return view('enroll_withdraw/enrollment_withdrawal', [
|
||||
'students' => $students,
|
||||
'classes' => $classes, // <-- used by the modal <select>
|
||||
'schoolYears' => $schoolYears,
|
||||
'selectedYear' => $selectedYear,
|
||||
'currentYear' => (string)$this->schoolYear,
|
||||
'isCurrentYear' => ((string)$selectedYear === (string)$this->schoolYear),
|
||||
'missingYear' => empty($this->schoolYear),
|
||||
'currentYear' => $selectedYear,
|
||||
'isCurrentYear' => ! $schoolYearContext->isReadonly(),
|
||||
'missingYear' => $selectedYear === '',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Enrollment/Withdrawal page error: {msg}', ['msg' => $e->getMessage()]);
|
||||
@@ -2415,8 +2440,7 @@ class AdministratorController extends BaseController
|
||||
public function enrollmentWithdrawalData()
|
||||
{
|
||||
try {
|
||||
$schoolYears = $this->availableSchoolYears();
|
||||
$selectedYear = $this->selectedEnrollmentSchoolYear($schoolYears);
|
||||
$selectedYear = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
|
||||
|
||||
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
|
||||
|
||||
@@ -2502,7 +2526,6 @@ class AdministratorController extends BaseController
|
||||
'csrfHash' => csrf_hash(),
|
||||
'semester' => (string)$this->semester,
|
||||
'school_year' => (string)$selectedYear,
|
||||
'schoolYears' => $schoolYears,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'enrollmentWithdrawalData error: {msg}', ['msg' => $e->getMessage()]);
|
||||
@@ -2510,70 +2533,6 @@ class AdministratorController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
private function availableSchoolYears(): array
|
||||
{
|
||||
$years = [];
|
||||
$addYear = static function (mixed $value) use (&$years): void {
|
||||
$year = trim((string) $value);
|
||||
if ($year !== '' && !in_array($year, $years, true)) {
|
||||
$years[] = $year;
|
||||
}
|
||||
};
|
||||
|
||||
$addYear($this->schoolYear ?? null);
|
||||
|
||||
if ($this->db->tableExists('school_years')) {
|
||||
$rows = $this->db->table('school_years')
|
||||
->select('name')
|
||||
->orderBy('name', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$addYear($row['name'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('enrollments')) {
|
||||
$rows = $this->db->table('enrollments')
|
||||
->distinct()
|
||||
->select('school_year')
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$addYear($row['school_year'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
usort($years, static fn(string $a, string $b): int => strnatcasecmp($b, $a));
|
||||
|
||||
$activeYear = trim((string) ($this->schoolYear ?? ''));
|
||||
if ($activeYear !== '') {
|
||||
$years = array_values(array_filter($years, static fn(string $year): bool => $year !== $activeYear));
|
||||
array_unshift($years, $activeYear);
|
||||
}
|
||||
|
||||
return $years;
|
||||
}
|
||||
|
||||
private function selectedEnrollmentSchoolYear(array $schoolYears): string
|
||||
{
|
||||
$requestedYear = trim((string) ($this->request->getGet('schoolYear') ?? ''));
|
||||
if ($requestedYear !== '' && in_array($requestedYear, $schoolYears, true)) {
|
||||
return $requestedYear;
|
||||
}
|
||||
|
||||
$activeYear = trim((string) ($this->schoolYear ?? ''));
|
||||
if ($activeYear !== '') {
|
||||
return $activeYear;
|
||||
}
|
||||
|
||||
return (string) ($schoolYears[0] ?? '');
|
||||
}
|
||||
|
||||
private function enrollmentClassOptions(string $selectedYear): array
|
||||
{
|
||||
$select = ['id', 'class_section_id', 'class_section_name'];
|
||||
@@ -2795,6 +2754,9 @@ class AdministratorController extends BaseController
|
||||
}
|
||||
|
||||
log_message('info', "Created enrollment for student ID {$studentId} with status {$newEnrollmentStatus} and admission {$admissionStatus}.");
|
||||
if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
|
||||
$this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
|
||||
}
|
||||
continue; // go to next student
|
||||
}
|
||||
|
||||
@@ -2811,6 +2773,9 @@ class AdministratorController extends BaseController
|
||||
// Skip if no actual change
|
||||
if ($oldStatus === $newEnrollmentStatus) {
|
||||
log_message('debug', "No status change for student {$studentId} ({$oldStatus}) — skipping.");
|
||||
if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
|
||||
$this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2830,6 +2795,9 @@ class AdministratorController extends BaseController
|
||||
}
|
||||
|
||||
log_message('info', "Updated enrollment for student ID $studentId: {$oldStatus} → {$newEnrollmentStatus} (admission: {$admissionStatus})");
|
||||
if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) {
|
||||
$this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear);
|
||||
}
|
||||
|
||||
// Student name
|
||||
$studentRow = $this->studentModel->find($studentId);
|
||||
@@ -3009,4 +2977,71 @@ class AdministratorController extends BaseController
|
||||
->with('error', 'An unexpected error occurred while processing enrollments.');
|
||||
}
|
||||
}
|
||||
|
||||
private function applyDistributionDraftToStudentClass(int $studentId, string $year): void
|
||||
{
|
||||
try {
|
||||
$draftModel = new StudentSectionDistributionDraftModel();
|
||||
$draft = $draftModel->where('student_id', $studentId)
|
||||
->where('school_year', $year)
|
||||
->where('status', 'pending')
|
||||
->first();
|
||||
|
||||
if (!$draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targetSectionId = (int)($draft['class_section_id'] ?? 0);
|
||||
if ($targetSectionId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$studentClass = new StudentClassModel();
|
||||
$exists = $studentClass->where('student_id', $studentId)
|
||||
->where('school_year', $year)
|
||||
->first();
|
||||
|
||||
$payload = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $targetSectionId,
|
||||
'school_year' => $year,
|
||||
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($exists) {
|
||||
$studentClass->update((int)$exists['id'], $payload);
|
||||
} else {
|
||||
$payload['created_at'] = utc_now();
|
||||
$studentClass->insert($payload);
|
||||
}
|
||||
|
||||
$this->db->table('enrollments')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $year)
|
||||
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
|
||||
->update([
|
||||
'class_section_id' => $targetSectionId,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
|
||||
$this->db->table('promotion_queue')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year_to', $year)
|
||||
->update([
|
||||
'to_class_section_id' => $targetSectionId,
|
||||
'status' => 'applied',
|
||||
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
|
||||
$draftModel->update((int)$draft['id'], [
|
||||
'status' => 'applied',
|
||||
'applied_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'applyDistributionDraftToStudentClass failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ class AssignmentController extends BaseController
|
||||
$selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? '');
|
||||
$year = (string)($this->schoolYear ?? '');
|
||||
|
||||
$this->applyPendingDistributionDraftsForEnrolledStudents($year);
|
||||
|
||||
$tcQ = $this->teacherClassModel;
|
||||
if ($year !== '') {
|
||||
$tcQ = $tcQ->where('school_year', $year);
|
||||
@@ -204,6 +206,94 @@ class AssignmentController extends BaseController
|
||||
return view('administrator/class_assignment', $data);
|
||||
}
|
||||
|
||||
private function applyPendingDistributionDraftsForEnrolledStudents(string $year): void
|
||||
{
|
||||
if ($year === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::connect();
|
||||
if (! $db->tableExists('student_section_distribution_drafts')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = $db->table('student_section_distribution_drafts d')
|
||||
->select('d.id, d.student_id, d.class_section_id')
|
||||
->join(
|
||||
'enrollments e',
|
||||
'e.student_id = d.student_id AND e.school_year = d.school_year',
|
||||
'inner'
|
||||
)
|
||||
->where('d.school_year', $year)
|
||||
->where('d.status', 'pending')
|
||||
->whereIn('e.enrollment_status', ['payment pending', 'enrolled'])
|
||||
->groupBy('d.id, d.student_id, d.class_section_id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
if (empty($rows)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = utc_now();
|
||||
$updatedBy = (int)(session()->get('user_id') ?? 0) ?: null;
|
||||
|
||||
$db->transStart();
|
||||
foreach ($rows as $row) {
|
||||
$studentId = (int)($row['student_id'] ?? 0);
|
||||
$sectionId = (int)($row['class_section_id'] ?? 0);
|
||||
if ($studentId <= 0 || $sectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = $db->table('student_class')
|
||||
->select('id')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $year)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$payload = [
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $sectionId,
|
||||
'school_year' => $year,
|
||||
'updated_by' => $updatedBy,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$db->table('student_class')
|
||||
->where('id', (int)$existing['id'])
|
||||
->update($payload);
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
$db->table('student_class')->insert($payload);
|
||||
}
|
||||
|
||||
$db->table('enrollments')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $year)
|
||||
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
|
||||
->update([
|
||||
'class_section_id' => $sectionId,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$db->table('student_section_distribution_drafts')
|
||||
->where('id', (int)$row['id'])
|
||||
->update([
|
||||
'status' => 'applied',
|
||||
'applied_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
$db->transComplete();
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'applyPendingDistributionDraftsForEnrolledStudents failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function save()
|
||||
|
||||
@@ -322,23 +322,23 @@ class EventController extends ResourceController
|
||||
|
||||
public function index()
|
||||
{
|
||||
$eventModel = new EventModel();
|
||||
|
||||
$today = local_date(utc_now(), 'Y-m-d');
|
||||
$schoolYear = $this->currentSchoolYearName();
|
||||
|
||||
// Fetch all events
|
||||
$events = $eventModel
|
||||
$events = $this->eventModel
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll();
|
||||
|
||||
// Fetch active events (not expired)
|
||||
$activeEventCount = $eventModel
|
||||
$activeEventCount = $this->eventModel
|
||||
->where('school_year', $schoolYear)
|
||||
->where('expiration_date >=', $today)
|
||||
->countAllResults();
|
||||
|
||||
return view('administrator/events/event_list', [
|
||||
'events' => $events,
|
||||
'activeEventCount' => $activeEventCount
|
||||
'activeEventCount' => $activeEventCount,
|
||||
'schoolYear' => $schoolYear,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,14 +54,29 @@ class ExtraChargesController extends BaseController
|
||||
/** Render HTML management page */
|
||||
public function page()
|
||||
{
|
||||
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
|
||||
$query = $this->request->getGet();
|
||||
$hasLegacyTermFilter = false;
|
||||
foreach ($legacyTermKeys as $key) {
|
||||
if (array_key_exists($key, $query)) {
|
||||
unset($query[$key]);
|
||||
$hasLegacyTermFilter = true;
|
||||
}
|
||||
}
|
||||
if ($hasLegacyTermFilter) {
|
||||
$target = site_url('admin/charges') . (!empty($query) ? '?' . http_build_query($query) : '');
|
||||
return redirect()->to($target);
|
||||
}
|
||||
|
||||
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||
$schoolYear = $schoolYearContext->yearName();
|
||||
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
|
||||
$status = $this->request->getGet('status') ?: null;
|
||||
$yearSelect = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
||||
|
||||
$rows = [];
|
||||
if ($parentId > 0) {
|
||||
$rows = $this->additionalChargeModel
|
||||
->byParentTerm($parentId, $this->schoolYear, $this->semester, $status);
|
||||
->byParentTerm($parentId, $schoolYear, $this->semester, $status);
|
||||
}
|
||||
|
||||
// Load ALL parents for current view
|
||||
@@ -85,7 +100,7 @@ class ExtraChargesController extends BaseController
|
||||
$parentIds = array_map(fn($r) => (int)$r['id'], $parents);
|
||||
$invoicesByParent = [];
|
||||
if (!empty($parentIds)) {
|
||||
$all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $yearSelect);
|
||||
$all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $schoolYear);
|
||||
foreach ($all as $inv) {
|
||||
$pid = (int)$inv['parent_id'];
|
||||
$invoicesByParent[$pid][] = [
|
||||
@@ -107,7 +122,7 @@ class ExtraChargesController extends BaseController
|
||||
|
||||
// ✅ Always pull all charges for the selected year & current semester (all parents)
|
||||
$rows = $this->additionalChargeModel->listAllForTerm(
|
||||
$yearSelect,
|
||||
$schoolYear,
|
||||
$this->semester,
|
||||
$status,
|
||||
$q,
|
||||
@@ -115,38 +130,6 @@ class ExtraChargesController extends BaseController
|
||||
);
|
||||
$pager = $this->additionalChargeModel->pager;
|
||||
|
||||
// Build school year options from data (additional_charges + invoices)
|
||||
$schoolYears = [];
|
||||
try {
|
||||
$q1 = $this->db->table('additional_charges')->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')->get()->getResultArray();
|
||||
foreach ($q1 as $r) {
|
||||
$val = (string)($r['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
try {
|
||||
$q2 = $this->db->table('invoices')->select('DISTINCT school_year', false)
|
||||
->where('school_year IS NOT NULL', null, false)
|
||||
->orderBy('school_year', 'DESC')->get()->getResultArray();
|
||||
foreach ($q2 as $r) {
|
||||
$val = (string)($r['school_year'] ?? '');
|
||||
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
if (empty($schoolYears) && is_string($this->schoolYear) && $this->schoolYear !== '') {
|
||||
// fallback: generate recent years around configured schoolYear
|
||||
$schoolYears[] = $this->schoolYear;
|
||||
// Optionally add previous/next
|
||||
[$start, $end] = explode('-', $this->schoolYear) + [0 => date('Y'), 1 => date('Y')+1];
|
||||
$start = (int)$start;
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$schoolYears[] = ($start - $i) . '-' . (($start - $i) + 1);
|
||||
}
|
||||
}
|
||||
rsort($schoolYears);
|
||||
|
||||
return view('payment/extra_charges', [
|
||||
'q' => $q,
|
||||
'pager' => $pager,
|
||||
@@ -157,9 +140,9 @@ class ExtraChargesController extends BaseController
|
||||
'parentId' => $parentId,
|
||||
'selectedParentLabel' => $selectedParentLabel,
|
||||
'status' => $status,
|
||||
'schoolYear' => $yearSelect,
|
||||
'schoolYears' => $schoolYears,
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'isSchoolYearReadonly' => $schoolYearContext->isReadonly(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -273,7 +256,7 @@ class ExtraChargesController extends BaseController
|
||||
public function invoicesForParent()
|
||||
{
|
||||
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
|
||||
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
||||
$schoolYear = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
|
||||
|
||||
$rows = ($parentId > 0)
|
||||
? ($this->invoiceModel->getInvoicesByUserId($parentId, $schoolYear) ?? [])
|
||||
@@ -304,6 +287,9 @@ class ExtraChargesController extends BaseController
|
||||
|
||||
public function store()
|
||||
{
|
||||
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||
$this->assertSchoolYearWritable($schoolYearContext);
|
||||
$schoolYear = $schoolYearContext->yearName();
|
||||
$data = $this->request->getPost();
|
||||
|
||||
$rules = [
|
||||
@@ -336,8 +322,8 @@ class ExtraChargesController extends BaseController
|
||||
$payload = [
|
||||
'parent_id' => (int)$data['parent_id'], // ← users.id of the parent
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => $data['school_year'] ?? $this->schoolYear,
|
||||
'semester' => $data['semester'] ?? $this->semester,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => (string)$this->semester,
|
||||
'charge_type' => $chargeType,
|
||||
'title' => trim($data['title']),
|
||||
'description' => trim($data['description'] ?? ''),
|
||||
@@ -351,7 +337,7 @@ class ExtraChargesController extends BaseController
|
||||
$this->db->transStart();
|
||||
|
||||
// BEFORE
|
||||
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
|
||||
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $schoolYear);
|
||||
|
||||
// Insert charge
|
||||
$this->additionalChargeModel->insert($payload);
|
||||
@@ -363,7 +349,7 @@ class ExtraChargesController extends BaseController
|
||||
}
|
||||
|
||||
// AFTER
|
||||
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
|
||||
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $schoolYear);
|
||||
|
||||
// Parent USER (not parent table)
|
||||
$parentUser = $this->userModel->getUserInfoById($data['parent_id']);
|
||||
@@ -526,8 +512,8 @@ class ExtraChargesController extends BaseController
|
||||
/** JSON: list charges for the current term (with optional filters). */
|
||||
public function apiList()
|
||||
{
|
||||
$year = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
|
||||
$sem = (string)($this->request->getGet('semester') ?? $this->semester);
|
||||
$year = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
|
||||
$sem = (string)$this->semester;
|
||||
$status = $this->request->getGet('status') ?: null;
|
||||
$q = trim((string)($this->request->getGet('q') ?? '')) ?: null;
|
||||
$per = (int)($this->request->getGet('per_page') ?? 50);
|
||||
|
||||
@@ -90,6 +90,10 @@ class NotificationsController extends BaseController
|
||||
{
|
||||
helper('url');
|
||||
|
||||
if ($redirect = $this->redirectWithoutLegacyTermFilters('notifications/active')) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$targetGroup = $this->request->getGet('target_group');
|
||||
|
||||
return view('notifications/list_active', [
|
||||
@@ -102,6 +106,10 @@ class NotificationsController extends BaseController
|
||||
{
|
||||
helper(['url', 'form']);
|
||||
|
||||
if ($redirect = $this->redirectWithoutLegacyTermFilters('notifications/deleted')) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
return view('notifications/list_deleted', [
|
||||
'deletedNotificationsEndpoint' => site_url('api/notifications/deleted'),
|
||||
'restoreEndpoint' => site_url('notifications/restore'),
|
||||
@@ -149,7 +157,7 @@ class NotificationsController extends BaseController
|
||||
$targetGroup = null;
|
||||
}
|
||||
|
||||
$schoolYear = (string) ((new \App\Models\ConfigurationModel())->getConfig('school_year') ?? '');
|
||||
$schoolYear = $this->currentSchoolYearName();
|
||||
if ($schoolYear !== '' && db_connect()->fieldExists('school_year', 'notifications')) {
|
||||
$this->notificationModel->where('school_year', $schoolYear);
|
||||
}
|
||||
@@ -215,4 +223,25 @@ class NotificationsController extends BaseController
|
||||
'notifications' => $notifications,
|
||||
];
|
||||
}
|
||||
|
||||
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
|
||||
{
|
||||
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
|
||||
$query = $this->request->getGet();
|
||||
$hasLegacyTermFilter = false;
|
||||
|
||||
foreach ($legacyTermKeys as $key) {
|
||||
if (array_key_exists($key, $query)) {
|
||||
unset($query[$key]);
|
||||
$hasLegacyTermFilter = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasLegacyTermFilter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
|
||||
return redirect()->to($target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\UserModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\StudentSectionDistributionDraftModel;
|
||||
use App\Models\AuthorizedUserModel;
|
||||
use App\Models\EmergencyContactModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
@@ -444,6 +445,7 @@ class ParentController extends BaseController
|
||||
$this->applyPromotionAssignment((int)$studentId, (string)$this->schoolYear);
|
||||
} else {
|
||||
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled.");
|
||||
$this->applyPromotionAssignment((int)$studentId, (string)$this->schoolYear);
|
||||
}
|
||||
} else {
|
||||
$passedPreviousYear = $this->studentPassedPreviousYear((int)$studentId, (string)$this->schoolYear);
|
||||
@@ -579,20 +581,29 @@ class ParentController extends BaseController
|
||||
try {
|
||||
$promo = new \App\Models\PromotionQueueModel();
|
||||
$classSectionModel = new \App\Models\ClassSectionModel();
|
||||
$draftModel = new StudentSectionDistributionDraftModel();
|
||||
$studentClass = new \App\Models\StudentClassModel();
|
||||
|
||||
$draft = $draftModel->where('student_id', $studentId)
|
||||
->where('school_year', $year)
|
||||
->where('status', 'pending')
|
||||
->first();
|
||||
|
||||
$row = $promo->where('student_id', $studentId)
|
||||
->where('school_year_to', $year)
|
||||
->first();
|
||||
|
||||
if (!$row) return; // nothing to do
|
||||
if (!$row && !$draft) return; // nothing to do
|
||||
|
||||
$targetSectionId = (int)($row['to_class_section_id'] ?? 0);
|
||||
$targetSectionId = (int)($draft['class_section_id'] ?? 0);
|
||||
if ($targetSectionId <= 0) {
|
||||
$targetSectionId = (int)($row['to_class_section_id'] ?? 0);
|
||||
}
|
||||
if ($targetSectionId <= 0) {
|
||||
// Resolve base section for target class (e.g., '3')
|
||||
$base = $classSectionModel->getBaseSectionByClassId((int)$row['to_class_id']);
|
||||
$base = $classSectionModel->getBaseSectionByClassId((int)($row['to_class_id'] ?? 0));
|
||||
if (!$base) {
|
||||
log_message('warning', 'applyPromotionAssignment: base section not found for class_id=' . (int)$row['to_class_id']);
|
||||
log_message('warning', 'applyPromotionAssignment: no draft or base section found for student_id=' . $studentId . ', year=' . $year);
|
||||
return;
|
||||
}
|
||||
$targetSectionId = (int)($base['class_section_id'] ?? 0);
|
||||
@@ -620,12 +631,31 @@ class ParentController extends BaseController
|
||||
$studentClass->insert($payload);
|
||||
}
|
||||
|
||||
// Mark promotion as applied
|
||||
$promo->update((int)$row['id'], [
|
||||
'status' => 'applied',
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
|
||||
]);
|
||||
$this->db->table('enrollments')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $year)
|
||||
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
|
||||
->update([
|
||||
'class_section_id' => $targetSectionId,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
|
||||
// Mark promotion as applied when promotion_queue is the source.
|
||||
if ($row) {
|
||||
$promo->update((int)$row['id'], [
|
||||
'status' => 'applied',
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($draft) {
|
||||
$draftModel->update((int)$draft['id'], [
|
||||
'status' => 'applied',
|
||||
'applied_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'applyPromotionAssignment failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@ class RolePermissionController extends Controller
|
||||
public function assignRole()
|
||||
{
|
||||
helper('url');
|
||||
|
||||
if ($redirect = $this->redirectWithoutLegacyTermFilters('rolepermission/assign_role')) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
return view('rolepermission/assign_role', [
|
||||
'assignRoleEndpoint' => site_url('api/rolepermission/users'),
|
||||
'rolesEndpoint' => site_url('api/rolepermission/roles'),
|
||||
@@ -196,6 +201,27 @@ class RolePermissionController extends Controller
|
||||
return ['users' => $normalized];
|
||||
}
|
||||
|
||||
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
|
||||
{
|
||||
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
|
||||
$query = $this->request->getGet();
|
||||
$hasLegacyTermFilter = false;
|
||||
|
||||
foreach ($legacyTermKeys as $key) {
|
||||
if (array_key_exists($key, $query)) {
|
||||
unset($query[$key]);
|
||||
$hasLegacyTermFilter = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasLegacyTermFilter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
|
||||
return redirect()->to($target);
|
||||
}
|
||||
|
||||
private function updateStaffRecord(int $userId, array $newRoleNames): void
|
||||
{
|
||||
$excludedRoles = ['parent', 'student', 'guest'];
|
||||
|
||||
@@ -32,6 +32,12 @@ class StaffController extends BaseController
|
||||
|
||||
public function index()
|
||||
{
|
||||
if ($redirect = $this->redirectWithoutLegacyTermFilters('staff/index')) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||
|
||||
// roles we never show
|
||||
$excludedRoles = ['student', 'parent', 'guest', 'inactive']; // ← add inactive here
|
||||
|
||||
@@ -45,7 +51,7 @@ class StaffController extends BaseController
|
||||
$assignRows = $this->teacherClassModel
|
||||
->select('teacher_class.teacher_id, teacher_class.position, classSection.class_section_name')
|
||||
->join('classSection', 'classSection.class_section_id = teacher_class.class_section_id', 'left')
|
||||
->where('teacher_class.school_year', (string)$this->schoolYear)
|
||||
->where('teacher_class.school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$assignByTeacher = [];
|
||||
@@ -88,10 +94,31 @@ class StaffController extends BaseController
|
||||
'staff' => $staffList,
|
||||
'issues_count' => $issuesCount,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'school_year' => $schoolYear,
|
||||
]);
|
||||
}
|
||||
|
||||
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
|
||||
{
|
||||
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
|
||||
$query = $this->request->getGet();
|
||||
$hasLegacyTermFilter = false;
|
||||
|
||||
foreach ($legacyTermKeys as $key) {
|
||||
if (array_key_exists($key, $query)) {
|
||||
unset($query[$key]);
|
||||
$hasLegacyTermFilter = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasLegacyTermFilter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
|
||||
return redirect()->to($target);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('staff/create');
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\ClassSectionModel;
|
||||
use App\Models\EmergencyContactModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\StudentSectionDistributionDraftModel;
|
||||
use App\Models\StudentAllergyModel;
|
||||
use App\Models\StudentMedicalConditionModel;
|
||||
use CodeIgniter\Database\Exceptions\DataException;
|
||||
@@ -866,10 +867,10 @@ class StudentController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* POST admin endpoint: auto distribute students into lettered sections for a class
|
||||
* Input: class_id (int), students_per_section (int), school_year (optional)
|
||||
* Uses promotion_queue for the selected year (students who passed last year to this class).
|
||||
* Balances male/female per section and respects capacity.
|
||||
* POST admin endpoint: create draft balanced distribution rows for a class.
|
||||
*
|
||||
* Input: class_id/class_section_id, section_count, min_students_per_section,
|
||||
* max_students_per_section, school_year.
|
||||
*/
|
||||
public function autoDistributeSections()
|
||||
{
|
||||
@@ -884,7 +885,10 @@ class StudentController extends BaseController
|
||||
try {
|
||||
$classId = (int) $this->request->getPost('class_id');
|
||||
$classSectionId = (int) $this->request->getPost('class_section_id');
|
||||
$perSec = (int) $this->request->getPost('students_per_section');
|
||||
$sectionCount = (int) $this->request->getPost('section_count');
|
||||
$minPerSection = (int) $this->request->getPost('min_students_per_section');
|
||||
$maxRaw = trim((string) ($this->request->getPost('max_students_per_section') ?? ''));
|
||||
$maxPerSection = $maxRaw === '' ? null : (int) $maxRaw;
|
||||
$year = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear));
|
||||
|
||||
if ($classId <= 0 && $classSectionId > 0) {
|
||||
@@ -892,51 +896,27 @@ class StudentController extends BaseController
|
||||
$classId = (int) ($cid ?? 0);
|
||||
}
|
||||
|
||||
if ($classId <= 0 || $perSec <= 0) {
|
||||
$msg = 'Invalid class_id or students_per_section.';
|
||||
if ($classId <= 0 || $sectionCount <= 0 || $minPerSection <= 0 || ($maxPerSection !== null && $maxPerSection <= 0)) {
|
||||
$msg = 'Enter a valid class, section count, minimum size, and optional maximum size.';
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
|
||||
}
|
||||
|
||||
$promo = new \App\Models\PromotionQueueModel();
|
||||
|
||||
// Candidates from promotion queue for this class/year and enrolled (or payment pending)
|
||||
$cands = $promo->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'])
|
||||
->findAll();
|
||||
$cands = $this->distributionCandidates($classId, $year);
|
||||
|
||||
if (empty($cands)) {
|
||||
$msg = 'No students found in promotion queue for selected class/year.';
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg);
|
||||
}
|
||||
|
||||
// Filter to those with enrollment for this year in acceptable statuses
|
||||
$studentIds = array_map(static fn($r) => (int)$r['student_id'], $cands);
|
||||
$enrolledIds = [];
|
||||
if (!empty($studentIds)) {
|
||||
$rows = $this->db->table('enrollments')
|
||||
->select('student_id')
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('school_year', $year)
|
||||
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
|
||||
->groupBy('student_id')
|
||||
->get()->getResultArray();
|
||||
$enrolledIds = array_map(static fn($r) => (int)$r['student_id'], $rows);
|
||||
}
|
||||
|
||||
$cands = array_values(array_filter($cands, static function ($r) use ($enrolledIds) {
|
||||
return in_array((int)$r['student_id'], $enrolledIds, true);
|
||||
}));
|
||||
|
||||
if (empty($cands)) {
|
||||
$msg = 'No eligible enrolled students found to distribute.';
|
||||
$msg = 'No promoted students found to distribute for selected class/year.';
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg);
|
||||
}
|
||||
|
||||
$total = count($cands);
|
||||
$sectionsNeeded = (int) ceil($total / $perSec);
|
||||
if ($sectionCount * $minPerSection > $total) {
|
||||
$msg = 'Insufficient students: ' . $sectionCount . ' sections require at least ' . ($sectionCount * $minPerSection) . ' students, but only ' . $total . ' are available.';
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
|
||||
}
|
||||
if ($maxPerSection !== null && $total > $sectionCount * $maxPerSection) {
|
||||
$msg = 'Capacity exceeded: ' . $sectionCount . ' sections can hold at most ' . ($sectionCount * $maxPerSection) . ' students, but ' . $total . ' must be assigned.';
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
|
||||
}
|
||||
|
||||
// Fetch lettered sections for this class
|
||||
$letters = $this->classSectionModel->getLetterSectionsByClassId($classId);
|
||||
@@ -945,109 +925,66 @@ class StudentController extends BaseController
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
|
||||
}
|
||||
|
||||
if (count($letters) < $sectionsNeeded) {
|
||||
$msg = 'Not enough sections available. Needed: ' . $sectionsNeeded . ', available: ' . count($letters);
|
||||
if (count($letters) < $sectionCount) {
|
||||
$msg = 'Not enough sections available. Needed: ' . $sectionCount . ', available: ' . count($letters);
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
|
||||
}
|
||||
|
||||
// Keep only required number of sections
|
||||
$letters = array_slice($letters, 0, $sectionsNeeded);
|
||||
$letters = array_slice($letters, 0, $sectionCount);
|
||||
$buckets = $this->buildBalancedDistribution($cands, $letters, $minPerSection, $maxPerSection);
|
||||
|
||||
// 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 = (string)($r['gender'] ?? '');
|
||||
if (strcasecmp($g, 'Female') === 0) $females[] = $r; else $males[] = $r; // default non-female -> male bucket
|
||||
}
|
||||
|
||||
// Helper to pick next bucket with available capacity and least of a gender
|
||||
$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 for balance
|
||||
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: set to_class_section_id on queue and upsert student_class
|
||||
$promoIdsBySid = [];
|
||||
foreach ($cands as $r) {
|
||||
$promoIdsBySid[(int)$r['student_id']] = (int)$r['id'];
|
||||
}
|
||||
|
||||
$studentClass = new StudentClassModel();
|
||||
$draftModel = new StudentSectionDistributionDraftModel();
|
||||
$promo = new \App\Models\PromotionQueueModel();
|
||||
$updatedBy = (int)(session()->get('user_id') ?? 0) ?: null;
|
||||
$now = utc_now();
|
||||
$batchKey = sha1($year . ':' . $classId . ':' . microtime(true));
|
||||
|
||||
$this->db->transStart();
|
||||
$studentIdsToReplace = array_values(array_unique(array_map(
|
||||
static fn(array $student): int => (int)($student['student_id'] ?? 0),
|
||||
$cands
|
||||
)));
|
||||
if (!empty($studentIdsToReplace)) {
|
||||
$draftModel->where('school_year', $year)
|
||||
->whereIn('student_id', $studentIdsToReplace)
|
||||
->where('status', 'pending')
|
||||
->delete();
|
||||
}
|
||||
foreach ($buckets as $b) {
|
||||
$secId = (int)$b['class_section_id'];
|
||||
foreach ($b['assigned'] as $sid) {
|
||||
// Update promotion queue
|
||||
if (isset($promoIdsBySid[$sid])) {
|
||||
$promo->update($promoIdsBySid[$sid], [
|
||||
foreach ($b['assigned'] as $student) {
|
||||
$sid = (int)$student['student_id'];
|
||||
$draftModel->insert([
|
||||
'student_id' => $sid,
|
||||
'class_id' => $classId,
|
||||
'class_section_id' => $secId,
|
||||
'school_year' => $year,
|
||||
'previous_school_year' => (string)($student['school_year_from'] ?? ''),
|
||||
'previous_final_score' => $student['previous_final_score'],
|
||||
'score_group' => $student['score_group'],
|
||||
'status' => 'pending',
|
||||
'batch_key' => $batchKey,
|
||||
'created_by' => $updatedBy,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
if ((int)($student['promotion_queue_id'] ?? 0) > 0) {
|
||||
$promo->update((int)$student['promotion_queue_id'], [
|
||||
'to_class_section_id' => $secId,
|
||||
'status' => 'assigned',
|
||||
'updated_by' => $updatedBy,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
// Upsert student_class
|
||||
$exists = $studentClass->where('student_id', $sid)
|
||||
->where('school_year', $year)
|
||||
->where('semester', (string)$this->semester)
|
||||
->first();
|
||||
|
||||
$payload = [
|
||||
'student_id' => $sid,
|
||||
'class_section_id' => $secId,
|
||||
'school_year' => $year,
|
||||
'semester' => (string)$this->semester,
|
||||
'updated_by' => $updatedBy,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if ($exists) {
|
||||
$studentClass->update((int)$exists['id'], $payload);
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
$studentClass->insert($payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->db->transComplete();
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
$msg = 'Distribution could not be saved. No official student class rows were changed.';
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg);
|
||||
}
|
||||
|
||||
// Build a map for section id -> name (for friendly headers)
|
||||
$nameById = [];
|
||||
foreach ($letters as $secRow) {
|
||||
$nameById[(int)$secRow['class_section_id']] = (string)($secRow['class_section_name'] ?? '');
|
||||
@@ -1056,27 +993,323 @@ class StudentController extends BaseController
|
||||
$summary = [];
|
||||
foreach ($buckets as $b) {
|
||||
$secId = (int)$b['class_section_id'];
|
||||
$scores = array_map(static fn($s): float => (float)$s['previous_final_score'], $b['assigned']);
|
||||
$groups = ['90_100' => 0, '80_89' => 0, '70_79' => 0, '69_below' => 0];
|
||||
$male = 0;
|
||||
$female = 0;
|
||||
$studentNames = [];
|
||||
foreach ($b['assigned'] as $student) {
|
||||
$groups[$student['score_group']] = ($groups[$student['score_group']] ?? 0) + 1;
|
||||
$gender = strtolower((string)($student['gender'] ?? ''));
|
||||
if ($gender === 'female') $female++; else $male++;
|
||||
$studentName = trim((string)($student['student_name'] ?? ''));
|
||||
if ($studentName === '') {
|
||||
$studentName = 'Student #' . (int)($student['student_id'] ?? 0);
|
||||
}
|
||||
$studentNames[] = $studentName;
|
||||
}
|
||||
$summary[] = [
|
||||
'class_section_id' => $secId,
|
||||
'class_section_name' => $nameById[$secId] ?? (string)$secId,
|
||||
'total' => count($b['assigned']),
|
||||
'male' => $b['male'],
|
||||
'female' => $b['female'],
|
||||
'male' => $male,
|
||||
'female' => $female,
|
||||
'score_groups' => $groups,
|
||||
'average_score' => count($scores) > 0 ? round(array_sum($scores) / count($scores), 2) : null,
|
||||
'student_names' => $studentNames,
|
||||
];
|
||||
}
|
||||
|
||||
return $isAjax
|
||||
? $json(['ok' => true, 'message' => 'Auto distribution completed.', 'sections' => $summary])
|
||||
: redirect()->back()->with('success', 'Auto distribution completed.');
|
||||
? $json(['ok' => true, 'message' => 'Draft distribution saved. Students will move to student_class when they enroll.', 'sections' => $summary])
|
||||
: redirect()->back()->with('success', 'Draft distribution saved.');
|
||||
} catch (\Throwable $e) {
|
||||
$msg = 'Auto distribution failed: ' . $e->getMessage();
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg);
|
||||
}
|
||||
}
|
||||
|
||||
private function distributionCandidates(int $classId, string $year): array
|
||||
{
|
||||
$rows = $this->db->table('promotion_queue pq')
|
||||
->select('pq.id AS promotion_queue_id, pq.student_id, pq.school_year_from, pq.to_class_id, students.firstname, students.lastname, students.gender, sd.year_score AS decision_score')
|
||||
->join('students', 'students.id = pq.student_id', 'left')
|
||||
->join('student_decisions sd', 'sd.student_id = pq.student_id AND sd.school_year = pq.school_year_from', 'left')
|
||||
->where('pq.to_class_id', $classId)
|
||||
->where('pq.school_year_to', $year)
|
||||
->whereIn('pq.status', ['queued', 'assigned'])
|
||||
->groupBy('pq.id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
if (empty($rows)) {
|
||||
return $this->decisionDistributionCandidates($classId, $year);
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$score = is_numeric($row['decision_score'] ?? null)
|
||||
? (float)$row['decision_score']
|
||||
: $this->previousAverageScore((int)$row['student_id'], (string)($row['school_year_from'] ?? ''));
|
||||
$score = $score === null ? 0.0 : max(0.0, min(100.0, $score));
|
||||
$row['previous_final_score'] = $score;
|
||||
$row['score_group'] = $this->scoreGroup($score);
|
||||
$row['student_name'] = $this->formatStudentName($row);
|
||||
$out[] = $row;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function decisionDistributionCandidates(int $classId, string $targetSchoolYear): array
|
||||
{
|
||||
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||||
if ($previousSchoolYear === null || ! $this->db->tableExists('student_decisions')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->db->table('student_decisions sd')
|
||||
->select('sd.id AS decision_id, sd.student_id, sd.class_section_name, sd.year_score, sd.decision, students.firstname, students.lastname, students.gender')
|
||||
->join('students', 'students.id = sd.student_id', 'left')
|
||||
->where('sd.school_year', $previousSchoolYear)
|
||||
->where('students.is_active', 1)
|
||||
->orderBy('sd.updated_at', 'DESC')
|
||||
->orderBy('sd.id', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$seen = [];
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$studentId = (int)($row['student_id'] ?? 0);
|
||||
if ($studentId <= 0 || isset($seen[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$studentId] = true;
|
||||
|
||||
$targetClassId = $this->targetClassIdFromDecision(
|
||||
(string)($row['class_section_name'] ?? ''),
|
||||
(string)($row['decision'] ?? '')
|
||||
);
|
||||
if ($targetClassId !== $classId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = is_numeric($row['year_score'] ?? null) ? (float)$row['year_score'] : 0.0;
|
||||
$score = max(0.0, min(100.0, $score));
|
||||
$out[] = [
|
||||
'promotion_queue_id' => 0,
|
||||
'student_id' => $studentId,
|
||||
'school_year_from' => $previousSchoolYear,
|
||||
'to_class_id' => $classId,
|
||||
'student_name' => $this->formatStudentName($row),
|
||||
'gender' => (string)($row['gender'] ?? ''),
|
||||
'previous_final_score' => $score,
|
||||
'score_group' => $this->scoreGroup($score),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function targetClassIdFromDecision(string $classSectionName, string $decision): ?int
|
||||
{
|
||||
$decision = strtolower(trim($decision));
|
||||
$baseName = strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? ''));
|
||||
if ($baseName === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$targetBaseName = $baseName;
|
||||
if ($decision === 'pass') {
|
||||
if ($baseName === 'KG') {
|
||||
$targetBaseName = '1';
|
||||
} elseif (ctype_digit($baseName)) {
|
||||
$level = (int)$baseName;
|
||||
$targetBaseName = $level >= 9 ? 'YOUTH' : (string)($level + 1);
|
||||
} elseif ($baseName === 'YOUTH') {
|
||||
$targetBaseName = 'YOUTH';
|
||||
}
|
||||
}
|
||||
|
||||
$row = $this->classSectionModel
|
||||
->select('class_id')
|
||||
->where('UPPER(class_section_name)', $targetBaseName)
|
||||
->where("class_section_name NOT LIKE '%-%'", null, false)
|
||||
->first();
|
||||
|
||||
return $row ? (int)$row['class_id'] : null;
|
||||
}
|
||||
|
||||
private function formatStudentName(array $row): string
|
||||
{
|
||||
$name = trim(
|
||||
trim((string)($row['firstname'] ?? '')) . ' ' .
|
||||
trim((string)($row['lastname'] ?? ''))
|
||||
);
|
||||
|
||||
return $name !== '' ? $name : 'Student #' . (int)($row['student_id'] ?? 0);
|
||||
}
|
||||
|
||||
private function previousSchoolYearName(string $schoolYear): ?string
|
||||
{
|
||||
if (preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ((int)$matches[1] - 1) . '-' . ((int)$matches[2] - 1);
|
||||
}
|
||||
|
||||
private function previousAverageScore(int $studentId, string $schoolYear): ?float
|
||||
{
|
||||
if ($studentId <= 0 || $schoolYear === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $this->db->table('semester_scores')
|
||||
->select('AVG(semester_score) AS avg_score')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester_score IS NOT NULL', null, false)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return is_numeric($row['avg_score'] ?? null) ? (float)$row['avg_score'] : null;
|
||||
}
|
||||
|
||||
private function scoreGroup(float $score): string
|
||||
{
|
||||
if ($score >= 90) return '90_100';
|
||||
if ($score >= 80) return '80_89';
|
||||
if ($score >= 70) return '70_79';
|
||||
return '69_below';
|
||||
}
|
||||
|
||||
private function buildBalancedDistribution(array $students, array $sections, int $minPerSection, ?int $maxPerSection): array
|
||||
{
|
||||
$sectionCount = count($sections);
|
||||
$total = count($students);
|
||||
$baseSize = intdiv($total, $sectionCount);
|
||||
$remainder = $total % $sectionCount;
|
||||
$targetSizes = [];
|
||||
|
||||
foreach ($sections as $idx => $section) {
|
||||
$targetSizes[$idx] = $baseSize + ($idx < $remainder ? 1 : 0);
|
||||
}
|
||||
|
||||
$buckets = [];
|
||||
foreach ($sections as $idx => $section) {
|
||||
$buckets[$idx] = [
|
||||
'class_section_id' => (int)$section['class_section_id'],
|
||||
'assigned' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$groups = ['90_100' => [], '80_89' => [], '70_79' => [], '69_below' => []];
|
||||
foreach ($students as $student) {
|
||||
$groups[$student['score_group']][] = $student;
|
||||
}
|
||||
|
||||
$currentCounts = array_fill(0, $sectionCount, 0);
|
||||
$allocations = [];
|
||||
$groupIndex = 0;
|
||||
foreach ($groups as $groupName => $groupStudents) {
|
||||
usort($groupStudents, static fn($a, $b): int => ((float)$b['previous_final_score']) <=> ((float)$a['previous_final_score']));
|
||||
$groupTotal = count($groupStudents);
|
||||
$base = intdiv($groupTotal, $sectionCount);
|
||||
$extra = $groupTotal % $sectionCount;
|
||||
$allocations[$groupName] = array_fill(0, $sectionCount, $base);
|
||||
foreach ($currentCounts as $idx => $cnt) {
|
||||
$currentCounts[$idx] += $base;
|
||||
}
|
||||
|
||||
$order = range(0, $sectionCount - 1);
|
||||
$offset = $groupIndex % $sectionCount;
|
||||
$order = array_merge(array_slice($order, $offset), array_slice($order, 0, $offset));
|
||||
usort($order, static function ($a, $b) use ($targetSizes, $currentCounts) {
|
||||
$remainingA = $targetSizes[$a] - $currentCounts[$a];
|
||||
$remainingB = $targetSizes[$b] - $currentCounts[$b];
|
||||
return $remainingB <=> $remainingA;
|
||||
});
|
||||
|
||||
foreach ($order as $sectionIdx) {
|
||||
if ($extra <= 0) break;
|
||||
if ($currentCounts[$sectionIdx] >= $targetSizes[$sectionIdx]) continue;
|
||||
$allocations[$groupName][$sectionIdx]++;
|
||||
$currentCounts[$sectionIdx]++;
|
||||
$extra--;
|
||||
}
|
||||
$groups[$groupName] = $groupStudents;
|
||||
$groupIndex++;
|
||||
}
|
||||
|
||||
foreach ($groups as $groupName => $groupStudents) {
|
||||
$quotas = $allocations[$groupName];
|
||||
foreach ($groupStudents as $idx => $student) {
|
||||
$round = intdiv($idx, max(1, $sectionCount));
|
||||
$order = range(0, $sectionCount - 1);
|
||||
if ($round % 2 === 1) {
|
||||
$order = array_reverse($order);
|
||||
}
|
||||
foreach ($order as $sectionIdx) {
|
||||
if (($quotas[$sectionIdx] ?? 0) <= 0) continue;
|
||||
$buckets[$sectionIdx]['assigned'][] = $student;
|
||||
$quotas[$sectionIdx]--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->balanceDistributionAverages($buckets, $minPerSection, $maxPerSection);
|
||||
}
|
||||
|
||||
private function balanceDistributionAverages(array $buckets, int $minPerSection, ?int $maxPerSection): array
|
||||
{
|
||||
for ($i = 0; $i < 50; $i++) {
|
||||
$averages = array_map(function ($bucket): float {
|
||||
if (empty($bucket['assigned'])) return 0.0;
|
||||
$scores = array_map(static fn($student): float => (float)$student['previous_final_score'], $bucket['assigned']);
|
||||
return array_sum($scores) / count($scores);
|
||||
}, $buckets);
|
||||
$highIdx = array_keys($averages, max($averages), true)[0];
|
||||
$lowIdx = array_keys($averages, min($averages), true)[0];
|
||||
if (($averages[$highIdx] - $averages[$lowIdx]) <= 1.0) {
|
||||
break;
|
||||
}
|
||||
|
||||
$best = null;
|
||||
foreach ($buckets[$highIdx]['assigned'] as $hiPos => $hiStudent) {
|
||||
foreach ($buckets[$lowIdx]['assigned'] as $loPos => $loStudent) {
|
||||
if ($hiStudent['score_group'] !== $loStudent['score_group']) continue;
|
||||
$trial = $buckets;
|
||||
$trial[$highIdx]['assigned'][$hiPos] = $loStudent;
|
||||
$trial[$lowIdx]['assigned'][$loPos] = $hiStudent;
|
||||
$trialAvg = array_map(function ($bucket): float {
|
||||
if (empty($bucket['assigned'])) return 0.0;
|
||||
$scores = array_map(static fn($student): float => (float)$student['previous_final_score'], $bucket['assigned']);
|
||||
return array_sum($scores) / count($scores);
|
||||
}, $trial);
|
||||
$newSpread = max($trialAvg) - min($trialAvg);
|
||||
if ($newSpread < ($averages[$highIdx] - $averages[$lowIdx])) {
|
||||
$best = [$hiPos, $loPos, $newSpread];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($best === null) {
|
||||
break;
|
||||
}
|
||||
[$hiPos, $loPos] = $best;
|
||||
$tmp = $buckets[$highIdx]['assigned'][$hiPos];
|
||||
$buckets[$highIdx]['assigned'][$hiPos] = $buckets[$lowIdx]['assigned'][$loPos];
|
||||
$buckets[$lowIdx]['assigned'][$loPos] = $tmp;
|
||||
}
|
||||
|
||||
return $buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Return totals per base class (KG, 1..9, Youth) for promotion_queue in selected year
|
||||
* Only counts students with enrollment status 'payment pending' or 'enrolled'.
|
||||
* API: Return promoted-student totals per base class for the selected year.
|
||||
*/
|
||||
public function promotionTotalsApi()
|
||||
{
|
||||
@@ -1111,22 +1344,24 @@ class StudentController extends BaseController
|
||||
$out = [];
|
||||
foreach ($wanted as $r) {
|
||||
$classId = (int)$r['class_id'];
|
||||
// candidates from promotion_queue for this base class in the target year
|
||||
$cands = $this->db->table('promotion_queue pq')
|
||||
->select('pq.student_id')
|
||||
->join('enrollments e', 'e.student_id = pq.student_id AND e.school_year = ' . $this->db->escape($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'])
|
||||
->whereIn('pq.status', ['queued','assigned'])
|
||||
->groupBy('pq.student_id')
|
||||
->get()->getResultArray();
|
||||
$total = count($cands);
|
||||
if ($total === 0) {
|
||||
$total = count($this->decisionDistributionCandidates($classId, $year));
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'class_id' => $classId,
|
||||
'class_section_id' => (int)($r['class_section_id'] ?? 0),
|
||||
'class_section_name'=> (string)($r['class_section_name'] ?? ''),
|
||||
'total' => count($cands),
|
||||
'total' => $total,
|
||||
'sections' => $this->savedDistributionSections($classId, $year),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1136,6 +1371,51 @@ class StudentController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
private function savedDistributionSections(int $classId, string $year): array
|
||||
{
|
||||
if (! $this->db->tableExists('student_section_distribution_drafts')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->db->table('student_section_distribution_drafts d')
|
||||
->select('d.class_section_id, cs.class_section_name, students.firstname, students.lastname, d.student_id')
|
||||
->join('classSection cs', 'cs.class_section_id = d.class_section_id', 'left')
|
||||
->join('students', 'students.id = d.student_id', 'left')
|
||||
->where('d.class_id', $classId)
|
||||
->where('d.school_year', $year)
|
||||
->where('d.status', 'pending')
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderBy('students.lastname', 'ASC')
|
||||
->orderBy('students.firstname', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$sections = [];
|
||||
foreach ($rows as $row) {
|
||||
$sectionId = (int)($row['class_section_id'] ?? 0);
|
||||
if ($sectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($sections[$sectionId])) {
|
||||
$sections[$sectionId] = [
|
||||
'class_section_id' => $sectionId,
|
||||
'class_section_name' => (string)($row['class_section_name'] ?? $sectionId),
|
||||
'total' => 0,
|
||||
'student_names' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$name = trim(trim((string)($row['firstname'] ?? '')) . ' ' . trim((string)($row['lastname'] ?? '')));
|
||||
if ($name === '') {
|
||||
$name = 'Student #' . (int)($row['student_id'] ?? 0);
|
||||
}
|
||||
$sections[$sectionId]['student_names'][] = $name;
|
||||
$sections[$sectionId]['total']++;
|
||||
}
|
||||
|
||||
return array_values($sections);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /students/update/{id}
|
||||
*/
|
||||
|
||||
@@ -256,31 +256,14 @@ class TeacherController extends BaseController
|
||||
|
||||
public function teacherClassAssignment()
|
||||
{
|
||||
$selectedYear = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? ''));
|
||||
if ($selectedYear === '') {
|
||||
$selectedYear = (string)($this->schoolYear ?? 'Not Set');
|
||||
if ($redirect = $this->redirectWithoutLegacyTermFilters('administrator/teacher_class_assignment')) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
// Build distinct school years from classSection (fallback to configured year)
|
||||
try {
|
||||
$yearsRows = $this->db->table('classSection')
|
||||
->distinct()
|
||||
->select('school_year')
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()->getResultArray();
|
||||
$schoolYears = array_values(array_filter(array_map(static function ($r) {
|
||||
return isset($r['school_year']) ? (string)$r['school_year'] : null;
|
||||
}, $yearsRows)));
|
||||
if (empty($schoolYears) && !empty($this->schoolYear)) {
|
||||
$schoolYears = [(string)$this->schoolYear];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$schoolYears = !empty($this->schoolYear) ? [(string)$this->schoolYear] : [];
|
||||
}
|
||||
$selectedYear = (string)($this->schoolYear ?? 'Not Set');
|
||||
|
||||
return view('administrator/teacher_class_assignment', [
|
||||
'schoolYear' => (string)$selectedYear,
|
||||
'schoolYears' => $schoolYears,
|
||||
'currentYear' => (string)($this->schoolYear ?? ''),
|
||||
'isCurrentYear' => ((string)$selectedYear === (string)($this->schoolYear ?? '')),
|
||||
'missingYear' => empty($this->schoolYear),
|
||||
@@ -292,6 +275,27 @@ class TeacherController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
|
||||
{
|
||||
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
|
||||
$query = $this->request->getGet();
|
||||
$hasLegacyTermFilter = false;
|
||||
|
||||
foreach ($legacyTermKeys as $key) {
|
||||
if (array_key_exists($key, $query)) {
|
||||
unset($query[$key]);
|
||||
$hasLegacyTermFilter = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasLegacyTermFilter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
|
||||
return redirect()->to($target);
|
||||
}
|
||||
|
||||
private function buildTeacherClassAssignmentPayload(?string $forYear = null): array
|
||||
{
|
||||
$classSectionModel = new ClassSectionModel();
|
||||
|
||||
@@ -81,6 +81,27 @@ class UserController extends BaseController
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
|
||||
private function redirectWithoutLegacyTermFilters(string $route): ?\CodeIgniter\HTTP\RedirectResponse
|
||||
{
|
||||
$legacyTermKeys = ['school_year', 'schoolYear', 'year', 'semester'];
|
||||
$query = $this->request->getGet();
|
||||
$hasLegacyTermFilter = false;
|
||||
|
||||
foreach ($legacyTermKeys as $key) {
|
||||
if (array_key_exists($key, $query)) {
|
||||
unset($query[$key]);
|
||||
$hasLegacyTermFilter = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasLegacyTermFilter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = site_url($route) . (!empty($query) ? '?' . http_build_query($query) : '');
|
||||
return redirect()->to($target);
|
||||
}
|
||||
|
||||
// Method to show the home page
|
||||
public function home()
|
||||
{
|
||||
@@ -113,6 +134,10 @@ class UserController extends BaseController
|
||||
|
||||
helper('url');
|
||||
|
||||
if ($redirect = $this->redirectWithoutLegacyTermFilters('user/user_list')) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
return view('user/user_list', [
|
||||
'userListEndpoint' => site_url('api/users'),
|
||||
]);
|
||||
@@ -854,6 +879,10 @@ class UserController extends BaseController
|
||||
|
||||
helper('url');
|
||||
|
||||
if ($redirect = $this->redirectWithoutLegacyTermFilters('user/login_activity')) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$perPage = (int) ($this->request->getGet('per_page') ?? 25);
|
||||
|
||||
return view('user/login_activity', [
|
||||
|
||||
+2
-2
@@ -31,8 +31,8 @@ class CreateStudentSectionDistributionDrafts extends Migration
|
||||
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addUniqueKey(['student_id', 'school_year'], 'unique_distribution_draft_student_year');
|
||||
$this->forge->addKey(['class_id', 'school_year', 'status'], false, 'distribution_draft_class_year_status');
|
||||
$this->forge->addKey(['class_section_id', 'school_year'], false, 'distribution_draft_section_year');
|
||||
$this->forge->addKey(['class_id', 'school_year', 'status'], false, false, 'distribution_draft_class_year_status');
|
||||
$this->forge->addKey(['class_section_id', 'school_year'], false, false, 'distribution_draft_section_year');
|
||||
$this->forge->createTable('student_section_distribution_drafts');
|
||||
}
|
||||
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class FixStudentSectionDistributionDraftIndexes extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (! $this->db->tableExists('student_section_distribution_drafts')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dropIndexIfExists('student_section_distribution_drafts', 'class_id_school_year_status');
|
||||
$this->dropIndexIfExists('student_section_distribution_drafts', 'class_section_id_school_year');
|
||||
$this->dropIndexIfExists('student_section_distribution_drafts', 'distribution_draft_class_year_status');
|
||||
$this->dropIndexIfExists('student_section_distribution_drafts', 'distribution_draft_section_year');
|
||||
|
||||
$this->db->query(
|
||||
'CREATE INDEX distribution_draft_class_year_status ' .
|
||||
'ON student_section_distribution_drafts (class_id, school_year, status)'
|
||||
);
|
||||
$this->db->query(
|
||||
'CREATE INDEX distribution_draft_section_year ' .
|
||||
'ON student_section_distribution_drafts (class_section_id, school_year)'
|
||||
);
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if (! $this->db->tableExists('student_section_distribution_drafts')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dropIndexIfExists('student_section_distribution_drafts', 'distribution_draft_class_year_status');
|
||||
$this->dropIndexIfExists('student_section_distribution_drafts', 'distribution_draft_section_year');
|
||||
}
|
||||
|
||||
private function dropIndexIfExists(string $table, string $indexName): void
|
||||
{
|
||||
$row = $this->db->query(
|
||||
'SHOW INDEX FROM ' . $this->db->protectIdentifiers($table, true) .
|
||||
' WHERE Key_name = ' . $this->db->escape($indexName)
|
||||
)->getRowArray();
|
||||
|
||||
if ($row) {
|
||||
$this->db->query(
|
||||
'ALTER TABLE ' . $this->db->protectIdentifiers($table, true) .
|
||||
' DROP INDEX ' . $this->db->protectIdentifiers($indexName)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -365,13 +365,33 @@ class StudentModel extends Model
|
||||
$schoolYear = trim((string) $schoolYear);
|
||||
$studentClassJoin = 'student_class.student_id = students.id';
|
||||
$enrollmentJoin = 'enrollments.student_id = students.id';
|
||||
$selectedYearFilter = '';
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$studentClassJoin .= ' AND student_class.school_year = ' . $this->db->escape($schoolYear);
|
||||
$enrollmentJoin .= ' AND enrollments.school_year = ' . $this->db->escape($schoolYear);
|
||||
$escapedSchoolYear = $this->db->escape($schoolYear);
|
||||
$studentClassJoin .= ' AND student_class.school_year = ' . $escapedSchoolYear;
|
||||
$enrollmentJoin .= ' AND enrollments.school_year = ' . $escapedSchoolYear;
|
||||
$selectedYearFilter = "
|
||||
(
|
||||
student_class.student_id IS NOT NULL
|
||||
OR enrollments.student_id IS NOT NULL
|
||||
OR (
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM student_class sc_history
|
||||
WHERE sc_history.student_id = students.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM enrollments e_history
|
||||
WHERE e_history.student_id = students.id
|
||||
)
|
||||
)
|
||||
)
|
||||
";
|
||||
}
|
||||
|
||||
return $this->select('
|
||||
$builder = $this->select('
|
||||
students.*,
|
||||
student_class.class_section_id,
|
||||
enrollments.enrollment_status,
|
||||
@@ -381,7 +401,13 @@ class StudentModel extends Model
|
||||
')
|
||||
->join('student_class', $studentClassJoin, 'left')
|
||||
->join('enrollments', $enrollmentJoin, 'left')
|
||||
->join('users u', 'u.id = students.parent_id', 'left')
|
||||
->join('users u', 'u.id = students.parent_id', 'left');
|
||||
|
||||
if ($selectedYearFilter !== '') {
|
||||
$builder->where($selectedYearFilter, null, false);
|
||||
}
|
||||
|
||||
return $builder
|
||||
// keep your original grouping as-is so behavior doesn't change
|
||||
->groupBy('students.id, student_class.class_section_id, enrollments.enrollment_status, enrollments.admission_status')
|
||||
->findAll();
|
||||
|
||||
@@ -17,34 +17,6 @@
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<!-- School Year / Semester Filter Form -->
|
||||
<form method="get" action="<?= base_url('administrator/calendar_view') ?>" class="mb-3 text-center d-flex gap-2 justify-content-center align-items-end flex-wrap">
|
||||
<div>
|
||||
<label for="school_year" class="form-label">School Year:</label>
|
||||
<select name="school_year" id="school_year" class="form-select w-auto d-inline-block ms-2">
|
||||
<?php foreach (range(date('Y') + 1, 2023) as $y):
|
||||
$sy = ($y - 1) . '-' . $y; ?>
|
||||
<option value="<?= $sy ?>" <?= $sy === ($schoolYear ?? '') ? 'selected' : '' ?>>
|
||||
<?= $sy ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="semester" class="form-label">Semester:</label>
|
||||
<?php $semVal = (string)($semester ?? ($_GET['semester'] ?? '')); ?>
|
||||
<select name="semester" id="semester" class="form-select w-auto d-inline-block ms-2">
|
||||
<option value="">—</option>
|
||||
<option value="Fall" <?= (strcasecmp($semVal,'Fall')===0?'selected':'') ?>>Fall</option>
|
||||
<option value="Spring" <?= (strcasecmp($semVal,'Spring')===0?'selected':'') ?>>Spring</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn btn-secondary">Apply</button>
|
||||
<a href="<?= base_url('administrator/calendar_view') ?>" class="btn btn-outline-secondary">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
<div class="alert alert-success">
|
||||
<?= session()->getFlashdata('success') ?>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<div class="wrapper">
|
||||
<div class="content">
|
||||
<h2 class="text-center mt-4 mb-3">Emergency Contact Information</h2>
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
<div class="table-responsive">
|
||||
<table id="emergencyTable" class="display table table-bordered table-striped align-middle">
|
||||
<thead class="table-dark">
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<div class="wrapper">
|
||||
<div class="content">
|
||||
<h2 class="text-center mt-4 mb-3">Parent Profiles</h2>
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
<table id="myTable" class="display table table-striped table-bordered align-middle" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -5,37 +5,26 @@
|
||||
<div class="content">
|
||||
<h2 class="text-center mt-4 mb-3">Auto-Distribute Students into Sections</h2>
|
||||
|
||||
<div class="row g-2 align-items-center justify-content-center mb-3">
|
||||
<div class="col-auto"><label for="adYearSelect" class="col-form-label">School year</label></div>
|
||||
<div class="col-auto">
|
||||
<form method="get" action="<?= site_url('administrator/sections/auto-distribute') ?>" class="d-flex align-items-center gap-2">
|
||||
<select id="adYearSelect" name="schoolYear" class="form-select form-select-sm" style="min-width: 180px;">
|
||||
<?php
|
||||
$years = isset($schoolYears) && is_array($schoolYears) ? $schoolYears : [];
|
||||
if (empty($years) && !empty($selectedYear)) $years = [$selectedYear];
|
||||
foreach ($years as $y): $val = is_array($y) && isset($y['school_year']) ? $y['school_year'] : (string)$y; ?>
|
||||
<option value="<?= esc($val) ?>" <?= ((string)($selectedYear ?? '') === (string)$val) ? 'selected' : '' ?>>
|
||||
<?= esc($val) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Apply</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end mb-2">
|
||||
<div class="col-md-3">
|
||||
<label for="globalPer" class="form-label">Students per section</label>
|
||||
<input type="number" min="1" id="globalPer" class="form-control" placeholder="e.g. 20" />
|
||||
<div class="col-md-2">
|
||||
<label for="sectionCount" class="form-label">Number of Sections</label>
|
||||
<input type="number" min="1" id="sectionCount" class="form-control" placeholder="e.g. 2" />
|
||||
</div>
|
||||
<div class="col-md-4 d-flex gap-2">
|
||||
<div class="col-md-2">
|
||||
<label for="minStudents" class="form-label">Minimum Students</label>
|
||||
<input type="number" min="1" id="minStudents" class="form-control" placeholder="e.g. 20" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="maxStudents" class="form-label">Maximum Students</label>
|
||||
<input type="number" min="1" id="maxStudents" class="form-control" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="col-md-3 d-flex gap-2">
|
||||
<button type="button" id="refreshTotalsBtn" class="btn btn-outline-secondary">Refresh Totals</button>
|
||||
<button type="button" id="generateAllBtn" class="btn btn-primary">Generate All</button>
|
||||
</div>
|
||||
<div class="col-md-7 text-end">
|
||||
<div class="col-md-3 text-end">
|
||||
<span id="pageMsg" class="small text-muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,7 +35,7 @@
|
||||
<tr id="tblHeader">
|
||||
<th>Class</th>
|
||||
<th>Total</th>
|
||||
<th>Sections Needed</th>
|
||||
<th>Sections</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -73,17 +62,18 @@
|
||||
|
||||
const tblBody = document.getElementById('tblBody');
|
||||
const tblHeader = document.getElementById('tblHeader');
|
||||
const perInput = document.getElementById('globalPer');
|
||||
const sectionCountInput = document.getElementById('sectionCount');
|
||||
const minInput = document.getElementById('minStudents');
|
||||
const maxInput = document.getElementById('maxStudents');
|
||||
const msgEl = document.getElementById('pageMsg');
|
||||
const refreshBtn= document.getElementById('refreshTotalsBtn');
|
||||
|
||||
let headerSections = []; // list of generated section names as columns
|
||||
let rowIndexByClassId = {}; // mapping to locate rows
|
||||
|
||||
function calcNeeded(total) {
|
||||
const per = parseInt(perInput.value || '0', 10);
|
||||
if (!per || per <= 0) return '';
|
||||
return Math.ceil(total / per);
|
||||
function selectedSectionCount() {
|
||||
const count = parseInt(sectionCountInput.value || '0', 10);
|
||||
return count > 0 ? count : '';
|
||||
}
|
||||
|
||||
function ensureSectionColumns(sectionNames) {
|
||||
@@ -129,7 +119,7 @@
|
||||
|
||||
const tdNeed = document.createElement('td');
|
||||
tdNeed.className = 'text-end need-cell';
|
||||
tdNeed.textContent = calcNeeded(r.total);
|
||||
tdNeed.textContent = selectedSectionCount();
|
||||
tr.appendChild(tdNeed);
|
||||
|
||||
const tdAct = document.createElement('td');
|
||||
@@ -143,24 +133,36 @@
|
||||
tblBody.appendChild(tr);
|
||||
rowIndexByClassId[r.class_id] = tr;
|
||||
});
|
||||
|
||||
rows.forEach(function(r){
|
||||
if (Array.isArray(r.sections) && r.sections.length) {
|
||||
renderSectionsForRow(r.class_section_name || '', r.sections);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateNeeds() {
|
||||
document.querySelectorAll('#tblBody tr').forEach(function(tr){
|
||||
const total = parseInt(tr.children[1].textContent || '0', 10);
|
||||
const needCell = tr.querySelector('.need-cell');
|
||||
if (needCell) needCell.textContent = calcNeeded(total);
|
||||
if (needCell) needCell.textContent = selectedSectionCount();
|
||||
});
|
||||
}
|
||||
|
||||
function runDistribution(baseSectionId, baseName) {
|
||||
const per = parseInt(perInput.value || '0', 10);
|
||||
if (!per || per <= 0) { msgEl.textContent = 'Enter students per section first.'; return; }
|
||||
const sectionCount = parseInt(sectionCountInput.value || '0', 10);
|
||||
const minStudents = parseInt(minInput.value || '0', 10);
|
||||
const maxStudents = parseInt(maxInput.value || '0', 10);
|
||||
if (!sectionCount || sectionCount <= 0 || !minStudents || minStudents <= 0) {
|
||||
msgEl.textContent = 'Enter number of sections and minimum students first.';
|
||||
return;
|
||||
}
|
||||
|
||||
msgEl.textContent = 'Distributing ' + (baseName || '') + '...';
|
||||
const fd = new FormData();
|
||||
fd.append('class_section_id', String(baseSectionId));
|
||||
fd.append('students_per_section', String(per));
|
||||
fd.append('section_count', String(sectionCount));
|
||||
fd.append('min_students_per_section', String(minStudents));
|
||||
if (maxStudents > 0) fd.append('max_students_per_section', String(maxStudents));
|
||||
fd.append('school_year', selectedYear);
|
||||
// CSRF
|
||||
const csrfNameEl = document.getElementById('csrfName');
|
||||
@@ -184,24 +186,39 @@
|
||||
|
||||
msgEl.textContent = res && res.message ? res.message : 'Completed.';
|
||||
|
||||
// Collect section names, then ensure header columns
|
||||
const names = res.sections.map(s => s.class_section_name).filter(Boolean);
|
||||
ensureSectionColumns(names);
|
||||
|
||||
// Fill row cells for this class
|
||||
const tr = Array.from(document.querySelectorAll('#tblBody tr')).find(function(_tr){
|
||||
return (_tr.children[0].textContent || '') === (baseName || '');
|
||||
});
|
||||
if (!tr) return;
|
||||
|
||||
res.sections.forEach(function(s){
|
||||
const td = tr.querySelector('td[data-section="'+ s.class_section_name +'"]');
|
||||
if (td) td.textContent = (s.total ?? 0) + ' (M'+ (s.male ?? 0) + '/F'+ (s.female ?? 0) +')';
|
||||
});
|
||||
renderSectionsForRow(baseName || '', res.sections);
|
||||
})
|
||||
.catch(() => { msgEl.textContent = 'Failed to distribute. Please try again.'; });
|
||||
}
|
||||
|
||||
function renderSectionsForRow(baseName, sections) {
|
||||
const names = sections.map(s => s.class_section_name).filter(Boolean);
|
||||
ensureSectionColumns(names);
|
||||
|
||||
const tr = Array.from(document.querySelectorAll('#tblBody tr')).find(function(_tr){
|
||||
return (_tr.children[0].textContent || '') === (baseName || '');
|
||||
});
|
||||
if (!tr) return;
|
||||
|
||||
sections.forEach(function(s){
|
||||
const td = Array.from(tr.querySelectorAll('td[data-section]')).find(cell => cell.dataset.section === s.class_section_name);
|
||||
if (!td) return;
|
||||
|
||||
const studentNames = Array.isArray(s.student_names) ? s.student_names : [];
|
||||
td.innerHTML = '';
|
||||
const count = document.createElement('div');
|
||||
count.className = 'fw-semibold small mb-1';
|
||||
count.textContent = (s.total ?? studentNames.length) + ' students';
|
||||
td.appendChild(count);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'small';
|
||||
list.style.whiteSpace = 'normal';
|
||||
list.textContent = studentNames.length ? studentNames.join(', ') : 'No students assigned';
|
||||
td.appendChild(list);
|
||||
});
|
||||
}
|
||||
|
||||
function loadTotals() {
|
||||
msgEl.textContent = 'Loading totals...';
|
||||
fetch(totalsUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' }})
|
||||
@@ -215,10 +232,14 @@
|
||||
.catch(() => { msgEl.textContent = 'Failed to load totals.'; });
|
||||
}
|
||||
|
||||
refreshBtn.addEventListener('click', function(){ updateNeeds(); });
|
||||
refreshBtn.addEventListener('click', function(){ loadTotals(); });
|
||||
document.getElementById('generateAllBtn').addEventListener('click', async function(){
|
||||
const per = parseInt(perInput.value || '0', 10);
|
||||
if (!per || per <= 0) { msgEl.textContent = 'Enter students per section first.'; return; }
|
||||
const sectionCount = parseInt(sectionCountInput.value || '0', 10);
|
||||
const minStudents = parseInt(minInput.value || '0', 10);
|
||||
if (!sectionCount || sectionCount <= 0 || !minStudents || minStudents <= 0) {
|
||||
msgEl.textContent = 'Enter number of sections and minimum students first.';
|
||||
return;
|
||||
}
|
||||
// Collect base sections from rows
|
||||
const rows = Array.from(document.querySelectorAll('#tblBody tr'))
|
||||
.map(tr => ({ id: tr.dataset.classSectionId, name: tr.dataset.className }))
|
||||
@@ -231,7 +252,7 @@
|
||||
}
|
||||
msgEl.textContent = 'All distributions completed.';
|
||||
});
|
||||
perInput.addEventListener('input', function(){ updateNeeds(); });
|
||||
sectionCountInput.addEventListener('input', function(){ updateNeeds(); });
|
||||
|
||||
loadTotals();
|
||||
})();
|
||||
|
||||
@@ -5,29 +5,6 @@
|
||||
<div class="content">
|
||||
<h2 class="text-center mt-4 mb-3">Student Class Assignment</h2>
|
||||
<div class="row g-2 align-items-center justify-content-center mb-3">
|
||||
<div class="col-auto"><label for="scaYearSelect" class="col-form-label">School year</label></div>
|
||||
<div class="col-auto">
|
||||
<form method="get" action="<?= site_url('administrator/student_class_assignment') ?>" class="d-flex align-items-center gap-2">
|
||||
<select id="scaYearSelect" name="schoolYear" class="form-select form-select-sm" style="min-width: 180px;">
|
||||
<?php
|
||||
$years = isset($schoolYears) && is_array($schoolYears) ? $schoolYears : [];
|
||||
if (empty($years) && !empty($selectedYear)) $years = [$selectedYear];
|
||||
foreach ($years as $y): $val = is_array($y) && isset($y['school_year']) ? $y['school_year'] : (string)$y; ?>
|
||||
<option value="<?= esc($val) ?>" <?= ((string)($selectedYear ?? '') === (string)$val) ? 'selected' : '' ?>>
|
||||
<?= esc($val) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php $semVal = (string)($semester ?? ($_GET['semester'] ?? '')); ?>
|
||||
<label for="scaSemesterSelect" class="col-form-label">Semester</label>
|
||||
<select id="scaSemesterSelect" name="semester" class="form-select form-select-sm" style="min-width: 140px;">
|
||||
<option value="">—</option>
|
||||
<option value="Fall" <?= (strcasecmp($semVal,'Fall')===0?'selected':'') ?>>Fall</option>
|
||||
<option value="Spring" <?= (strcasecmp($semVal,'Spring')===0?'selected':'') ?>>Spring</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Apply</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php if (isset($isCurrentYear) && !$isCurrentYear): ?>
|
||||
<div class="col-auto"><span class="badge bg-secondary">Read-only (Past Year)</span></div>
|
||||
<?php endif; ?>
|
||||
@@ -59,10 +36,7 @@
|
||||
<label for="autoDistPer" class="form-label">Students per section</label>
|
||||
<input type="number" min="1" id="autoDistPer" name="students_per_section" class="form-control" required />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="autoDistYear" class="form-label">School year</label>
|
||||
<input type="text" id="autoDistYear" name="school_year" value="<?= esc($selectedYear ?? '') ?>" class="form-control" />
|
||||
</div>
|
||||
<input type="hidden" id="autoDistYear" name="school_year" value="<?= esc($selectedYear ?? '') ?>" />
|
||||
<div class="col-md-2 d-grid">
|
||||
<button type="submit" class="btn btn-outline-primary">Auto-Distribute</button>
|
||||
</div>
|
||||
|
||||
@@ -17,25 +17,6 @@
|
||||
<a href="<?= site_url('configuration/configuration_view') ?>" class="alert-link">Add/Edit Configuration</a> (key: <code>school_year</code>).
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="d-flex justify-content-center align-items-center gap-2 mb-3">
|
||||
<label for="tcaYearSelect" class="col-form-label">School year</label>
|
||||
<form method="get" action="<?= site_url('administrator/teacher_class_assignment') ?>" class="d-flex align-items-center gap-2">
|
||||
<select id="tcaYearSelect" name="schoolYear" class="form-select form-select-sm" style="min-width: 180px;">
|
||||
<?php
|
||||
$years = isset($schoolYears) && is_array($schoolYears) ? $schoolYears : [];
|
||||
if (empty($years) && !empty($schoolYear)) $years = [$schoolYear];
|
||||
foreach ($years as $y): $val = is_array($y) && isset($y['school_year']) ? $y['school_year'] : (string)$y; ?>
|
||||
<option value="<?= esc($val) ?>" <?= ((string)($schoolYear ?? '') === (string)$val) ? 'selected' : '' ?>>
|
||||
<?= esc($val) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Apply</button>
|
||||
</form>
|
||||
<?php if (isset($isCurrentYear) && !$isCurrentYear): ?>
|
||||
<span class="badge bg-secondary">Read-only (Past Year)</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?= view('partials/flash_messages') ?>
|
||||
<div id="assignmentMessages"></div>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
<h2 class="text-center mt-4 mb-3">Discounts</h2>
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
<div class="d-flex gap-2 mb-3 justify-content-end">
|
||||
<div>
|
||||
<a href="/discount/create" class="btn btn-primary">Create New Voucher</a>
|
||||
|
||||
@@ -12,34 +12,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="row g-2 align-items-center justify-content-center mb-2">
|
||||
<div class="col-auto"><label for="schoolYearSelect" class="col-form-label">School year</label></div>
|
||||
<div class="col-auto">
|
||||
<form method="get" action="<?= site_url('enroll_withdraw/enrollment_withdrawal') ?>" class="d-flex align-items-center gap-2">
|
||||
<select id="schoolYearSelect" name="schoolYear" class="form-select form-select-sm" style="min-width:180px;">
|
||||
<?php if (!empty($schoolYears) && is_array($schoolYears)): ?>
|
||||
<?php foreach ($schoolYears as $y): ?>
|
||||
<?php $yval = is_array($y) && isset($y['school_year']) ? (string)$y['school_year'] : (string)$y; ?>
|
||||
<option value="<?= esc($yval) ?>" <?= (isset($selectedYear) && (string)$selectedYear === $yval) ? 'selected' : '' ?>><?= esc($yval) ?></option>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<option value="<?= esc($selectedYear ?? '') ?>" selected><?= esc($selectedYear ?? '') ?></option>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
<?php $semVal = (string)($semester ?? ($_GET['semester'] ?? '')); ?>
|
||||
<label for="ewSemester" class="col-form-label">Semester</label>
|
||||
<select id="ewSemester" name="semester" class="form-select form-select-sm" style="min-width: 140px;">
|
||||
<option value="">—</option>
|
||||
<option value="Fall" <?= (strcasecmp($semVal,'Fall')===0?'selected':'') ?>>Fall</option>
|
||||
<option value="Spring" <?= (strcasecmp($semVal,'Spring')===0?'selected':'') ?>>Spring</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Apply</button>
|
||||
</form>
|
||||
<?php if (empty($isCurrentYear)): ?>
|
||||
<div class="text-center mb-2">
|
||||
<span class="badge bg-secondary">Read-only (Past Year)</span>
|
||||
</div>
|
||||
<?php if (isset($selectedYear, $currentYear) && (string)$selectedYear !== (string)$currentYear): ?>
|
||||
<div class="col-auto"><span class="badge bg-secondary">Read-only (Past Year)</span></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<!-- Display success and error messages -->
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
<div class="alert alert-success">
|
||||
@@ -161,7 +138,7 @@
|
||||
|
||||
<!-- Update Enrollment -->
|
||||
<td>
|
||||
<select <?= (isset($selectedYear, $currentYear) && (string)$selectedYear !== (string)$currentYear) ? 'disabled' : '' ?>
|
||||
<select <?= empty($isCurrentYear) ? 'disabled' : '' ?>
|
||||
name="enrollment_status[<?= $sid ?>]"
|
||||
class="form-control enrollment-status"
|
||||
data-student-id="<?= $sid ?>"
|
||||
@@ -182,7 +159,7 @@
|
||||
<td>
|
||||
<?php $isUnderReview = (strtolower($student['enrollment_status'] ?? '') === 'admission under review'); ?>
|
||||
<?php if ($isUnderReview): ?>
|
||||
<select class="form-select form-select-sm assign-class-select" <?= (isset($selectedYear, $currentYear) && (string)$selectedYear !== (string)$currentYear) ? 'disabled' : '' ?>
|
||||
<select class="form-select form-select-sm assign-class-select" <?= empty($isCurrentYear) ? 'disabled' : '' ?>
|
||||
data-student-id="<?= $sid ?>"
|
||||
data-parent-id="<?= $pid ?>">
|
||||
<option value="">— Select class section —</option>
|
||||
@@ -212,7 +189,7 @@
|
||||
</div>
|
||||
<!-- Centered action buttons under the table -->
|
||||
<div class="d-flex justify-content-center gap-2 my-3">
|
||||
<button type="button" id="bulkSaveGenerateButton" class="btn btn-success btn-lg" <?= (isset($selectedYear, $currentYear) && (string)$selectedYear !== (string)$currentYear) ? 'disabled title="Editing disabled for non-current year"' : '' ?> >
|
||||
<button type="button" id="bulkSaveGenerateButton" class="btn btn-success btn-lg" <?= empty($isCurrentYear) ? 'disabled title="Editing disabled for non-current year"' : '' ?> >
|
||||
Save & Generate Invoice
|
||||
</button>
|
||||
<span id="bulkProgress" class="small text-muted d-none">Processing…</span>
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
<span class="badge bg-secondary ms-2"><?= (int)$total_new ?></span>
|
||||
<?php endif; ?>
|
||||
</h2>
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
|
||||
<!-- Flash messages -->
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
<h2 class="text-center mt-4 mb-3">Expense / Purchase</h2>
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
<div class="d-flex gap-2 mb-3 justify-content-end">
|
||||
<a href="<?= base_url('expenses/create') ?>" class="btn btn-success mb-3">Add New</a>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<?= $this->section('content') ?>
|
||||
<div class="container-fluid">
|
||||
<h2 class="text-center mt-4 mb-3">Incidents Management</h2>
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
<!-- Flash Success Message -->
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
<div class="alert alert-success">
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
data-notifications-endpoint="<?= esc($endpoint) ?>"
|
||||
data-default-target-group="<?= esc($defaultTarget) ?>">
|
||||
<h2 class="text-center mt-4 mb-3">Active Notifications</h2>
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
|
||||
@@ -61,53 +60,6 @@
|
||||
<?= $this->section('scripts') ?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- FixedHeader helpers / assets ---
|
||||
function getFixedHeaderOffset() {
|
||||
let total = 0;
|
||||
const stack = [];
|
||||
const header = document.querySelector('header.navbar.sticky-top, header.navbar.fixed-top');
|
||||
if (header) stack.push(header);
|
||||
const mgmt = document.getElementById('navbarManagement');
|
||||
if (mgmt && (mgmt.classList.contains('sticky-top') || mgmt.classList.contains('fixed-top'))) stack.push(mgmt);
|
||||
document.querySelectorAll('.navbar.sticky-top, .navbar.fixed-top').forEach(el => { if (!stack.includes(el)) stack.push(el); });
|
||||
stack.forEach(el => { const h = el.offsetHeight || el.getBoundingClientRect().height || 0; total += Math.max(0, Math.round(h)); });
|
||||
return total;
|
||||
}
|
||||
|
||||
function loadScript(src, id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (id && document.getElementById(id)) return resolve();
|
||||
const s = document.createElement('script');
|
||||
if (id) s.id = id;
|
||||
s.src = src;
|
||||
s.onload = resolve;
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
function loadCss(href, id) {
|
||||
return new Promise((resolve) => {
|
||||
if (id && document.getElementById(id)) return resolve();
|
||||
const l = document.createElement('link');
|
||||
if (id) l.id = id;
|
||||
l.rel = 'stylesheet';
|
||||
l.href = href;
|
||||
l.onload = resolve;
|
||||
document.head.appendChild(l);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureFixedHeaderAssets() {
|
||||
const hasFH = !!(window.jQuery && window.jQuery.fn && window.jQuery.fn.dataTable && window.jQuery.fn.dataTable.FixedHeader);
|
||||
if (hasFH) return Promise.resolve();
|
||||
return Promise.all([
|
||||
loadScript('https://cdn.jsdelivr.net/npm/datatables.net-fixedheader@3.4.0/js/dataTables.fixedHeader.min.js', 'dt-fixedheader'),
|
||||
loadCss('https://cdn.jsdelivr.net/npm/datatables.net-fixedheader-bs5@3.4.0/css/fixedHeader.bootstrap5.min.css', 'dt-fixedheader-css')
|
||||
]).catch(() => {});
|
||||
}
|
||||
|
||||
// Start fetching FixedHeader in parallel
|
||||
ensureFixedHeaderAssets();
|
||||
const container = document.querySelector('[data-notifications-endpoint]');
|
||||
if (!container) {
|
||||
return;
|
||||
@@ -123,6 +75,7 @@
|
||||
const resetBtn = document.getElementById('resetFilterBtn');
|
||||
|
||||
let lastPayload = [];
|
||||
let dataTable = null;
|
||||
|
||||
const showError = (message) => {
|
||||
if (!alertBox) {
|
||||
@@ -140,23 +93,16 @@
|
||||
alertBox.textContent = '';
|
||||
};
|
||||
|
||||
const destroyDataTable = () => {
|
||||
if (!window.jQuery || !window.jQuery.fn || !window.jQuery.fn.DataTable) {
|
||||
return;
|
||||
}
|
||||
if (window.jQuery.fn.DataTable.isDataTable(tableSelector)) {
|
||||
window.jQuery(tableSelector).DataTable().clear().destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const initDataTable = () => {
|
||||
if (!window.jQuery || !window.jQuery.fn || !window.jQuery.fn.DataTable) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const opts = {
|
||||
if (dataTable) {
|
||||
return dataTable;
|
||||
}
|
||||
|
||||
dataTable = window.jQuery(tableSelector).DataTable({
|
||||
pageLength: 25,
|
||||
responsive: true,
|
||||
// Sort by Scheduled At desc, then Created At desc by default
|
||||
order: [[5, 'desc'], [7, 'desc']],
|
||||
columnDefs: [
|
||||
{
|
||||
@@ -164,22 +110,9 @@
|
||||
orderable: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
if (window.jQuery.fn.dataTable && window.jQuery.fn.dataTable.FixedHeader) {
|
||||
opts.fixedHeader = { header: true, headerOffset: getFixedHeaderOffset() };
|
||||
}
|
||||
|
||||
const dt = window.jQuery(tableSelector).DataTable(opts);
|
||||
|
||||
// Attach FixedHeader if it loads slightly later
|
||||
if (!opts.fixedHeader) {
|
||||
ensureFixedHeaderAssets().then(() => {
|
||||
if (window.jQuery.fn.dataTable && window.jQuery.fn.dataTable.FixedHeader) {
|
||||
try { new window.jQuery.fn.dataTable.FixedHeader(dt, { header: true, headerOffset: getFixedHeaderOffset() }); } catch (_) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
return dataTable;
|
||||
};
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
@@ -208,21 +141,32 @@
|
||||
};
|
||||
|
||||
const renderTable = () => {
|
||||
destroyDataTable();
|
||||
tableBody.innerHTML = '';
|
||||
if (!dataTable && window.jQuery && window.jQuery.fn && window.jQuery.fn.DataTable) {
|
||||
tableBody.innerHTML = '';
|
||||
}
|
||||
const dt = initDataTable();
|
||||
if (dt) {
|
||||
dt.clear();
|
||||
} else {
|
||||
tableBody.innerHTML = '';
|
||||
}
|
||||
|
||||
if (!Array.isArray(lastPayload) || lastPayload.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
const cell = document.createElement('td');
|
||||
cell.colSpan = 8;
|
||||
cell.classList.add('text-center', 'text-muted');
|
||||
cell.textContent = 'No active notifications found.';
|
||||
row.appendChild(cell);
|
||||
tableBody.appendChild(row);
|
||||
initDataTable();
|
||||
if (dt) {
|
||||
dt.draw();
|
||||
} else {
|
||||
const row = document.createElement('tr');
|
||||
const cell = document.createElement('td');
|
||||
cell.colSpan = 8;
|
||||
cell.classList.add('text-center', 'text-muted');
|
||||
cell.textContent = 'No active notifications found.';
|
||||
row.appendChild(cell);
|
||||
tableBody.appendChild(row);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
lastPayload.forEach((entry, index) => {
|
||||
const row = document.createElement('tr');
|
||||
if (entry.isExpired) {
|
||||
@@ -265,10 +209,15 @@
|
||||
row.appendChild(cell);
|
||||
});
|
||||
|
||||
tableBody.appendChild(row);
|
||||
if (!dt) {
|
||||
tableBody.appendChild(row);
|
||||
}
|
||||
rows.push(row);
|
||||
});
|
||||
|
||||
initDataTable();
|
||||
if (dt) {
|
||||
dt.rows.add(rows).draw();
|
||||
}
|
||||
};
|
||||
|
||||
const buildUrl = (target) => {
|
||||
@@ -290,11 +239,15 @@
|
||||
clearError();
|
||||
const url = buildUrl(targetGroup);
|
||||
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted">Loading notifications…</td>
|
||||
</tr>
|
||||
`;
|
||||
if (dataTable) {
|
||||
dataTable.clear().draw();
|
||||
} else {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted">Loading notifications…</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
@@ -314,12 +267,16 @@
|
||||
})
|
||||
.catch((error) => {
|
||||
showError(error.message || 'Unable to load notifications.');
|
||||
destroyDataTable();
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-danger">Failed to load notifications.</td>
|
||||
</tr>
|
||||
`;
|
||||
const dt = initDataTable();
|
||||
if (dt) {
|
||||
dt.clear().draw();
|
||||
} else {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-danger">Failed to load notifications.</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
data-csrf-name="<?= esc($csrfTokenName) ?>"
|
||||
data-csrf-value="<?= esc($csrfTokenValue) ?>">
|
||||
<h2 class="text-center mt-4 mb-3">Deleted Notifications</h2>
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
<?php if (!empty($schoolYearSelectorEnabled) && isset($schoolYearContext, $schoolYearOptions)): ?>
|
||||
<?php
|
||||
$currentPath = trim(service('request')->getUri()->getPath(), '/');
|
||||
$hiddenOnPaths = [
|
||||
'administrator/emergency_contact',
|
||||
'flags/flags_management',
|
||||
'administrator/calendar_view',
|
||||
'administrator/student_class_assignment',
|
||||
'administrator/sections/auto-distribute',
|
||||
'admin/enrollment/new-students',
|
||||
'payment/unpaid-parents',
|
||||
'discounts/list',
|
||||
'expenses/index',
|
||||
'reimbursements/index',
|
||||
];
|
||||
?>
|
||||
<?php if (!in_array($currentPath, $hiddenOnPaths, true) && !empty($schoolYearSelectorEnabled) && isset($schoolYearContext, $schoolYearOptions)): ?>
|
||||
<?php
|
||||
$query = service('request')->getUri()->getQuery();
|
||||
$returnTo = current_url() . ($query !== '' ? '?' . $query : '');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- app/Views/payment/extra_charges.php -->
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
<?php $isSchoolYearReadonly = (bool)($isSchoolYearReadonly ?? false); ?>
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
|
||||
@@ -10,46 +11,23 @@
|
||||
<?php if (session()->get('error')): ?>
|
||||
<div class="alert alert-danger"><?= session()->get('error') ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($isSchoolYearReadonly): ?>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
This school year is read-only. Charges cannot be changed.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Centered School Year filter (match compact design) -->
|
||||
<div class="d-flex justify-content-center mb-3">
|
||||
<form id="chargesYearFilter" class="d-flex align-items-center gap-3" method="get" action="<?= site_url('admin/charges') ?>">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 gap-3 flex-wrap">
|
||||
<h3 class="mb-0">
|
||||
Add & Deduct Charges
|
||||
<span class="text-muted ms-2">(<?= esc($schoolYear) ?> - <?= esc($semester) ?>)</span>
|
||||
</h3>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 gap-3 flex-wrap">
|
||||
<h3 class="mb-0">
|
||||
Add & Deduct Charges
|
||||
<span class="text-muted ms-2">(<?= esc($schoolYear) ?> - <?= esc($semester) ?>)</span>
|
||||
</h3>
|
||||
|
||||
<div class="d-flex align-items-center gap-3 flex-wrap">
|
||||
<label for="schoolYear" class="form-label mb-0">School year</label>
|
||||
<select id="schoolYear"
|
||||
name="school_year"
|
||||
class="form-select form-select-sm rounded-pill px-3"
|
||||
style="min-width: 180px; width: auto;">
|
||||
<?php foreach (($schoolYears ?? []) as $sy): ?>
|
||||
<option value="<?= esc($sy) ?>" <?= (string)$schoolYear === (string)$sy ? 'selected' : '' ?>>
|
||||
<?= esc($sy) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mb-3 justify-content-end"></div>
|
||||
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#chargeModal">
|
||||
<i class="bi bi-plus-circle"></i> Add Charge
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#chargeModal" <?= $isSchoolYearReadonly ? 'disabled' : '' ?>>
|
||||
<i class="bi bi-plus-circle"></i> Add Charge
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<?php if (!empty($status)): ?>
|
||||
<input type="hidden" name="status" value="<?= esc($status) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($parentId)): ?>
|
||||
<input type="hidden" name="parent_id" value="<?= (int)$parentId ?>">
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body table-responsive">
|
||||
<?php $pageTotal = 0.0; if (!empty($rows)) { foreach ($rows as $__r) { $pageTotal += (float)($__r['amount'] ?? 0); } } ?>
|
||||
@@ -192,11 +170,6 @@
|
||||
<?= $this->section('scripts') ?>
|
||||
<script>
|
||||
$(function() {
|
||||
// Auto-apply School Year when selection changes (no Filter button)
|
||||
$('#chargesYearFilter select[name="school_year"]').on('change', function() {
|
||||
this.form.submit();
|
||||
});
|
||||
|
||||
const $parent = $('#parentSelect');
|
||||
const $invId = $('#invoiceIdHidden');
|
||||
const $invNum = $('#invoiceNumberDisplay');
|
||||
@@ -221,8 +194,7 @@
|
||||
if (!pid) return;
|
||||
|
||||
$.getJSON('<?= site_url('admin/charges/invoices') ?>', {
|
||||
parent_id: pid,
|
||||
school_year: '<?= esc($schoolYear) ?>'
|
||||
parent_id: pid
|
||||
}).done(function(data) {
|
||||
const items = (data && Array.isArray(data.results)) ? data.results : [];
|
||||
const chosen = pickDefaultInvoice(items);
|
||||
|
||||
@@ -27,8 +27,6 @@
|
||||
<div class="hint">Balance is summed across invoices for the selected school year.</div>
|
||||
</div>
|
||||
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
|
||||
<?php $flashStatus = session()->getFlashdata('status'); $flashError = session()->getFlashdata('error'); ?>
|
||||
<?php if (!empty($flashStatus)): ?>
|
||||
<div class="alert alert-success"><?= esc($flashStatus) ?></div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
<?php $isSchoolYearReadonly = (bool) ($isSchoolYearReadonly ?? false); ?>
|
||||
|
||||
<div class="container-fluid py-5">
|
||||
<div class="row">
|
||||
@@ -10,6 +11,11 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($isSchoolYearReadonly): ?>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
This school year is read-only. Print request statuses cannot be changed.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (session()->getFlashdata('error')): ?>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<?= session()->getFlashdata('error') ?>
|
||||
@@ -90,7 +96,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($request['status'] != 'delivered'): ?>
|
||||
<?php if (!$isSchoolYearReadonly && $request['status'] != 'delivered'): ?>
|
||||
<form action="<?= site_url('print-requests/update/' . $request['id']) ?>" method="post" class="d-flex">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="admin_id" value="<?= session()->get('user_id') ?>">
|
||||
|
||||
@@ -5,16 +5,6 @@
|
||||
<h2 class="text-center mt-4 mb-3">Reimbursed Expenses</h2>
|
||||
|
||||
<form method="get" action="<?= base_url('reimbursements') ?>" class="row g-3 mb-4">
|
||||
<!-- Semester -->
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Semester</label>
|
||||
<select name="semester" class="form-select">
|
||||
<option value="">All</option>
|
||||
<option value="Fall" <?= request()->getGet('semester') === 'Fall' ? 'selected' : '' ?>>Fall</option>
|
||||
<option value="Spring" <?= request()->getGet('semester') === 'Spring' ? 'selected' : '' ?>>Spring</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Status (from reimbursements.status) -->
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Status</label>
|
||||
@@ -28,19 +18,6 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- School Year -->
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">School Year</label>
|
||||
<select name="school_year" class="form-select">
|
||||
<option value="">All</option>
|
||||
<?php foreach ($schoolYears as $sy): ?>
|
||||
<option value="<?= esc($sy) ?>" <?= request()->getGet('school_year') == $sy ? 'selected' : '' ?>>
|
||||
<?= esc($sy) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- User -->
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Reimbursed To</label>
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<div class="wrapper">
|
||||
<div class="content"></div>
|
||||
<h2 class="text-center mt-4 mb-3">Assign Role to Users</h2>
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')); ?></div>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
<h2 class="text-center mt-4 mb-3">Staff List</h2>
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
|
||||
<?php if (!empty($issues_count) && (int)$issues_count > 0): ?>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<div class="wrapper">
|
||||
<div class="content"></div>
|
||||
<h2 class="text-center mt-4 mb-3">Login Activity</h2>
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
|
||||
<div id="loginActivityAlert" class="alert alert-danger d-none" role="alert"></div>
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<div class="content"></div>
|
||||
|
||||
<h2 class="text-center mt-4 mb-3">User List</h2>
|
||||
<?= $this->include('partials/academic_filter') ?>
|
||||
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# Balanced Student Section Distribution Plan
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This plan describes how already-promoted students will be distributed into sections for the next academic year.
|
||||
|
||||
Promotion, deliberation, passing decisions, and student eligibility have already been completed and are outside the scope of this process.
|
||||
|
||||
The distribution process must ensure that:
|
||||
|
||||
* Students are divided as equally as possible among sections.
|
||||
* Each section contains a balanced number of students from every score range.
|
||||
* Section average scores are reasonably close.
|
||||
* User-defined minimum and maximum section sizes are respected.
|
||||
|
||||
## 2. Required User Inputs
|
||||
|
||||
Before starting the distribution, the user must enter:
|
||||
|
||||
| Input | Description |
|
||||
| ---------------------------- | --------------------------------------------- |
|
||||
| Number of Sections | Number of sections to be created |
|
||||
| Minimum Students per Section | Minimum permitted section size |
|
||||
| Maximum Students per Section | Maximum permitted section size, if applicable |
|
||||
|
||||
The student list must already include each student’s final score from the previous academic year.
|
||||
|
||||
## 3. Score Range Classification
|
||||
|
||||
Students must be classified using their previous-year final scores:
|
||||
|
||||
| Score Group | Score Range |
|
||||
| ----------- | -----------: |
|
||||
| Group 1 | 90–100 |
|
||||
| Group 2 | 80–89 |
|
||||
| Group 3 | 70–79 |
|
||||
| Group 4 | 69 and below |
|
||||
|
||||
Each student must belong to exactly one score group.
|
||||
|
||||
## 4. Validate the Number of Sections
|
||||
|
||||
Let:
|
||||
|
||||
* **N** = Total number of students to distribute.
|
||||
* **S** = Number of sections entered by the user.
|
||||
* **M** = Minimum number of students per section.
|
||||
* **X** = Maximum number of students per section.
|
||||
|
||||
The requested section count is valid only when:
|
||||
|
||||
**S × M ≤ N**
|
||||
|
||||
When a maximum section size is used, the following condition must also be satisfied:
|
||||
|
||||
**N ≤ S × X**
|
||||
|
||||
Therefore, the complete validation rule is:
|
||||
|
||||
**S × M ≤ N ≤ S × X**
|
||||
|
||||
If no maximum section size is used, only the minimum condition applies.
|
||||
|
||||
## 5. Validation Results
|
||||
|
||||
The system should return one of the following results:
|
||||
|
||||
### Insufficient Students
|
||||
|
||||
The requested number of sections cannot be created because the total number of students is below the required minimum.
|
||||
|
||||
### Capacity Exceeded
|
||||
|
||||
The requested sections cannot contain all students without exceeding the maximum section size.
|
||||
|
||||
### Valid Setup
|
||||
|
||||
The entered number of sections and section-size limits are valid, and distribution may proceed.
|
||||
|
||||
The system must not automatically change the number of sections entered by the user.
|
||||
|
||||
## 6. Determine the Target Section Sizes
|
||||
|
||||
Divide the total number of students by the number of sections.
|
||||
|
||||
Each section should receive:
|
||||
|
||||
**Base section size = N divided by S**
|
||||
|
||||
Any remaining students should be assigned one at a time to different sections.
|
||||
|
||||
The difference between the largest and smallest section should not exceed one student.
|
||||
|
||||
Example:
|
||||
|
||||
* Total students: 61
|
||||
* Number of sections: 2
|
||||
|
||||
Result:
|
||||
|
||||
* Section 1: 31 students
|
||||
* Section 2: 30 students
|
||||
|
||||
## 7. Calculate the Score-Group Allocation
|
||||
|
||||
For each score group:
|
||||
|
||||
1. Count the number of students in that group.
|
||||
2. Divide the group total by the number of sections.
|
||||
3. Assign the base number to every section.
|
||||
4. Distribute any remaining students across different sections.
|
||||
|
||||
For a score group containing **G** students:
|
||||
|
||||
**Base allocation = G divided by S**
|
||||
|
||||
**Remaining students = G modulo S**
|
||||
|
||||
Example:
|
||||
|
||||
* Students scoring 90–100: 11
|
||||
* Sections: 2
|
||||
|
||||
Result:
|
||||
|
||||
* Section 1: 6 students
|
||||
* Section 2: 5 students
|
||||
|
||||
The difference between sections within the same score group should not exceed one student.
|
||||
|
||||
## 8. Mathematical Limitation
|
||||
|
||||
Exact equality is not always possible.
|
||||
|
||||
For example, nine students from one score group cannot be divided equally between two sections. One section must receive five students and the other must receive four.
|
||||
|
||||
Therefore, the required rule is:
|
||||
|
||||
* Equal distribution when mathematically possible.
|
||||
* A maximum difference of one student when exact equality is impossible.
|
||||
|
||||
## 9. Student Assignment Method
|
||||
|
||||
Within each score group:
|
||||
|
||||
1. Sort students from highest score to lowest score.
|
||||
2. Assign them using a snake-distribution method.
|
||||
3. Reverse the assignment direction after each round.
|
||||
|
||||
For two sections, use:
|
||||
|
||||
* First round: Section 1, Section 2
|
||||
* Second round: Section 2, Section 1
|
||||
* Third round: Section 1, Section 2
|
||||
|
||||
For three sections, use:
|
||||
|
||||
* First round: Section 1, Section 2, Section 3
|
||||
* Second round: Section 3, Section 2, Section 1
|
||||
|
||||
This prevents one section from repeatedly receiving the highest-scoring students.
|
||||
|
||||
## 10. Rotate Remaining Students
|
||||
|
||||
Additional students caused by uneven division should not always be assigned to the first section.
|
||||
|
||||
The section receiving the extra student should rotate between score groups.
|
||||
|
||||
Example with two sections:
|
||||
|
||||
| Score Range | Section Receiving the Extra Student |
|
||||
| ------------ | ----------------------------------- |
|
||||
| 90–100 | Section 1 |
|
||||
| 80–89 | Section 2 |
|
||||
| 70–79 | Section 1 |
|
||||
| 69 and below | Section 2 |
|
||||
|
||||
This helps maintain equal total section sizes.
|
||||
|
||||
## 11. Balance the Average Scores
|
||||
|
||||
After the initial distribution, calculate the average previous-year score for every section.
|
||||
|
||||
The difference between the highest and lowest section averages should preferably not exceed one percentage point.
|
||||
|
||||
If the difference is too large, students may be exchanged between sections when:
|
||||
|
||||
* They belong to the same score range.
|
||||
* The exchange improves the average-score balance.
|
||||
* Section sizes remain valid.
|
||||
* Minimum and maximum limits are still satisfied.
|
||||
|
||||
## 12. Additional Distribution Considerations
|
||||
|
||||
After academic balancing, the distribution may also be reviewed for:
|
||||
|
||||
* Gender balance, where applicable.
|
||||
* Special educational needs.
|
||||
* Language-support requirements.
|
||||
* Behavioral considerations.
|
||||
* Documented student-separation requirements.
|
||||
* Medical or accessibility needs.
|
||||
|
||||
Any adjustment should preserve the score-range and section-size balance as much as possible.
|
||||
|
||||
## 13. Final Distribution Summary
|
||||
|
||||
The final result should include:
|
||||
|
||||
| Section | Total Students | 90–100 | 80–89 | 70–79 | 69 and Below | Average Score |
|
||||
| --------- | -------------: | -----: | ----: | ----: | -----------: | ------------: |
|
||||
| Section 1 | 30 | 5 | 8 | 9 | 8 | 78.4 |
|
||||
| Section 2 | 30 | 5 | 7 | 9 | 9 | 78.2 |
|
||||
|
||||
## 14. Final Validation Checklist
|
||||
|
||||
Before confirming the distribution, verify that:
|
||||
|
||||
* The number of sections was entered by the user.
|
||||
* The minimum section size was entered by the user.
|
||||
* The maximum section size was entered, when applicable.
|
||||
* All students have been assigned exactly once.
|
||||
* No section is below the minimum size.
|
||||
* No section exceeds the maximum size.
|
||||
* Total section sizes differ by no more than one student.
|
||||
* Score-group counts differ by no more than one student.
|
||||
* Average scores are reasonably balanced.
|
||||
* No student is missing or duplicated.
|
||||
|
||||
## 15. Final Rule
|
||||
|
||||
The system distributes students only after promotion and deliberation are complete.
|
||||
|
||||
The user determines the number of sections and the section-size limits.
|
||||
|
||||
The system validates those values and creates the most balanced possible distribution based on:
|
||||
|
||||
* Total student count.
|
||||
* Score-range counts.
|
||||
* Section-size limits.
|
||||
* Average section scores.
|
||||
Reference in New Issue
Block a user