fix test and add invocie to parent enrollment
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m23s

This commit is contained in:
root
2026-08-29 15:25:17 -04:00
parent d2cb159451
commit 611a5c8e4b
9 changed files with 523 additions and 188 deletions
@@ -521,6 +521,7 @@ class AdministratorController extends BaseController
return view('administrator/administratordashboard', array_merge($searchData, [
'dashboardEndpoint' => site_url('api/administrator/dashboard'),
'schoolYear' => $this->schoolYear,
]));
}
+4 -6
View File
@@ -1052,13 +1052,11 @@ class InvoiceController extends ResourceController
$invoiceId = $invoice !== null ? (int) ($invoice['id'] ?? 0) : null;
$invoiceDate = null;
if ($invoice !== null) {
if ($invoice !== null && ! empty($invoice['updated_at'])) {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$invoiceDate = ! empty($invoice['issue_date'])
? (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC')))
->setTimezone(new \DateTimeZone($tzName))
->format('Y-m-d H:i:s')
: ($invoice['updated_at'] ?? null);
$invoiceDate = (new \DateTimeImmutable($invoice['updated_at'], new \DateTimeZone('UTC')))
->setTimezone(new \DateTimeZone($tzName))
->format('Y-m-d H:i:s');
}
$description = '';
+87 -3
View File
@@ -481,6 +481,8 @@ class ParentController extends BaseController
// Handle enrollments
$studentData = [];
$enrollmentResultMessages = [];
$invoiceResultMessage = null;
$invoiceErrorMessage = null;
if (!empty($enroll)) {
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
@@ -713,17 +715,34 @@ class ParentController extends BaseController
}
}
if (!empty($enroll) && empty($withdraw)) {
$invoiceResult = $this->generateInvoiceForParentEnrollment((int) $parentId);
if (! empty($invoiceResult['ok'])) {
$invoiceResultMessage = (string) ($invoiceResult['message'] ?? 'Invoice generated.');
} else {
$invoiceErrorMessage = (string) ($invoiceResult['message'] ?? 'Enrollment was submitted, but the invoice could not be generated.');
}
}
$parentData = $this->userModel->getUserInfoById($parentId);
$parentData['user_id'] = $parentId;
// Redirect to the success page after processing enrollment and withdrawal
if (!empty($withdraw)) {
// Redirect to withdrawal success page if there are withdrawals //parent/enroll_classes
$redirect = redirect()->to('/parent/enroll_classes');
$successParts = [];
$errorParts = [];
if ($withdrawalResultMessages !== []) {
$redirect = $redirect->with('success', 'Withdrawal request submitted. ' . implode(' ', $withdrawalResultMessages));
$successParts[] = 'Withdrawal request submitted. ' . implode(' ', $withdrawalResultMessages);
}
if ($withdrawalErrors !== []) {
$redirect = $redirect->with('error', 'Some withdrawal requests failed. ' . implode(' ', $withdrawalErrors));
$errorParts[] = 'Some withdrawal requests failed. ' . implode(' ', $withdrawalErrors);
}
if ($successParts !== []) {
$redirect = $redirect->with('success', implode(' ', $successParts));
}
if ($errorParts !== []) {
$redirect = $redirect->with('error', implode(' ', $errorParts));
}
return $redirect;
@@ -742,11 +761,76 @@ class ParentController extends BaseController
if ($enrollmentResultMessages !== []) {
$successMessage .= ' ' . implode(' ', $enrollmentResultMessages);
}
if ($invoiceResultMessage !== null) {
$successMessage .= ' ' . $invoiceResultMessage;
}
return redirect()->to('/parent/enroll_success')->with('success', $successMessage);
$redirect = redirect()->to('/parent/enroll_success')->with('success', $successMessage);
if ($invoiceErrorMessage !== null) {
$redirect = $redirect->with('error', $invoiceErrorMessage);
}
return $redirect;
}
}
/**
* Generate or refresh the parent's school-year invoice after parent-submitted enrollment.
*
* The invoice engine is intentionally authoritative for billable statuses. For example,
* first-time students still under admission review may not produce billable lines yet.
*
* @return array{ok: bool, message: string}
*/
private function generateInvoiceForParentEnrollment(int $parentId): array
{
if ($parentId <= 0) {
return ['ok' => false, 'message' => 'Enrollment was submitted, but the parent invoice could not be generated.'];
}
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
$semester = (string) ($this->semester ?? getSemester());
try {
$result = $this->eventController->generateInvoice((string) $parentId, $schoolYear, $semester);
} catch (Throwable $e) {
log_message('error', 'Invoice generation failed after parent enrollment: {message}', [
'message' => $e->getMessage(),
'parentId' => $parentId,
'schoolYear' => $schoolYear,
'semester' => $semester,
]);
return ['ok' => false, 'message' => 'Enrollment was submitted, but the invoice could not be generated. Please contact the school administration.'];
}
if (is_array($result) && ! empty($result['ok'])) {
return [
'ok' => true,
'message' => ! empty($result['updated']) ? 'Invoice updated.' : 'Invoice generated.',
];
}
$message = is_array($result) ? (string) ($result['message'] ?? '') : '';
if ($message === 'Invoice requires at least one non-zero line.') {
log_message('info', 'No invoice generated after parent enrollment because no billable invoice lines exist yet for parent {parentId}, year {schoolYear}.', [
'parentId' => $parentId,
'schoolYear' => $schoolYear,
]);
return ['ok' => true, 'message' => 'No invoice was generated yet because there are no billable enrollment charges.'];
}
log_message('error', 'Invoice generation returned an unsuccessful result after parent enrollment: {result}', [
'result' => json_encode($result),
'parentId' => $parentId,
'schoolYear' => $schoolYear,
'semester' => $semester,
]);
return ['ok' => false, 'message' => 'Enrollment was submitted, but the invoice could not be generated. Please contact the school administration.'];
}
private function enrollmentPayloadFromEvaluation(array $evaluation, array $base): array
{
$payload = array_merge($base, [
+207 -17
View File
@@ -34,14 +34,13 @@ public function metrics(string $schoolYear, string $semester): array
$totalAdmins = (int) ($this->userModel->countAdminsBySchoolYear($this->schoolYear) ?? 0);
$teachers = $this->userModel->getUsersByRoleAndSchoolYear('teacher', $this->schoolYear);
$teachers = $this->userModel->getUsersByRole('teacher');
$totalTeachers = $this->countUniqueEntities($teachers);
$teacherAssistants = $this->userModel->getUsersByRoleAndSchoolYear('teacher_assistant', $this->schoolYear);
$teacherAssistants = $this->userModel->getUsersByRole('teacher_assistant');
$totalTeacherAssistants = $this->countUniqueEntities($teacherAssistants);
$parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $this->schoolYear);
$totalParents = $this->countUniqueEntities($parents);
$totalParents = $this->countParentsWithEnrolledStudents($this->schoolYear);
// Count only students that have a class assigned and exist in student_class for the current school year
$totalStudents = (int) (
@@ -103,6 +102,36 @@ private function countUniqueEntities($rows): int
return count(array_unique($ids));
}
private function countParentsWithEnrolledStudents(string $schoolYear): int
{
$schoolYear = trim($schoolYear);
if ($schoolYear === '') {
return 0;
}
return (int) (
$this->db->table('students')
->select('COUNT(DISTINCT students.parent_id) AS cnt')
->join('student_class', 'student_class.student_id = students.id', 'inner')
->join('users', 'users.id = students.parent_id', 'inner')
->join('user_roles', 'user_roles.user_id = users.id', 'inner')
->join('roles', 'roles.id = user_roles.role_id', 'inner')
->where('student_class.school_year', $schoolYear)
->where('student_class.class_section_id IS NOT NULL', null, false)
->where('students.is_active', 1)
->where('students.parent_id IS NOT NULL', null, false)
->where('students.parent_id >', 0)
->where('user_roles.deleted_at', null)
->groupStart()
->where('LOWER(roles.name)', 'parent')
->orWhere('roles.slug', 'parent')
->groupEnd()
->get()
->getRow('cnt')
?? 0
);
}
public function search(string $query): array
{
$q = trim($query);
@@ -122,6 +151,15 @@ public function search(string $query): array
// 1) Tokenize input: split by whitespace and punctuation, keep meaningful pieces
$rawTokens = preg_split('/[,\s]+/u', $q, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$tokens = array_values(array_filter(array_map('trim', $rawTokens)));
if ($tokens === []) {
return [
'query' => $q,
'results' => [],
'scope_used' => 'unscoped-merged',
'scope_label' => 'all years/semesters (merged)',
'total_found' => 0,
];
}
// 2) Build phone variants for any token that looks numeric-ish
$phoneMap = []; // token => variants[]
@@ -229,22 +267,174 @@ public function search(string $query): array
$applyMultiTokenLike($ecQB, $ecCols, $tokens, ['cellphone']);
$emergency = $ecQB->limit(150)->get()->getResultArray();
$raw = [
'users' => $users,
'students' => $students,
'parents' => $parents,
'staff' => $staff,
'emergency_contacts' => $emergency,
];
$total = count($users) + count($students) + count($parents) + count($staff) + count($emergency);
$results = $this->mergeSearchResults($users, $students, $parents, $staff, $emergency);
return [
'query' => $q,
'results' => $raw,
'scope_used' => 'unscoped-raw',
'scope_label' => 'all years/semesters (raw, tokenized)',
'total_found' => $total,
'results' => $results,
'scope_used' => 'unscoped-merged',
'scope_label' => 'all years/semesters (merged, tokenized)',
'total_found' => count($results),
];
}
private function mergeSearchResults(array $users, array $students, array $parents, array $staff, array $emergency): array
{
$bundles = [];
$userIds = [];
$ensureBundle = static function (int $userId) use (&$bundles, &$userIds): void {
if ($userId <= 0) {
return;
}
if (!isset($bundles[$userId])) {
$bundles[$userId] = [
'user' => null,
'students' => [],
'parents' => [],
'staff' => [],
'emergency_contacts' => [],
];
}
$userIds[$userId] = $userId;
};
foreach ($users as $user) {
$userId = (int) ($user['id'] ?? 0);
$ensureBundle($userId);
if ($userId > 0) {
$bundles[$userId]['user'] = $user;
}
}
foreach ($students as $student) {
$parentId = (int) ($student['parent_id'] ?? 0);
$ensureBundle($parentId);
if ($parentId > 0) {
$bundles[$parentId]['students'][(int) ($student['id'] ?? 0)] = $student;
}
}
foreach ($parents as $parent) {
$firstParentId = (int) ($parent['firstparent_id'] ?? 0);
$ensureBundle($firstParentId);
if ($firstParentId > 0) {
$bundles[$firstParentId]['parents'][(int) ($parent['id'] ?? 0)] = $parent;
}
}
foreach ($staff as $staffRow) {
$userId = (int) ($staffRow['user_id'] ?? 0);
$ensureBundle($userId);
if ($userId > 0) {
$bundles[$userId]['staff'][(int) ($staffRow['id'] ?? 0)] = $staffRow;
}
}
foreach ($emergency as $emergencyRow) {
$parentId = (int) ($emergencyRow['parent_id'] ?? 0);
$ensureBundle($parentId);
if ($parentId > 0) {
$bundles[$parentId]['emergency_contacts'][(int) ($emergencyRow['id'] ?? 0)] = $emergencyRow;
}
}
if ($userIds === []) {
return [];
}
$this->hydrateSearchBundles($bundles, array_values($userIds));
$results = array_values($bundles);
usort($results, static function (array $a, array $b): int {
$aUser = $a['user'] ?? [];
$bUser = $b['user'] ?? [];
$aName = trim((string) ($aUser['lastname'] ?? '') . ' ' . (string) ($aUser['firstname'] ?? ''));
$bName = trim((string) ($bUser['lastname'] ?? '') . ' ' . (string) ($bUser['firstname'] ?? ''));
return strcasecmp($aName, $bName);
});
return $results;
}
private function hydrateSearchBundles(array &$bundles, array $userIds): void
{
$userRows = $this->db->table('users')
->select('id, firstname, lastname, email, cellphone, school_id, city, state')
->whereIn('id', $userIds)
->get()
->getResultArray();
foreach ($userRows as $user) {
$userId = (int) ($user['id'] ?? 0);
if ($userId > 0 && isset($bundles[$userId]) && empty($bundles[$userId]['user'])) {
$bundles[$userId]['user'] = $user;
}
}
$studentRows = $this->db->table('students')
->select('id, parent_id, school_id, firstname, lastname, dob, gender, rfid_tag, is_active')
->whereIn('parent_id', $userIds)
->orderBy('lastname', 'ASC')
->orderBy('firstname', 'ASC')
->get()
->getResultArray();
foreach ($studentRows as $student) {
$parentId = (int) ($student['parent_id'] ?? 0);
if ($parentId > 0 && isset($bundles[$parentId])) {
$bundles[$parentId]['students'][(int) ($student['id'] ?? 0)] = $student;
}
}
$parentRows = $this->db->table('parents')
->select('id, firstparent_id, secondparent_firstname, secondparent_lastname, secondparent_email, secondparent_phone')
->whereIn('firstparent_id', $userIds)
->get()
->getResultArray();
foreach ($parentRows as $parent) {
$firstParentId = (int) ($parent['firstparent_id'] ?? 0);
if ($firstParentId > 0 && isset($bundles[$firstParentId])) {
$bundles[$firstParentId]['parents'][(int) ($parent['id'] ?? 0)] = $parent;
}
}
$staffRows = $this->db->table('staff')
->select('id, user_id, firstname, lastname, email, phone, role_name, active_role')
->whereIn('user_id', $userIds)
->get()
->getResultArray();
foreach ($staffRows as $staffRow) {
$userId = (int) ($staffRow['user_id'] ?? 0);
if ($userId > 0 && isset($bundles[$userId])) {
$bundles[$userId]['staff'][(int) ($staffRow['id'] ?? 0)] = $staffRow;
}
}
$emergencyRows = $this->db->table('emergency_contacts')
->select('id, parent_id, emergency_contact_name, relation, cellphone, email')
->whereIn('parent_id', $userIds)
->get()
->getResultArray();
foreach ($emergencyRows as $emergencyRow) {
$parentId = (int) ($emergencyRow['parent_id'] ?? 0);
if ($parentId > 0 && isset($bundles[$parentId])) {
$bundles[$parentId]['emergency_contacts'][(int) ($emergencyRow['id'] ?? 0)] = $emergencyRow;
}
}
foreach ($bundles as &$bundle) {
$bundle['students'] = array_values($bundle['students']);
$bundle['parents'] = array_values($bundle['parents']);
$bundle['staff'] = array_values($bundle['staff']);
$bundle['emergency_contacts'] = array_values($bundle['emergency_contacts']);
}
unset($bundle);
}
}
@@ -9,7 +9,7 @@ final class EnrollmentEligibility
public const DEFERRED_MESSAGE = 'Re-enrollment cannot currently be completed because the final deliberation decision is deferred. Please contact the school administration for the next required step.';
public const KG_MISSING_DECISION_ELIGIBLE_MESSAGE = 'KG students may complete registration now. Their new-year grade placement will be based on the school age-placement rule.';
public const MISSING_DECISION_MESSAGE = 'Re-enrollment cannot currently be completed because no final deliberation decision is recorded for the student. Registration will become available after the school records a final decision.';
public const ADULT_STUDENT_MESSAGE = 'This student will be 18 years old or older on September 1 of the selected school year. The student can no longer enroll in the school.';
public const ADULT_STUDENT_MESSAGE = 'This student will be 18 years old or older on September 1 of the selected school year. A parent or guardian cannot complete registration because the student can no longer enroll in the school.';
public const ADULT_STUDENT_PARENT_PORTAL_MESSAGE = self::ADULT_STUDENT_MESSAGE;
public const WITHDRAWN_PORTAL_MESSAGE = 'This student is currently marked as Withdrawn and cannot be enrolled at this time. Please contact school administration.';
public const SIBLING_PORTAL_MESSAGE = 'Enrollment cannot continue because the family record requires administrative review. Please contact school administration.';
+93 -12
View File
@@ -1,4 +1,81 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('styles') ?>
<style>
.homework-table-scroll {
overflow-x: auto;
max-width: 100%;
-webkit-overflow-scrolling: touch;
}
.homework-sticky-table {
border-collapse: separate;
border-spacing: 0;
min-width: max-content;
width: auto;
}
.homework-sticky-table th,
.homework-sticky-table td {
box-sizing: border-box;
vertical-align: middle;
}
.homework-sticky-table th.homework-rownum-col,
.homework-sticky-table td.homework-rownum-col {
min-width: 56px;
width: 56px;
text-align: center;
white-space: nowrap;
}
.homework-sticky-table th.homework-school-id-col,
.homework-sticky-table td.homework-school-id-col {
position: sticky;
left: 0;
z-index: 4;
min-width: 112px;
width: 112px;
max-width: 112px;
}
.homework-sticky-table th.homework-first-name-col,
.homework-sticky-table td.homework-first-name-col {
position: sticky;
left: 112px;
z-index: 4;
min-width: 168px;
width: 168px;
max-width: 168px;
}
.homework-sticky-table th.homework-last-name-col,
.homework-sticky-table td.homework-last-name-col {
position: sticky;
left: 280px;
z-index: 4;
min-width: 168px;
width: 168px;
max-width: 168px;
box-shadow: 1px 0 0 rgba(0, 0, 0, 0.08);
}
.homework-sticky-table th.homework-school-id-col,
.homework-sticky-table td.homework-school-id-col,
.homework-sticky-table th.homework-first-name-col,
.homework-sticky-table td.homework-first-name-col,
.homework-sticky-table th.homework-last-name-col,
.homework-sticky-table td.homework-last-name-col {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: #fff;
}
.homework-sticky-table thead th.homework-school-id-col,
.homework-sticky-table thead th.homework-first-name-col,
.homework-sticky-table thead th.homework-last-name-col {
z-index: 6;
background: #f8f9fa;
}
.homework-sticky-table th.text-center,
.homework-sticky-table tbody td:not(.homework-rownum-col):not(.homework-school-id-col):not(.homework-first-name-col):not(.homework-last-name-col) {
min-width: 132px;
width: 132px;
}
</style>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?php
@@ -37,13 +114,14 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
<input type="hidden" name="class_section_id" value="<?= esc($classSectionId ?? '') ?>">
<table id="homeworkTable" class="table table-bordered mt-4 w-100" data-no-mgmt-sticky>
<div class="homework-table-scroll">
<table id="homeworkTable" class="table table-bordered mt-4 homework-sticky-table homework-sticky-table--grading" data-no-mgmt-sticky>
<thead class="table-light">
<tr>
<th>#</th>
<th>School ID</th>
<th>First Name</th>
<th>Last Name</th>
<th class="homework-rownum-col">#</th>
<th class="homework-school-id-col">School ID</th>
<th class="homework-first-name-col">First Name</th>
<th class="homework-last-name-col">Last Name</th>
<?php foreach ($homeworkHeaders as $index): ?>
<th class="text-center"><?= esc("Homework " . $index) ?></th>
<?php endforeach; ?>
@@ -59,15 +137,15 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
$rowLockAttr = $scoresLocked ? $lockAttr : ($rowLocked ? 'readonly aria-disabled="true"' : '');
?>
<tr class="<?= $rowLocked ? 'table-secondary text-muted' : '' ?>">
<td><?= $row++ ?></td>
<td><?= esc($student['school_id']) ?></td>
<td>
<td class="homework-rownum-col"><?= $row++ ?></td>
<td class="homework-school-id-col"><?= esc($student['school_id']) ?></td>
<td class="homework-first-name-col">
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)$studentId ?>">
<?= esc($student['firstname']) ?>
</a>
<?= student_enrollment_status_button($student, $schoolYear ?? null) ?>
</td>
<td>
<td class="homework-last-name-col">
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)$studentId ?>">
<?= esc($student['lastname']) ?>
</a>
@@ -89,6 +167,7 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
</tbody>
</table>
</div>
<div class="d-flex justify-content-between mt-4 flex-wrap gap-2">
<button type="submit" class="btn btn-success" <?= $lockAttr ?>>
@@ -144,9 +223,11 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
autoWidth: false,
order: [[2, 'asc'], [3, 'asc']],
columnDefs: [
{ targets: 0, orderable: false, searchable: false },
{ targets: 1, className: 'text-nowrap' },
...(scoreCols.length ? [{ targets: scoreCols, orderDataType: 'dom-num-input', orderSequence: ['desc', 'asc'], type: 'num' }] : []),
{ targets: 0, orderable: false, searchable: false, width: '56px' },
{ targets: 1, className: 'text-nowrap', width: '112px' },
{ targets: 2, width: '168px' },
{ targets: 3, width: '168px' },
...(scoreCols.length ? [{ targets: scoreCols, width: '132px', orderDataType: 'dom-num-input', orderSequence: ['desc', 'asc'], type: 'num' }] : []),
],
fixedHeader: {
header: true,
-1
View File
@@ -594,7 +594,6 @@
</div>
</div>
<!-- About Start - Our Mission Section -->
<div class="container-xxl py-2 content-section">
<div class="container">
@@ -147,7 +147,7 @@
}
function renderInvoiceRow(r) {
const isCarryForward = !!r.is_carry_forward;
const ts = Date.parse(r.invoice_date || new Date().toISOString());
const ts = Date.parse(r.invoice_date || '');
const genBtn = isCarryForward
? '<span class="text-muted small">Audit only</span>'
: `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${escAttr(r.parent_id)}\" data-parent-name=\"${escAttr(r.parent_name || '')}\">Generate Invoice</button>`;
@@ -165,11 +165,12 @@
genBtn,
fmtMoney(r.invoice_amount),
renderRefundCell(r),
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
`<span data-order=\"${Number.isNaN(ts) ? 0 : ts}\">${Number.isNaN(ts) ? '' : formatDateTime(ts)}</span>`,
pdf,
];
}
async function generateInvoice(parentId) {
const body = new URLSearchParams();
body.append('parent_id', parentId);
+127 -146
View File
@@ -1,4 +1,77 @@
<?= $this->extend('layout/main_layout') ?>
<?= $this->section('styles') ?>
<style>
.homework-table-scroll {
overflow-x: auto;
max-width: 100%;
-webkit-overflow-scrolling: touch;
}
.homework-sticky-table {
border-collapse: separate;
border-spacing: 0;
min-width: max-content;
width: auto;
}
.homework-sticky-table th,
.homework-sticky-table td {
box-sizing: border-box;
vertical-align: middle;
background-clip: padding-box;
}
.homework-sticky-table th.homework-rownum-col,
.homework-sticky-table td.homework-rownum-col {
position: sticky;
left: 0;
z-index: 4;
min-width: 56px;
width: 56px;
text-align: center;
white-space: nowrap;
background: #fff;
}
.homework-sticky-table th.homework-student-name-col,
.homework-sticky-table td.homework-student-name-col {
position: sticky;
left: 56px;
z-index: 4;
min-width: var(--homework-name-col-width, 18ch);
width: var(--homework-name-col-width, 18ch);
max-width: var(--homework-name-col-width, 18ch);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: #fff;
box-shadow: 1px 0 0 rgba(0, 0, 0, 0.12);
}
.homework-sticky-table thead th.homework-rownum-col,
.homework-sticky-table thead th.homework-student-name-col {
z-index: 6;
background: #f8f9fa;
}
.homework-sticky-table th.homework-score-col,
.homework-sticky-table td.homework-score-col,
.homework-sticky-table th.dynamic-col,
.homework-sticky-table td.dynamic-col {
min-width: 180px;
width: 180px;
}
.homework-sticky-table .form-control {
min-width: 0;
width: 100%;
}
.score-empty {
background-color: #fff3cd;
}
.missing-check {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: #6c757d;
white-space: nowrap;
}
</style>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<div class="container-fluid py-5">
<div class="container-fluid">
@@ -16,36 +89,42 @@
return ($value === null || $value === '') ? '' : esc($value);
};
$missingOkMap = $missingOkMap ?? [];
$studentNameWidthCh = 18;
foreach ($students as $student) {
$fullName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''));
$studentNameWidthCh = max($studentNameWidthCh, strlen($fullName) + 3);
}
$studentNameWidthCh = min($studentNameWidthCh, 42);
?>
<form id="homeworkForm" action="<?= base_url('/teacher/updateHomework') ?>" method="post">
<?= csrf_field() ?>
<input type="hidden" name="semester" value="<?= esc($semester) ?>">
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
<div class="table-responsive">
<table id="homeworkTable" class="table table-bordered mt-4 w-100">
<div class="homework-table-scroll">
<table id="homeworkTable" class="table table-bordered mt-4 homework-sticky-table homework-sticky-table--teacher" style="--homework-name-col-width: <?= (int) $studentNameWidthCh ?>ch;">
<thead>
<tr>
<th>#</th>
<th style="text-align: left;">Student Name</th>
<th class="homework-rownum-col">#</th>
<th class="homework-student-name-col" style="text-align: left;">Student Name</th>
<?php foreach ($homeworkHeaders as $homeworkIndex): ?>
<th class="text-center" data-index="<?= esc($homeworkIndex) ?>"><?= esc("Homework " . $homeworkIndex) ?></th>
<th class="text-center homework-score-col" data-index="<?= esc($homeworkIndex) ?>"><?= esc("Homework " . $homeworkIndex) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ($students as $index => $student): ?>
<tr>
<td><?= $index + 1 ?></td>
<td class="homework-rownum-col"><?= $index + 1 ?></td>
<td style="text-align: left;">
<td class="homework-student-name-col" style="text-align: left;">
<?= esc($student['firstname'] . ' ' . $student['lastname']) ?>
<input type="hidden" class="student-id" value="<?= $student['student_id'] ?>">
</td>
<?php foreach ($homeworkHeaders as $homeworkIndex): ?>
<td>
<td class="homework-score-col">
<?php
$rawScore = $student['scores'][$homeworkIndex] ?? null;
$isEmptyScore = ($rawScore === null || $rawScore === '');
@@ -70,7 +149,7 @@
</tr>
<?php endforeach; ?>
</tbody>
</table>
</table>
</div>
<div class="d-flex justify-content-between mt-3">
@@ -86,90 +165,26 @@
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.10/css/jquery.dataTables.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.10/css/dataTables.bootstrap5.min.css">
<script src="https://cdn.datatables.net/1.13.10/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.10/js/dataTables.bootstrap5.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const $table = window.jQuery ? window.jQuery('#homeworkTable') : null;
// Numeric ordering based on input values
if (window.jQuery && jQuery.fn && jQuery.fn.dataTable && !jQuery.fn.dataTable.ext.order['dom-num-input']) {
jQuery.fn.dataTable.ext.order['dom-num-input'] = function (settings, col) {
return this.api()
.column(col, { order: 'index' })
.nodes()
.map(function (td) {
const val = jQuery('input', td).val();
const num = parseFloat(val);
return Number.isFinite(num) ? num : -Infinity;
});
};
}
const table = document.getElementById('homeworkTable');
if (!table) return;
function syncHeaderTitles() {
document.querySelectorAll('thead th[data-index]').forEach((th) => {
table.querySelectorAll('thead th[data-index]').forEach((th) => {
const idx = th.dataset.index;
if (!idx) return;
const text = th.textContent.trim();
if (!/^Homework\\s+\\d+$/i.test(text)) {
th.textContent = "Homework " + idx;
if (!/^Homework\s+\d+$/i.test(text)) {
th.textContent = 'Homework ' + idx;
}
});
}
function initHomeworkTable() {
if (!(window.jQuery && jQuery.fn && jQuery.fn.DataTable && $table && $table.length)) return;
const totalCols = $table.find('thead th').length;
const scoreCols = [];
for (let i = 2; i < totalCols; i++) scoreCols.push(i);
$table.DataTable({
paging: false,
info: false,
searching: false,
autoWidth: false,
order: [[1, 'asc']],
columnDefs: [
{ targets: 0, orderable: false, searchable: false },
...(scoreCols.length ? [{ targets: scoreCols, orderDataType: 'dom-num-input', orderSequence: ['desc', 'asc'], type: 'num' }] : []),
],
drawCallback: function () {
const api = this.api();
api.column(0, { search: 'applied', order: 'applied' }).nodes().each(function (cell, i) {
cell.textContent = i + 1;
});
},
});
syncHeaderTitles();
}
function refreshDataTable() {
if (!(window.jQuery && jQuery.fn && jQuery.fn.DataTable && $table && $table.length)) return;
if (jQuery.fn.DataTable.isDataTable($table)) {
$table.DataTable().destroy();
}
initHomeworkTable();
}
function destroyDataTable() {
if (!(window.jQuery && jQuery.fn && jQuery.fn.DataTable && $table && $table.length)) return;
if (jQuery.fn.DataTable.isDataTable($table)) {
$table.DataTable().destroy();
}
}
syncHeaderTitles();
initHomeworkTable();
const toggleEmptyClass = (input) => {
if (input.value === '' || input.value === null) {
input.classList.add('score-empty');
} else {
input.classList.remove('score-empty');
}
input.classList.toggle('score-empty', input.value === '' || input.value === null);
};
const toggleMissingCheck = (input) => {
const label = input.parentElement ? input.parentElement.querySelector('.missing-check') : null;
if (!label) return;
@@ -182,7 +197,7 @@
};
const attachInputListeners = () => {
document.querySelectorAll('input[type="number"]').forEach((input) => {
table.querySelectorAll('input[type="number"]').forEach((input) => {
toggleEmptyClass(input);
toggleMissingCheck(input);
if (!input.dataset.listenerAttached) {
@@ -194,92 +209,76 @@
}
});
};
attachInputListeners();
// Initialize counter from current highest Homework index
let homeworkCounter = getMaxHomeworkIndex() + 1;
function getMaxHomeworkIndex() {
const headers = document.querySelectorAll('thead th');
let maxIndex = 0;
headers.forEach(th => {
table.querySelectorAll('thead th').forEach(th => {
const dataIndex = th.dataset.index;
if (dataIndex && !isNaN(parseInt(dataIndex, 10))) {
if (dataIndex && !Number.isNaN(parseInt(dataIndex, 10))) {
maxIndex = Math.max(maxIndex, parseInt(dataIndex, 10));
return;
}
const text = th.textContent.trim();
const match = text.match(/^Homework\s+(\d+)$/i);
const match = th.textContent.trim().match(/^Homework\s+(\d+)$/i);
if (match) {
const index = parseInt(match[1], 10);
if (!isNaN(index)) {
maxIndex = Math.max(maxIndex, index);
}
maxIndex = Math.max(maxIndex, parseInt(match[1], 10));
}
});
return maxIndex;
}
syncHeaderTitles();
attachInputListeners();
let homeworkCounter = getMaxHomeworkIndex() + 1;
const addBtn = document.getElementById('addColumnBtn');
const removeBtn = document.getElementById('removeColumnBtn');
// Add Column
addBtn.addEventListener('click', function(e) {
addBtn?.addEventListener('click', function(e) {
e.preventDefault();
destroyDataTable();
const newIndex = homeworkCounter++;
const headerRow = document.querySelector('thead tr');
const headerRow = table.querySelector('thead tr');
const newTh = document.createElement('th');
newTh.textContent = "Homework " + newIndex;
newTh.textContent = 'Homework ' + newIndex;
newTh.classList.add(`homework-col-${newIndex}`, 'homework-header', 'dynamic-col', 'text-center');
newTh.dataset.index = newIndex;
headerRow.appendChild(newTh);
const rows = document.querySelectorAll('tbody tr');
rows.forEach(row => {
table.querySelectorAll('tbody tr').forEach(row => {
const studentIdInput = row.querySelector('.student-id');
const studentId = studentIdInput ? studentIdInput.value : null;
const newTd = document.createElement('td');
newTd.classList.add(`homework-col-${newIndex}`, 'dynamic-col');
if (!studentId) {
console.warn("Missing student ID in row", row);
const newTd = document.createElement('td');
newTd.classList.add(`homework-col-${newIndex}`, 'dynamic-col');
row.appendChild(newTd);
return;
}
const newTd = document.createElement('td');
newTd.classList.add(`homework-col-${newIndex}`, 'dynamic-col');
newTd.innerHTML = `
<input type="number"
name="scores[${studentId}][${newIndex}]"
class="form-control text-center score-empty"
min="0" max="100" step="0.01">
<label class="missing-check mt-1">
<input type="checkbox"
name="missing_ok[${studentId}][${newIndex}]"
value="1"
class="missing-score-checkbox"
data-field-label="Homework ${newIndex}">
Missing ok
</label>`;
<input type="number"
name="scores[${studentId}][${newIndex}]"
class="form-control text-center score-empty"
min="0" max="100" step="0.01">
<label class="missing-check mt-1">
<input type="checkbox"
name="missing_ok[${studentId}][${newIndex}]"
value="1"
class="missing-score-checkbox"
data-field-label="Homework ${newIndex}">
Missing ok
</label>`;
row.appendChild(newTd);
});
attachInputListeners();
initHomeworkTable();
});
// ❌ Remove Last Column
removeBtn.addEventListener('click', function(e) {
removeBtn?.addEventListener('click', function(e) {
e.preventDefault();
destroyDataTable();
const dynamicHeaders = document.querySelectorAll('th.dynamic-col');
const dynamicHeaders = table.querySelectorAll('th.dynamic-col');
if (dynamicHeaders.length === 0) {
alert("No dynamic columns to remove.");
alert('No dynamic columns to remove.');
return;
}
@@ -287,30 +286,12 @@
const index = lastHeader.dataset.index;
lastHeader.remove();
const rows = document.querySelectorAll('tbody tr');
rows.forEach(row => {
const cell = row.querySelector(`.homework-col-${index}.dynamic-col`);
if (cell) {
cell.remove();
}
table.querySelectorAll('tbody tr').forEach(row => {
row.querySelector(`.homework-col-${index}.dynamic-col`)?.remove();
});
// ✅ Sync the counter with the current highest index
homeworkCounter = getMaxHomeworkIndex() + 1;
initHomeworkTable();
});
});
</script>
<style>
.score-empty {
background-color: #fff3cd;
}
.missing-check {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: #6c757d;
}
</style>
<?= $this->endSection() ?>