fix test and add invocie to parent enrollment
This commit is contained in:
@@ -521,6 +521,7 @@ class AdministratorController extends BaseController
|
||||
|
||||
return view('administrator/administratordashboard', array_merge($searchData, [
|
||||
'dashboardEndpoint' => site_url('api/administrator/dashboard'),
|
||||
'schoolYear' => $this->schoolYear,
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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() ?>
|
||||
|
||||
Reference in New Issue
Block a user