Compare commits

...

3 Commits

Author SHA1 Message Date
root 4b2dd4bdf8 add assessment guide
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m26s
2026-09-10 06:49:53 -04:00
root b5e719382d add assessment feature
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m36s
2026-09-10 06:44:24 -04:00
root ac19226bf0 add features to family card, scores, invoices and students details
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m51s
2026-09-09 23:02:16 -04:00
40 changed files with 2557 additions and 103 deletions
+44 -1
View File
@@ -1074,10 +1074,14 @@ $routes->group('family', ['filter' => 'auth:admin|principal'], static function (
$routes->get('', 'View\FamilyAdminController::index');
$routes->get('index', 'View\FamilyAdminController::index');
$routes->get('search', 'View\FamilyAdminController::search');
$routes->get('card', 'View\FamilyAdminController::card');
$routes->get('compose-email', 'View\FamilyAdminController::composeEmail');
$routes->post('compose-email/send', 'View\FamilyAdminController::sendComposeEmail');
});
// Teachers and TAs may inspect family/student details from their student lists.
// Financial data remains protected inside FamilyAdminController::card().
$routes->get('family/card', 'View\FamilyAdminController::card', [
'filter' => 'auth:admin|administrator|administrative staff|principal|teacher|teacher_assistant',
]);
// Convenience alias
$routes->get('family', 'View\FamilyAdminController::index', ['filter' => 'auth:admin|principal']);
//////////////////////////////////////////////////////////
@@ -1300,6 +1304,45 @@ $routes->get('/access_denied', 'ErrorController::accessDenied');
$routes->post('administrator/students/update', 'View\StudentController::editStudentData', ['filter' => 'auth:edit_student,update']);
// Shared assessment management (all administrative roles).
$assessmentAdminFilter = 'auth:admin_category';
$routes->group('administrator/assessments', ['filter' => $assessmentAdminFilter], static function ($routes) {
$routes->get('', 'View\AssessmentController::forms');
$routes->get('pools', 'View\AssessmentController::pools');
$routes->get('pools/(:num)', 'View\AssessmentController::pool/$1');
$routes->post('pools/(:num)/questions', 'View\AssessmentController::storeQuestion/$1');
$routes->post('questions/(:num)/update', 'View\AssessmentController::updateQuestion/$1');
$routes->post('questions/(:num)/delete', 'View\AssessmentController::deleteQuestion/$1');
$routes->post('questions/(:num)/move', 'View\AssessmentController::moveQuestion/$1');
$routes->get('forms', 'View\AssessmentController::forms');
$routes->get('forms/new', 'View\AssessmentController::newForm');
$routes->post('forms', 'View\AssessmentController::storeForm');
$routes->get('forms/(:num)/edit', 'View\AssessmentController::editForm/$1');
$routes->post('forms/(:num)', 'View\AssessmentController::updateForm/$1');
$routes->post('forms/(:num)/publish', 'View\AssessmentController::publishForm/$1');
$routes->get('forms/(:num)/preview', 'View\AssessmentController::preview/$1');
$routes->get('students', 'View\AssessmentController::newStudentRoster');
$routes->get('students/(:num)', 'View\AssessmentController::studentAssignments/$1');
$routes->post('students/(:num)/start', 'View\AssessmentController::startInterview/$1');
$routes->post('students/(:num)/assign', 'View\AssessmentController::assign/$1');
$routes->post('attempts/(:num)/progress', 'View\AssessmentController::saveAdminProgress/$1');
$routes->post('attempts/(:num)/note', 'View\AssessmentController::saveAdminNote/$1');
$routes->post('attempts/(:num)/complete', 'View\AssessmentController::completeAdminInterview/$1');
$routes->get('review/(:num)', 'View\AssessmentController::grade/$1');
$routes->post('review/(:num)', 'View\AssessmentController::saveGrade/$1');
$routes->get('grade/(:num)', 'View\AssessmentController::grade/$1');
$routes->post('grade/(:num)', 'View\AssessmentController::saveGrade/$1');
$routes->get('results/(:num)', 'View\AssessmentController::results/$1');
});
$routes->group('student/assessments', ['filter' => 'auth:student|parent'], static function ($routes) {
$routes->get('', 'View\AssessmentController::myAssessments');
$routes->get('(:num)', 'View\AssessmentController::take/$1');
$routes->post('(:num)/progress', 'View\AssessmentController::saveProgress/$1');
$routes->post('(:num)/submit', 'View\AssessmentController::submit/$1');
$routes->get('(:num)/results', 'View\AssessmentController::myResults/$1');
});
/*
* --------------------------------------------------------------------
* routes for View\SupportController
@@ -619,9 +619,12 @@ class AdministratorController extends BaseController
{
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
$data = service('administratorDirectory')->studentProfiles($selectedYear);
$data['assessmentByStudent'] = $this->assessmentActionsForStudents(array_column($data['students'] ?? [], 'id'), $selectedYear);
return view(
'administrator/student_profiles',
service('administratorDirectory')->studentProfiles($selectedYear)
$data
);
}
@@ -676,10 +679,9 @@ class AdministratorController extends BaseController
public function showNewStudents()
{
return view(
'enroll_withdraw/new-students',
service('enrollmentWithdrawal')->newStudents((string) $this->schoolYear)
);
$data = service('enrollmentWithdrawal')->newStudents((string) $this->schoolYear);
$data['assessmentByStudent'] = $this->assessmentActionsForStudents(array_column($data['new_students'] ?? [], 'id'), (string) $this->schoolYear);
return view('enroll_withdraw/new-students', $data);
}
public function adminEnrollmentWithdrawalHandler()
@@ -694,4 +696,31 @@ class AdministratorController extends BaseController
return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal'))
->with(!empty($result['ok']) ? 'success' : 'error', (string) ($result['message'] ?? ''));
}
private function assessmentActionsForStudents(array $studentIds, string $schoolYear): array
{
$studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds))));
$db = db_connect();
if ($studentIds === [] || ! $db->tableExists('student_assessments')) return [];
$rows = $db->table('student_assessments sa')
->select('sa.id, sa.student_id, sa.form_id, sa.status, sa.assigned_at, f.name AS form_name')
->join('assessment_forms f', 'f.id = sa.form_id')
->whereIn('sa.student_id', $studentIds)
->where('f.school_year', $schoolYear)
->orderBy('sa.assigned_at', 'DESC')->orderBy('sa.id', 'DESC')->get()->getResultArray();
$map = [];
foreach ($rows as $row) {
$studentId = (int) $row['student_id'];
$priority = match ((string) $row['status']) {
'completed' => 3, 'not_started', 'in_progress' => 2, 'graded' => 1, default => 0,
};
$current = $map[$studentId]['status'] ?? null;
$currentPriority = match ($current) {
'completed' => 3, 'not_started', 'in_progress' => 2, 'graded' => 1, default => -1,
};
if ($priority > $currentPriority) $map[$studentId] = $row;
}
return $map;
}
}
@@ -0,0 +1,681 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\AssessmentFormModel;
use App\Models\AssessmentQuestionModel;
use App\Models\QuestionPoolModel;
use App\Models\StudentAssessmentModel;
use App\Models\StudentModel;
use CodeIgniter\Exceptions\PageNotFoundException;
class AssessmentController extends BaseController
{
private $db;
private QuestionPoolModel $poolModel;
private AssessmentQuestionModel $questionModel;
private AssessmentFormModel $formModel;
private StudentAssessmentModel $assessmentModel;
private StudentModel $studentModel;
public function __construct()
{
helper(['form']);
$this->db = db_connect();
$this->poolModel = new QuestionPoolModel();
$this->questionModel = new AssessmentQuestionModel();
$this->formModel = new AssessmentFormModel();
$this->assessmentModel = new StudentAssessmentModel();
$this->studentModel = new StudentModel();
}
public function pools()
{
$pool = $this->primaryPool();
if (! $pool) {
throw new PageNotFoundException('The New Student Assessment pool is not installed. Run the database migrations first.');
}
$questions = $this->questionModel->where('pool_id', $pool['id'])->orderBy('order_index', 'ASC')->orderBy('id', 'ASC')->findAll();
return view('assessments/pool', ['pool' => $pool, 'questions' => $questions]);
}
public function pool(int $id)
{
$pool = $this->primaryPool();
if (! $pool || (int) $pool['id'] !== $id) {
return redirect()->to('administrator/assessments/pools');
}
$questions = $this->questionModel->where('pool_id', $id)->orderBy('order_index', 'ASC')->orderBy('id', 'ASC')->findAll();
return view('assessments/pool', ['pool' => $pool, 'questions' => $questions]);
}
public function storeQuestion(int $poolId)
{
$this->requirePool($poolId);
if (! $this->validateQuestion()) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$max = $this->db->table('assessment_questions')->selectMax('order_index')->where('pool_id', $poolId)->get()->getRowArray();
$this->questionModel->insert($this->questionPayload($poolId, ((int) ($max['order_index'] ?? 0)) + 1));
return redirect()->to('administrator/assessments/pools')->with('success', 'Question added.');
}
public function updateQuestion(int $id)
{
$question = $this->requireQuestion($id);
if (! $this->validateQuestion()) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$this->questionModel->update($id, $this->questionPayload((int) $question['pool_id'], (int) $question['order_index']));
return redirect()->to('administrator/assessments/pools')->with('success', 'Question updated.');
}
public function deleteQuestion(int $id)
{
$question = $this->requireQuestion($id);
if ($this->db->table('assessment_form_questions')->where('question_id', $id)->countAllResults() > 0) {
return redirect()->back()->with('error', 'This question is used by a form and cannot be deleted.');
}
$this->questionModel->delete($id);
$this->normalizeQuestionOrder((int) $question['pool_id']);
return redirect()->to('administrator/assessments/pools')->with('success', 'Question deleted.');
}
public function moveQuestion(int $id)
{
$question = $this->requireQuestion($id);
$direction = (string) $this->request->getPost('direction');
$operator = $direction === 'up' ? '<' : '>';
$sort = $direction === 'up' ? 'DESC' : 'ASC';
$other = $this->db->table('assessment_questions')
->where('pool_id', $question['pool_id'])->where('order_index ' . $operator, $question['order_index'])
->orderBy('order_index', $sort)->limit(1)->get()->getRowArray();
if ($other) {
$this->db->transStart();
$this->questionModel->update($id, ['order_index' => $other['order_index']]);
$this->questionModel->update((int) $other['id'], ['order_index' => $question['order_index']]);
$this->db->transComplete();
}
return redirect()->to('administrator/assessments/pools');
}
public function forms()
{
$forms = $this->db->table('assessment_forms f')
->select('f.*, p.name AS pool_name, COUNT(DISTINCT fq.question_id) AS question_count, COUNT(DISTINCT sa.id) AS assignment_count')
->join('question_pools p', 'p.id = f.pool_id')
->join('assessment_form_questions fq', 'fq.form_id = f.id', 'left')
->join('student_assessments sa', 'sa.form_id = f.id', 'left')
->groupBy('f.id')->orderBy('f.created_at', 'DESC')->get()->getResultArray();
return view('assessments/forms', ['forms' => $forms]);
}
public function newForm()
{
$pool = $this->primaryPool();
$selectedSchoolYear = $this->currentSchoolYearName((string) session()->get('school_year'));
return view('assessments/form_builder', [
'form' => null,
'pools' => $pool ? [$pool] : [],
'questions' => $pool ? $this->questionModel->where('pool_id', $pool['id'])->orderBy('order_index', 'ASC')->findAll() : [],
'selectedIds' => [], 'locked' => false, 'schoolYears' => $this->schoolYearOptions(),
'selectedSchoolYear' => $selectedSchoolYear,
]);
}
public function storeForm()
{
return $this->saveForm(null);
}
public function editForm(int $id)
{
$form = $this->requireForm($id);
$questions = $this->questionModel->where('pool_id', $form['pool_id'])->orderBy('order_index', 'ASC')->findAll();
$selectedRows = $this->db->table('assessment_form_questions')->where('form_id', $id)->orderBy('order_index', 'ASC')->get()->getResultArray();
return view('assessments/form_builder', [
'form' => $form,
'pools' => $this->poolModel->orderBy('name', 'ASC')->findAll(),
'questions' => $questions,
'selectedIds' => array_map('intval', array_column($selectedRows, 'question_id')),
'locked' => $this->db->table('student_assessments')->where('form_id', $id)->countAllResults() > 0,
'schoolYears' => $this->schoolYearOptions(),
'selectedSchoolYear' => (string) ($form['school_year'] ?? ''),
]);
}
public function updateForm(int $id)
{
$this->requireForm($id);
return $this->saveForm($id);
}
public function publishForm(int $id)
{
$form = $this->requireForm($id);
$questionCount = $this->db->table('assessment_form_questions')->where('form_id', $id)->countAllResults();
if ($questionCount === 0) {
return redirect()->to('administrator/assessments/forms')->with('error', 'Add at least one question before publishing this form.');
}
if (! preg_match('/^\d{4}-\d{4}$/', (string) ($form['school_year'] ?? ''))) {
return redirect()->to('administrator/assessments/forms/' . $id . '/edit')
->with('error', 'Select a school year before publishing this form.');
}
if ($form['status'] !== 'published') {
$this->formModel->update($id, ['status' => 'published']);
}
return redirect()->to('administrator/assessments/forms')->with('success', 'Assessment form published.');
}
public function preview(int $id)
{
$data = $this->formWithQuestions($id);
$data['preview'] = true;
return view('assessments/take', $data);
}
public function newStudentRoster()
{
$schoolYear = $this->currentSchoolYearName((string) session()->get('school_year'));
$data = service('enrollmentWithdrawal')->newStudents($schoolYear);
$data['schoolYear'] = $schoolYear;
$data['assessmentByStudent'] = $this->assessmentActionsForStudents(array_column($data['new_students'] ?? [], 'id'), $schoolYear);
return view('assessments/new_students', $data);
}
public function studentAssignments(int $studentId)
{
$student = $this->studentModel->find($studentId);
if (! $student) {
throw new PageNotFoundException('Student not found.');
}
$schoolYear = $this->currentSchoolYearName((string) session()->get('school_year'));
$active = $this->activeAssessmentForStudent($studentId, $schoolYear);
if ($active) {
return view('assessments/admin_take', $this->assessmentDetails((int) $active['id']));
}
$forms = $this->db->table('assessment_forms f')
->select('f.*, p.name AS pool_name, COUNT(fq.question_id) AS question_count')
->join('question_pools p', 'p.id = f.pool_id')
->join('assessment_form_questions fq', 'fq.form_id = f.id', 'left')
->where('f.status', 'published')->where('f.school_year', $schoolYear)
->groupBy('f.id')->orderBy('f.name', 'ASC')->get()->getResultArray();
$assignments = $this->db->table('student_assessments sa')
->select('sa.*, f.name AS form_name, f.school_year')->join('assessment_forms f', 'f.id = sa.form_id')
->where('sa.student_id', $studentId)->orderBy('sa.assigned_at', 'DESC')->get()->getResultArray();
return view('assessments/student_assignments', ['student' => $student, 'forms' => $forms, 'assignments' => $assignments]);
}
public function startInterview(int $studentId)
{
if (! $this->studentModel->find($studentId)) {
throw new PageNotFoundException('Student not found.');
}
$schoolYear = $this->currentSchoolYearName((string) session()->get('school_year'));
$active = $this->activeAssessmentForStudent($studentId, $schoolYear);
if ($active) return redirect()->to("administrator/assessments/students/{$studentId}");
$assignedFormIds = array_map('intval', array_column(
$this->assessmentModel->select('form_id')->where('student_id', $studentId)->findAll(),
'form_id'
));
$formBuilder = $this->formModel->where('status', 'published')->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC')->orderBy('id', 'DESC');
if ($assignedFormIds !== []) $formBuilder->whereNotIn('id', $assignedFormIds);
$form = $formBuilder->first();
if (! $form) {
return redirect()->to('administrator/assessments/students/' . $studentId)
->with('error', 'No unassigned published assessment form is available for this student.');
}
$this->assessmentModel->insert([
'form_id' => (int) $form['id'], 'student_id' => $studentId, 'status' => 'not_started',
'assigned_at' => date('Y-m-d H:i:s'),
]);
return redirect()->to("administrator/assessments/students/{$studentId}");
}
public function assign(int $studentId)
{
if (! $this->studentModel->find($studentId)) {
throw new PageNotFoundException('Student not found.');
}
$formId = (int) $this->request->getPost('form_id');
$schoolYear = $this->currentSchoolYearName((string) session()->get('school_year'));
$form = $this->formModel->where('id', $formId)->where('status', 'published')->where('school_year', $schoolYear)->first();
if (! $form) {
return redirect()->back()->with('error', 'Choose a published assessment form.');
}
if ($this->assessmentModel->where(['form_id' => $formId, 'student_id' => $studentId])->first()) {
return redirect()->back()->with('error', 'This form is already assigned to this student.');
}
try {
$inserted = $this->assessmentModel->insert([
'form_id' => $formId, 'student_id' => $studentId, 'status' => 'not_started',
'assigned_at' => date('Y-m-d H:i:s'),
]);
} catch (\Throwable $e) {
log_message('warning', 'Assessment assignment rejected: ' . $e->getMessage());
$inserted = false;
}
if ($inserted === false) {
return redirect()->back()->with('error', 'This form is already assigned to this student.');
}
return redirect()->to("administrator/assessments/students/{$studentId}")->with('success', 'Assessment assigned.');
}
public function grade(int $assessmentId)
{
$data = $this->assessmentDetails($assessmentId);
if (! in_array($data['assessment']['status'], ['completed', 'graded'], true)) {
return redirect()->back()->with('error', 'The student must complete the assessment before it can be reviewed.');
}
return view('assessments/grade', $data);
}
public function saveGrade(int $assessmentId)
{
$data = $this->assessmentDetails($assessmentId);
if (! in_array($data['assessment']['status'], ['completed', 'graded'], true)) {
return redirect()->back()->with('error', 'The assessment is not ready for review.');
}
$this->persistEducationCommitteeNote($assessmentId);
$this->db->transStart();
$this->db->table('student_answers')->where('student_assessment_id', $assessmentId)->update([
'is_correct' => null, 'points_awarded' => null, 'updated_at' => date('Y-m-d H:i:s'),
]);
$this->assessmentModel->update($assessmentId, [
'status' => 'graded', 'score' => null, 'graded_at' => date('Y-m-d H:i:s'),
'graded_by' => (int) session()->get('user_id') ?: null,
]);
$this->db->transComplete();
return redirect()->to("administrator/assessments/results/{$assessmentId}")->with('success', 'Assessment review completed.');
}
public function results(int $assessmentId)
{
$data = $this->assessmentDetails($assessmentId);
$data['adminView'] = true;
return view('assessments/results', $data);
}
public function saveAdminProgress(int $assessmentId)
{
$assessment = $this->assessmentModel->find($assessmentId);
if (! $assessment) throw new PageNotFoundException('Assessment not found.');
if (! in_array($assessment['status'], ['not_started', 'in_progress'], true)) {
return $this->response->setStatusCode(409)->setJSON(['status' => false, 'message' => 'This assessment can no longer be changed.']);
}
$this->persistEducationCommitteeNote($assessmentId);
$this->persistAnswers($assessment, (array) $this->request->getPost('answers'));
return $this->response->setJSON([
'status' => true, 'message' => 'Saved', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(),
]);
}
public function saveAdminNote(int $assessmentId)
{
if (! $this->assessmentModel->find($assessmentId)) {
throw new PageNotFoundException('Assessment not found.');
}
$this->persistEducationCommitteeNote($assessmentId);
return $this->response->setJSON([
'status' => true, 'message' => 'Note saved', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(),
]);
}
public function completeAdminInterview(int $assessmentId)
{
$assessment = $this->assessmentModel->find($assessmentId);
if (! $assessment) throw new PageNotFoundException('Assessment not found.');
if (! in_array($assessment['status'], ['not_started', 'in_progress'], true)) {
return redirect()->to('administrator/assessments/students/' . $assessment['student_id'])
->with('error', 'This assessment has already been completed.');
}
$this->persistEducationCommitteeNote($assessmentId);
$this->persistAnswers($assessment, (array) $this->request->getPost('answers'));
$this->assessmentModel->update($assessmentId, ['status' => 'completed', 'submitted_at' => date('Y-m-d H:i:s')]);
return redirect()->to("administrator/assessments/review/{$assessmentId}")
->with('success', 'Assessment completed. Review the responses below.');
}
public function myAssessments()
{
$studentIds = $this->accessibleStudentIds();
$assignments = [];
if ($studentIds) {
$assignments = $this->db->table('student_assessments sa')
->select('sa.*, f.name AS form_name, f.school_year, s.firstname, s.lastname')
->join('assessment_forms f', 'f.id = sa.form_id')
->join('students s', 's.id = sa.student_id')
->whereIn('sa.student_id', $studentIds)->orderBy('sa.assigned_at', 'DESC')->get()->getResultArray();
}
return view('assessments/my_assessments', ['assignments' => $assignments]);
}
public function take(int $assessmentId)
{
$assessment = $this->requireAccessibleAssessment($assessmentId);
if ($assessment['status'] === 'graded') {
return redirect()->to("student/assessments/{$assessmentId}/results");
}
if ($assessment['status'] === 'completed') {
return redirect()->to('student/assessments')->with('success', 'This assessment has already been submitted and is awaiting review.');
}
$data = $this->formWithQuestions((int) $assessment['form_id']);
$answerRows = $this->db->table('student_answers')->where('student_assessment_id', $assessmentId)->get()->getResultArray();
$data['answers'] = array_column($answerRows, 'answer_value', 'question_id');
$data['assessment'] = $assessment;
$data['preview'] = false;
return view('assessments/take', $data);
}
public function saveProgress(int $assessmentId)
{
$assessment = $this->requireAccessibleAssessment($assessmentId);
if (! in_array($assessment['status'], ['not_started', 'in_progress'], true)) {
return $this->response->setStatusCode(409)->setJSON(['status' => false, 'message' => 'This assessment can no longer be changed.']);
}
$answers = (array) $this->request->getPost('answers');
$this->persistAnswers($assessment, $answers);
if ($this->request->isAJAX()) {
return $this->response->setJSON([
'status' => true, 'message' => 'Progress saved.',
'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(),
]);
}
return redirect()->back()->with('success', 'Progress saved.');
}
public function submit(int $assessmentId)
{
$assessment = $this->requireAccessibleAssessment($assessmentId);
if (! in_array($assessment['status'], ['not_started', 'in_progress'], true)) {
return redirect()->to('student/assessments')->with('error', 'This assessment was already submitted.');
}
$this->persistAnswers($assessment, (array) $this->request->getPost('answers'));
$this->assessmentModel->update($assessmentId, ['status' => 'completed', 'submitted_at' => date('Y-m-d H:i:s')]);
return redirect()->to('student/assessments')->with('success', 'Assessment submitted. Your responses will appear after review.');
}
public function myResults(int $assessmentId)
{
$assessment = $this->requireAccessibleAssessment($assessmentId);
if ($assessment['status'] !== 'graded') {
return redirect()->to('student/assessments')->with('error', 'Results are not available yet.');
}
$data = $this->assessmentDetails($assessmentId);
$data['adminView'] = false;
return view('assessments/results', $data);
}
private function saveForm(?int $id)
{
$rules = [
'name' => 'required|string|max_length[150]', 'pool_id' => 'required|is_natural_no_zero',
'school_year' => 'required|regex_match[/^\d{4}-\d{4}$/]',
'status' => 'required|in_list[draft,published,archived]',
'education_committee_note' => 'permit_empty|string|max_length[10000]',
];
if (! $this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$existingForm = $id !== null ? $this->requireForm($id) : null;
$submittedSchoolYear = trim((string) $this->request->getPost('school_year'));
if ($this->db->tableExists('school_years')
&& $this->db->table('school_years')->where('name', $submittedSchoolYear)->countAllResults() === 0) {
return redirect()->back()->withInput()->with('error', 'Select a valid school year.');
}
$poolId = $existingForm ? (int) $existingForm['pool_id'] : (int) $this->request->getPost('pool_id');
if (! $this->poolModel->find($poolId)) {
return redirect()->back()->withInput()->with('error', 'Question pool not found.');
}
$locked = $id !== null && $this->db->table('student_assessments')->where('form_id', $id)->countAllResults() > 0;
$selected = array_values(array_unique(array_map('intval', (array) $this->request->getPost('question_ids'))));
if ($this->request->getPost('use_all')) {
$selected = array_map('intval', array_column($this->questionModel->where('pool_id', $poolId)->orderBy('order_index', 'ASC')->findAll(), 'id'));
}
if (! $locked) {
$validRows = empty($selected) ? [] : $this->questionModel->where('pool_id', $poolId)->whereIn('id', $selected)->findAll();
$validIds = array_map('intval', array_column($validRows, 'id'));
$selected = array_values(array_filter($selected, static fn ($qid) => in_array($qid, $validIds, true)));
usort($selected, function ($a, $b) {
return ((int) $this->request->getPost('order_' . $a)) <=> ((int) $this->request->getPost('order_' . $b));
});
if (empty($selected)) {
return redirect()->back()->withInput()->with('error', 'Select at least one question.');
}
}
$payload = [
'name' => trim((string) $this->request->getPost('name')),
'school_year' => $locked ? (string) $existingForm['school_year'] : $submittedSchoolYear,
'status' => $this->request->getPost('status'),
'education_committee_note' => $this->nullableString($this->request->getPost('education_committee_note')),
];
if ($id === null) $payload['pool_id'] = $poolId;
$this->db->transStart();
if ($id === null) {
$payload['created_by'] = (int) session()->get('user_id') ?: null;
$id = (int) $this->formModel->insert($payload, true);
} else {
$this->formModel->update($id, $payload);
}
if (! $locked) {
$this->db->table('assessment_form_questions')->where('form_id', $id)->delete();
foreach ($selected as $index => $questionId) {
$this->db->table('assessment_form_questions')->insert(['form_id' => $id, 'question_id' => $questionId, 'order_index' => $index + 1]);
}
}
$this->db->transComplete();
return redirect()->to('administrator/assessments/forms')->with('success', 'Assessment form saved.');
}
private function validateQuestion(): bool
{
$valid = $this->validate([
'type' => 'required|in_list[multiple_choice,short_answer,true_false,essay]',
'text' => 'required|string|max_length[10000]', 'options_text' => 'permit_empty|string|max_length[10000]',
'correct_answer' => 'permit_empty|string|max_length[10000]',
]);
if ($valid && $this->request->getPost('type') === 'multiple_choice') {
$choices = preg_split('/\r\n|\r|\n/', trim((string) $this->request->getPost('options_text'))) ?: [];
$choices = array_filter(array_map('trim', $choices), static fn ($choice) => $choice !== '');
if (count($choices) < 2) {
$this->validator->setError('options_text', 'Multiple-choice questions need at least two choices.');
return false;
}
}
return $valid;
}
private function questionPayload(int $poolId, int $order): array
{
$type = (string) $this->request->getPost('type');
$options = null;
if ($type === 'true_false') {
$options = json_encode(['True', 'False']);
} elseif ($type === 'multiple_choice') {
$values = preg_split('/\r\n|\r|\n/', trim((string) $this->request->getPost('options_text')));
$values = array_values(array_filter(array_map('trim', $values ?: []), static fn ($v) => $v !== ''));
$options = $values ? json_encode($values, JSON_UNESCAPED_UNICODE) : null;
}
return [
'pool_id' => $poolId, 'type' => $type, 'text' => trim((string) $this->request->getPost('text')),
'options' => $options, 'correct_answer' => $this->nullableString($this->request->getPost('correct_answer')),
'points' => 0, 'order_index' => $order,
];
}
private function formWithQuestions(int $id): array
{
$form = $this->requireForm($id);
$questions = $this->db->table('assessment_form_questions fq')
->select('q.*, fq.order_index AS form_order')->join('assessment_questions q', 'q.id = fq.question_id')
->where('fq.form_id', $id)->orderBy('fq.order_index', 'ASC')->get()->getResultArray();
return ['form' => $form, 'questions' => $questions, 'answers' => []];
}
private function assessmentDetails(int $id): array
{
$assessment = $this->db->table('student_assessments sa')
->select('sa.*, f.name AS form_name, f.id AS assessment_form_id, f.school_year, f.education_committee_note AS form_education_committee_note, s.firstname, s.lastname, s.school_id')
->join('assessment_forms f', 'f.id = sa.form_id')->join('students s', 's.id = sa.student_id')
->where('sa.id', $id)->get()->getRowArray();
if (! $assessment) {
throw new PageNotFoundException('Assessment not found.');
}
$questions = $this->db->table('assessment_form_questions fq')
->select('q.*, fq.order_index AS form_order, a.answer_value')
->join('assessment_questions q', 'q.id = fq.question_id')
->join('student_answers a', 'a.question_id = q.id AND a.student_assessment_id = ' . (int) $id, 'left')
->where('fq.form_id', $assessment['form_id'])->orderBy('fq.order_index', 'ASC')->get()->getResultArray();
return ['assessment' => $assessment, 'questions' => $questions];
}
private function requirePool(int $id): array
{
$row = $this->poolModel->find($id);
if (! $row) throw new PageNotFoundException('Question pool not found.');
return $row;
}
private function primaryPool(): ?array
{
$pool = $this->poolModel->where('name', 'New Student Assessment')->first();
if ($pool) return $pool;
return $this->poolModel->orderBy('id', 'ASC')->first() ?: null;
}
private function schoolYearOptions(): array
{
if (! $this->db->tableExists('school_years')) return [];
return $this->db->table('school_years')->select('name, status')->orderBy('name', 'DESC')->get()->getResultArray();
}
private function requireQuestion(int $id): array
{
$row = $this->questionModel->find($id);
if (! $row) throw new PageNotFoundException('Question not found.');
return $row;
}
private function requireForm(int $id): array
{
$row = $this->formModel->find($id);
if (! $row) throw new PageNotFoundException('Assessment form not found.');
return $row;
}
private function normalizeQuestionOrder(int $poolId): void
{
$rows = $this->questionModel->where('pool_id', $poolId)->orderBy('order_index', 'ASC')->orderBy('id', 'ASC')->findAll();
foreach ($rows as $index => $row) $this->questionModel->update($row['id'], ['order_index' => $index + 1]);
}
private function nullableString($value): ?string
{
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function assessmentActionsForStudents(array $studentIds, string $schoolYear): array
{
$studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds))));
if ($studentIds === []) return [];
$rows = $this->db->table('student_assessments sa')
->select('sa.*, f.name AS form_name')->join('assessment_forms f', 'f.id = sa.form_id')
->where('f.school_year', $schoolYear)
->whereIn('sa.student_id', $studentIds)->orderBy('sa.assigned_at', 'DESC')->orderBy('sa.id', 'DESC')
->get()->getResultArray();
$map = [];
foreach ($rows as $row) {
$studentId = (int) $row['student_id'];
$priority = match ((string) $row['status']) {
'completed' => 3, 'not_started', 'in_progress' => 2, 'graded' => 1, default => 0,
};
$currentStatus = $map[$studentId]['status'] ?? null;
$currentPriority = match ($currentStatus) {
'completed' => 3, 'not_started', 'in_progress' => 2, 'graded' => 1, default => -1,
};
if ($priority > $currentPriority) $map[$studentId] = $row;
}
return $map;
}
private function activeAssessmentForStudent(int $studentId, string $schoolYear): ?array
{
return $this->db->table('student_assessments sa')
->select('sa.*')->join('assessment_forms f', 'f.id = sa.form_id')
->where('sa.student_id', $studentId)->where('f.school_year', $schoolYear)
->whereIn('sa.status', ['not_started', 'in_progress'])
->orderBy('sa.assigned_at', 'DESC')->orderBy('sa.id', 'DESC')->get()->getRowArray() ?: null;
}
private function persistAnswers(array $assessment, array $answers): void
{
$questionIds = array_map('intval', array_column(
$this->db->table('assessment_form_questions')->select('question_id')->where('form_id', $assessment['form_id'])->get()->getResultArray(),
'question_id'
));
$now = date('Y-m-d H:i:s');
$this->db->transStart();
foreach ($questionIds as $questionId) {
if (! array_key_exists($questionId, $answers)) continue;
$value = is_array($answers[$questionId]) ? implode(', ', $answers[$questionId]) : trim((string) $answers[$questionId]);
$existing = $this->db->table('student_answers')->where([
'student_assessment_id' => $assessment['id'], 'question_id' => $questionId,
])->get()->getRowArray();
$payload = ['answer_value' => $value, 'updated_at' => $now];
if ($existing) {
$this->db->table('student_answers')->where('id', $existing['id'])->update($payload);
} else {
$payload += ['student_assessment_id' => $assessment['id'], 'question_id' => $questionId, 'created_at' => $now];
$this->db->table('student_answers')->insert($payload);
}
}
if ($assessment['status'] === 'not_started') {
$this->assessmentModel->update($assessment['id'], ['status' => 'in_progress', 'started_at' => $now]);
}
$this->db->transComplete();
}
private function persistEducationCommitteeNote(int $assessmentId): void
{
$note = trim((string) $this->request->getPost('education_committee_note'));
if (mb_strlen($note) > 10000) $note = mb_substr($note, 0, 10000);
$this->assessmentModel->update($assessmentId, [
'education_committee_note' => $note === '' ? null : $note,
]);
}
private function requireAccessibleAssessment(int $id): array
{
$assessment = $this->assessmentModel->find($id);
if (! $assessment || ! in_array((int) $assessment['student_id'], $this->accessibleStudentIds(), true)) {
throw new PageNotFoundException('Assessment not found.');
}
return $assessment;
}
private function accessibleStudentIds(): array
{
$userId = (int) session()->get('user_id');
if ($userId <= 0) return [];
$ids = [];
$user = $this->db->table('users')->select('school_id')->where('id', $userId)->get()->getRowArray();
$schoolId = trim((string) ($user['school_id'] ?? ''));
if ($schoolId !== '') {
$row = $this->db->table('students')->select('id')->where('school_id', $schoolId)->get()->getRowArray();
if ($row) $ids[] = (int) $row['id'];
}
$parentRows = $this->db->table('students')->select('id')->where('parent_id', $userId)->get()->getResultArray();
$ids = array_merge($ids, array_map('intval', array_column($parentRows, 'id')));
if ($this->db->tableExists('family_guardians') && $this->db->tableExists('family_students')) {
$familyRows = $this->db->table('family_guardians fg')->select('fs.student_id')
->join('family_students fs', 'fs.family_id = fg.family_id')->where('fg.user_id', $userId)->get()->getResultArray();
$ids = array_merge($ids, array_map('intval', array_column($familyRows, 'student_id')));
}
return array_values(array_unique(array_filter($ids)));
}
}
+132 -12
View File
@@ -239,6 +239,7 @@ class FamilyAdminController extends BaseController
public function card(): ResponseInterface
{
$db = \Config\Database::connect();
$canViewInvoices = $this->canViewFamilyInvoices();
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
$guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
$familyId = (int) ($this->request->getGet('family_id') ?? 0);
@@ -338,9 +339,8 @@ class FamilyAdminController extends BaseController
// Hydrate with guardians, students (+grades), invoices, payments
$invoiceModel = new \App\Models\InvoiceModel();
$paymentModel = new \App\Models\PaymentModel();
$studentClassModel = new \App\Models\StudentClassModel();
$configModel = new \App\Models\ConfigurationModel();
$schoolYear = (string) ($configModel->getConfig('school_year') ?? '');
$schoolYear = $this->currentSchoolYearName((string) ($configModel->getConfig('school_year') ?? ''));
// Guardians
$guardians = $db->query(
@@ -354,10 +354,11 @@ class FamilyAdminController extends BaseController
[$familyId]
)->getResultArray();
$family['guardians'] = $guardians;
$family['can_view_invoices'] = $canViewInvoices;
// Students
$studentsRows = $db->query(
"SELECT s.id, s.firstname, s.lastname
"SELECT s.*
FROM family_students fs
JOIN students s ON s.id = fs.student_id
WHERE fs.family_id = ?
@@ -369,7 +370,7 @@ class FamilyAdminController extends BaseController
$studentIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $studentsRows);
if (!in_array($studentId, $studentIds, true)) {
$selectedStudent = $db->query(
"SELECT id, firstname, lastname
"SELECT *
FROM students
WHERE id = ?
LIMIT 1",
@@ -383,13 +384,101 @@ class FamilyAdminController extends BaseController
}
if (!empty($studentsRows)) {
$studentIds = array_values(array_filter(array_map(
static fn(array $row): int => (int) ($row['id'] ?? 0),
$studentsRows
)));
$allergiesByStudent = [];
$conditionsByStudent = [];
$classAssignmentsByStudent = [];
$enrollmentByStudent = [];
$scoreHistoryByStudent = [];
if (!empty($studentIds)) {
if ($schoolYear !== '') {
$classSectionJoin = 'cs.class_section_id = sc.class_section_id';
if ($db->fieldExists('school_year', 'classSection')) {
$classSectionJoin .= ' AND (cs.school_year = sc.school_year OR cs.school_year IS NULL)';
}
$classAssignmentRows = $db->table('student_class sc')
->select('sc.student_id, cs.class_section_name, c.class_name')
->join('classSection cs', $classSectionJoin, 'left')
->join('classes c', 'c.id = cs.class_id', 'left')
->whereIn('sc.student_id', $studentIds)
->where('sc.school_year', $schoolYear)
->where('sc.class_section_id IS NOT NULL', null, false)
->orderBy('cs.class_section_name', 'ASC')
->get()
->getResultArray();
foreach ($classAssignmentRows as $classAssignmentRow) {
$assignmentStudentId = (int) ($classAssignmentRow['student_id'] ?? 0);
$className = trim((string) ($classAssignmentRow['class_name'] ?? ''));
$sectionName = trim((string) ($classAssignmentRow['class_section_name'] ?? ''));
$label = $className !== '' && $sectionName !== '' && strcasecmp($className, $sectionName) !== 0
? $className . ' / ' . $sectionName
: ($sectionName !== '' ? $sectionName : $className);
if ($assignmentStudentId > 0 && $label !== '') {
$classAssignmentsByStudent[$assignmentStudentId][$label] = true;
}
}
$enrollmentRows = $db->table('enrollments e')
->select('e.student_id, e.enrollment_status')
->whereIn('e.student_id', $studentIds)
->where('e.school_year', $schoolYear)
->orderBy('e.updated_at', 'DESC')
->orderBy('e.enrollment_date', 'DESC')
->orderBy('e.id', 'DESC')
->get()
->getResultArray();
foreach ($enrollmentRows as $enrollmentRow) {
$enrollmentStudentId = (int) ($enrollmentRow['student_id'] ?? 0);
if ($enrollmentStudentId > 0 && !isset($enrollmentByStudent[$enrollmentStudentId])) {
$enrollmentByStudent[$enrollmentStudentId] = $enrollmentRow;
}
}
}
$allergyRows = $db->table('student_allergies')
->select('student_id, allergy')
->whereIn('student_id', $studentIds)
->orderBy('allergy', 'ASC')
->get()
->getResultArray();
foreach ($allergyRows as $allergyRow) {
$allergiesByStudent[(int) ($allergyRow['student_id'] ?? 0)][] = (string) ($allergyRow['allergy'] ?? '');
}
$conditionRows = $db->table('student_medical_conditions')
->select('student_id, condition_name')
->whereIn('student_id', $studentIds)
->orderBy('condition_name', 'ASC')
->get()
->getResultArray();
foreach ($conditionRows as $conditionRow) {
$conditionsByStudent[(int) ($conditionRow['student_id'] ?? 0)][] = (string) ($conditionRow['condition_name'] ?? '');
}
$scoreHistoryByStudent = (new \App\Services\StudentScoreHistoryService($db))
->forStudents($studentIds);
}
foreach ($studentsRows as &$sr) {
$sid = (int) ($sr['id'] ?? 0);
$sr['grade'] = $sid ? (string) ($studentClassModel->getClassSectionsByStudentId($sid, $schoolYear) ?? '') : '';
$enrollment = $enrollmentByStudent[$sid] ?? [];
$sr['grade'] = implode(', ', array_keys($classAssignmentsByStudent[$sid] ?? []));
$sr['enrollment_status'] = (string) ($enrollment['enrollment_status'] ?? '');
$sr['allergies'] = $allergiesByStudent[$sid] ?? [];
$sr['medical_conditions'] = $conditionsByStudent[$sid] ?? [];
$sr['score_history'] = $scoreHistoryByStudent[$sid] ?? [];
}
unset($sr);
}
$family['students'] = $studentsRows;
$family['selected_student_id'] = $studentId;
// Financials
$parentIds = array_map(static fn($g) => (int)($g['user_id'] ?? 0), $guardians);
@@ -427,13 +516,21 @@ class FamilyAdminController extends BaseController
}
}
if (!empty($parentIds)) {
if ($canViewInvoices && !empty($parentIds)) {
// Invoices
$invRows = $db->table('invoices')
$invoiceBuilder = $db->table('invoices')
->select('id, parent_id, invoice_number, status, total_amount, paid_amount, balance, issue_date, due_date')
->whereIn('parent_id', $parentIds)
->orderBy('issue_date', 'DESC')
->get()->getResultArray();
->orderBy('issue_date', 'DESC');
if ($schoolYear !== '') {
$invoiceBuilder->where('school_year', $schoolYear);
}
$invRows = $invoiceBuilder->get()->getResultArray();
foreach ($invRows as &$invoiceRow) {
$invoiceParentId = (int) ($invoiceRow['parent_id'] ?? 0);
$invoiceRow['parent_name'] = $gmap[$invoiceParentId] ?? ('Parent #' . $invoiceParentId);
}
unset($invoiceRow);
$family['invoices'] = $invRows;
$invoiceMap = [];
foreach ($invRows as $ir) {
@@ -449,20 +546,43 @@ class FamilyAdminController extends BaseController
}
// Payments
$payRows = $db->table('payments p')
$paymentBuilder = $db->table('payments p')
->select('p.id, p.parent_id, p.invoice_id, p.paid_amount, p.payment_method, p.payment_date, p.status AS payment_status, p.installment_seq, p.number_of_installments, i.invoice_number, i.balance AS invoice_current_balance, i.status AS invoice_status, i.school_year')
->join('invoices i', 'i.id = p.invoice_id', 'inner')
->whereIn('p.parent_id', $parentIds)
->orderBy('p.payment_date', 'DESC')
->orderBy('p.id', 'DESC')
->limit(10)
->get()->getResultArray();
->limit(10);
if ($schoolYear !== '') {
$paymentBuilder->where('i.school_year', $schoolYear);
}
$payRows = $paymentBuilder->get()->getResultArray();
foreach ($payRows as &$paymentRow) {
$paymentParentId = (int) ($paymentRow['parent_id'] ?? 0);
$paymentRow['parent_name'] = $gmap[$paymentParentId] ?? ('Parent #' . $paymentParentId);
}
unset($paymentRow);
$family['payments'] = $payRows;
}
return service('response')->setBody(view('family/card', ['f' => $family]));
}
private function canViewFamilyInvoices(): bool
{
$roles = array_map(
static fn($role): string => strtolower(trim((string) $role)),
array_filter(array_merge((array) session()->get('roles'), [session()->get('role')]))
);
return (bool) array_intersect(array_unique($roles), [
'admin',
'administrator',
'administrative staff',
'principal',
]);
}
public function composeEmail()
{
$to = trim((string)$this->request->getGet('to'));
@@ -0,0 +1,122 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateStudentAssessmentTables extends Migration
{
public function up()
{
if (! $this->db->tableExists('question_pools')) {
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 150],
'subject_tag' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('subject_tag');
$this->forge->createTable('question_pools', true);
}
if (! $this->db->tableExists('assessment_questions')) {
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'pool_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'type' => ['type' => 'ENUM', 'constraint' => ['multiple_choice', 'short_answer', 'true_false', 'essay']],
'text' => ['type' => 'TEXT'],
'options' => ['type' => 'TEXT', 'null' => true],
'correct_answer' => ['type' => 'TEXT', 'null' => true],
'points' => ['type' => 'DECIMAL', 'constraint' => '8,2', 'default' => 0],
'order_index' => ['type' => 'INT', 'constraint' => 11, 'default' => 0],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['pool_id', 'order_index']);
$this->forge->addForeignKey('pool_id', 'question_pools', 'id', 'CASCADE', 'CASCADE');
$this->forge->createTable('assessment_questions', true);
}
if (! $this->db->tableExists('assessment_forms')) {
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'name' => ['type' => 'VARCHAR', 'constraint' => 150],
'pool_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'status' => ['type' => 'ENUM', 'constraint' => ['draft', 'published', 'archived'], 'default' => 'draft'],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['pool_id', 'status']);
$this->forge->addForeignKey('pool_id', 'question_pools', 'id', 'RESTRICT', 'CASCADE');
$this->forge->createTable('assessment_forms', true);
}
if (! $this->db->tableExists('assessment_form_questions')) {
$this->forge->addField([
'form_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'question_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'order_index' => ['type' => 'INT', 'constraint' => 11, 'default' => 0],
]);
$this->forge->addKey(['form_id', 'question_id'], true);
$this->forge->addKey(['form_id', 'order_index']);
$this->forge->addForeignKey('form_id', 'assessment_forms', 'id', 'CASCADE', 'CASCADE');
$this->forge->addForeignKey('question_id', 'assessment_questions', 'id', 'RESTRICT', 'CASCADE');
$this->forge->createTable('assessment_form_questions', true);
}
if (! $this->db->tableExists('student_assessments')) {
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'form_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'status' => ['type' => 'ENUM', 'constraint' => ['not_started', 'in_progress', 'completed', 'graded'], 'default' => 'not_started'],
'assigned_at' => ['type' => 'DATETIME', 'null' => true],
'started_at' => ['type' => 'DATETIME', 'null' => true],
'submitted_at' => ['type' => 'DATETIME', 'null' => true],
'graded_at' => ['type' => 'DATETIME', 'null' => true],
'graded_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'score' => ['type' => 'DECIMAL', 'constraint' => '10,2', 'null' => true],
'education_committee_note' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['form_id', 'student_id'], 'uq_assessment_form_student');
$this->forge->addKey(['student_id', 'status']);
$this->forge->addForeignKey('form_id', 'assessment_forms', 'id', 'RESTRICT', 'CASCADE');
$this->forge->addForeignKey('student_id', 'students', 'id', 'CASCADE', 'CASCADE');
$this->forge->createTable('student_assessments', true);
}
if (! $this->db->tableExists('student_answers')) {
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'student_assessment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'question_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'answer_value' => ['type' => 'TEXT', 'null' => true],
'is_correct' => ['type' => 'TINYINT', 'constraint' => 1, 'null' => true],
'points_awarded' => ['type' => 'DECIMAL', 'constraint' => '8,2', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['student_assessment_id', 'question_id'], 'uq_student_answer_question');
$this->forge->addForeignKey('student_assessment_id', 'student_assessments', 'id', 'CASCADE', 'CASCADE');
$this->forge->addForeignKey('question_id', 'assessment_questions', 'id', 'RESTRICT', 'CASCADE');
$this->forge->createTable('student_answers', true);
}
}
public function down()
{
foreach (['student_answers', 'student_assessments', 'assessment_form_questions', 'assessment_forms', 'assessment_questions', 'question_pools'] as $table) {
$this->forge->dropTable($table, true);
}
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddAssessmentNavigation extends Migration
{
private const ITEMS = [
'administrator/assessments' => 'Assessment Management',
'student/assessments' => 'My Assessments',
];
public function up(): void
{
if (! $this->db->tableExists('nav_items')) return;
$parentColumn = $this->parentColumn();
$studentAffairsId = null;
if ($parentColumn !== null) {
$parent = $this->db->table('nav_items')->select('id')->where('label', 'Student-Affairs')->where($parentColumn, null)->get()->getRowArray();
$studentAffairsId = $parent ? (int) $parent['id'] : null;
}
$adminId = $this->upsertItem(self::ITEMS['administrator/assessments'], 'administrator/assessments', $studentAffairsId, 7);
$studentId = $this->upsertItem(self::ITEMS['student/assessments'], 'student/assessments', null, 80);
if ($this->db->tableExists('role_nav_items') && $this->db->tableExists('roles')) {
$roles = $this->db->table('roles')->select('id, name')->get()->getResultArray();
foreach ($roles as $role) {
$token = strtolower(str_replace([' ', '-'], '_', trim((string) ($role['name'] ?? ''))));
if (in_array($token, ['parent', 'student'], true)) {
$this->grant((int) $role['id'], $studentId);
} elseif (! in_array($token, ['guest', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta'], true)) {
$this->grant((int) $role['id'], $adminId);
}
}
}
cache()->clean();
}
public function down(): void
{
if (! $this->db->tableExists('nav_items')) return;
foreach (array_keys(self::ITEMS) as $url) {
$row = $this->db->table('nav_items')->select('id')->where('url', $url)->get()->getRowArray();
if ($row && $this->db->tableExists('role_nav_items')) {
$this->db->table('role_nav_items')->where('nav_item_id', (int) $row['id'])->delete();
}
$this->db->table('nav_items')->where('url', $url)->delete();
}
cache()->clean();
}
private function upsertItem(string $label, string $url, ?int $parentId, int $sort): int
{
$existing = $this->db->table('nav_items')->select('id')->where('url', $url)->get()->getRowArray();
$data = ['label' => $label, 'is_enabled' => 1, 'sort_order' => $sort, 'updated_at' => date('Y-m-d H:i:s')];
$parentColumn = $this->parentColumn();
if ($parentColumn !== null) $data[$parentColumn] = $parentId;
if ($this->db->fieldExists('icon_class', 'nav_items')) $data['icon_class'] = 'bi bi-ui-checks-grid';
if ($existing) {
$this->db->table('nav_items')->where('id', (int) $existing['id'])->update($data);
return (int) $existing['id'];
}
$data += ['url' => $url, 'created_at' => date('Y-m-d H:i:s')];
$this->db->table('nav_items')->insert($data);
return (int) $this->db->insertID();
}
private function grant(int $roleId, int $navItemId): void
{
if ($roleId <= 0 || $navItemId <= 0) return;
$builder = $this->db->table('role_nav_items');
if ($builder->where(['role_id' => $roleId, 'nav_item_id' => $navItemId])->countAllResults() > 0) return;
$data = ['role_id' => $roleId, 'nav_item_id' => $navItemId];
if ($this->db->fieldExists('created_at', 'role_nav_items')) $data['created_at'] = date('Y-m-d H:i:s');
if ($this->db->fieldExists('updated_at', 'role_nav_items')) $data['updated_at'] = date('Y-m-d H:i:s');
$this->db->table('role_nav_items')->insert($data);
}
private function parentColumn(): ?string
{
if ($this->db->fieldExists('menu_parent_id', 'nav_items')) return 'menu_parent_id';
if ($this->db->fieldExists('parent_id', 'nav_items')) return 'parent_id';
return null;
}
}
@@ -0,0 +1,129 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class SeedNewStudentAssessmentQuestions extends Migration
{
private const POOL_NAME = 'New Student Assessment';
private const QUESTIONS = [
[
'type' => 'essay',
'text' => 'What is the students Islamic education background?',
],
[
'type' => 'short_answer',
'text' => 'What is the students Arabic competency level?',
],
[
'type' => 'short_answer',
'text' => 'What is the students English competency level?',
],
[
'type' => 'essay',
'text' => 'Has the student attended any Sunday/Islamic school in the past? If YES, which one?',
],
[
'type' => 'essay',
'text' => 'Has the student memorized any Surahs? If yes, which one(s)?',
],
[
'type' => 'essay',
'text' => 'Why have the parents chosen AlRahma Sunday School for their child(ren)?',
],
[
'type' => 'essay',
'text' => 'Any serious/special medical condition about the student which we have to be aware of?',
],
[
'type' => 'essay',
'text' => 'If surnames of enrolled students are different, please confirm if they are all siblings; if NOT, then please specify their relationship to each other:',
],
[
'type' => 'essay',
'text' => 'Any other questions / concerns that the parents may have?',
],
];
public function up(): void
{
if (! $this->db->tableExists('question_pools') || ! $this->db->tableExists('assessment_questions')) {
return;
}
$pool = $this->db->table('question_pools')
->select('id')
->where('name', self::POOL_NAME)
->get()
->getRowArray();
if ($pool === null) {
$now = date('Y-m-d H:i:s');
$this->db->table('question_pools')->insert([
'name' => self::POOL_NAME,
'subject_tag' => 'New Student Intake',
'created_by' => null,
'created_at' => $now,
'updated_at' => $now,
]);
$poolId = (int) $this->db->insertID();
} else {
$poolId = (int) $pool['id'];
}
if ($poolId <= 0) return;
$now = date('Y-m-d H:i:s');
$maxOrder = $this->db->table('assessment_questions')->selectMax('order_index')->where('pool_id', $poolId)->get()->getRowArray();
$nextOrder = ((int) ($maxOrder['order_index'] ?? 0)) + 1;
foreach (self::QUESTIONS as $question) {
$exists = $this->db->table('assessment_questions')
->where('pool_id', $poolId)
->where('text', $question['text'])
->countAllResults() > 0;
if ($exists) continue;
$this->db->table('assessment_questions')->insert([
'pool_id' => $poolId,
'type' => $question['type'],
'text' => $question['text'],
'options' => null,
'correct_answer' => null,
'points' => 0,
'order_index' => $nextOrder++,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
public function down(): void
{
if (! $this->db->tableExists('question_pools') || ! $this->db->tableExists('assessment_questions')) {
return;
}
$pool = $this->db->table('question_pools')->select('id')->where('name', self::POOL_NAME)->get()->getRowArray();
if ($pool === null) return;
$poolId = (int) $pool['id'];
foreach (array_column(self::QUESTIONS, 'text') as $text) {
$question = $this->db->table('assessment_questions')->select('id')->where(['pool_id' => $poolId, 'text' => $text])->get()->getRowArray();
if ($question === null) continue;
if ($this->db->tableExists('assessment_form_questions')
&& $this->db->table('assessment_form_questions')->where('question_id', (int) $question['id'])->countAllResults() > 0) {
continue;
}
$this->db->table('assessment_questions')->where('id', (int) $question['id'])->delete();
}
$hasQuestions = $this->db->table('assessment_questions')->where('pool_id', $poolId)->countAllResults() > 0;
$hasForms = $this->db->tableExists('assessment_forms')
&& $this->db->table('assessment_forms')->where('pool_id', $poolId)->countAllResults() > 0;
if (! $hasQuestions && ! $hasForms) {
$this->db->table('question_pools')->where('id', $poolId)->delete();
}
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddEducationCommitteeNoteToAssessmentForms extends Migration
{
public function up(): void
{
if (! $this->db->tableExists('assessment_forms')
|| $this->db->fieldExists('education_committee_note', 'assessment_forms')) {
return;
}
$this->forge->addColumn('assessment_forms', [
'education_committee_note' => [
'type' => 'TEXT',
'null' => true,
'after' => 'status',
],
]);
}
public function down(): void
{
if ($this->db->tableExists('assessment_forms')
&& $this->db->fieldExists('education_committee_note', 'assessment_forms')) {
$this->forge->dropColumn('assessment_forms', 'education_committee_note');
}
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddSchoolYearToAssessmentForms extends Migration
{
public function up(): void
{
if (! $this->db->tableExists('assessment_forms')) return;
if (! $this->db->fieldExists('school_year', 'assessment_forms')) {
$this->forge->addColumn('assessment_forms', [
'school_year' => [
'type' => 'VARCHAR',
'constraint' => 9,
'null' => true,
'after' => 'pool_id',
],
]);
}
$schoolYear = $this->activeSchoolYear();
if ($schoolYear !== null) {
$this->db->table('assessment_forms')
->groupStart()->where('school_year', null)->orWhere('school_year', '')->groupEnd()
->update(['school_year' => $schoolYear]);
}
}
public function down(): void
{
if ($this->db->tableExists('assessment_forms') && $this->db->fieldExists('school_year', 'assessment_forms')) {
$this->forge->dropColumn('assessment_forms', 'school_year');
}
}
private function activeSchoolYear(): ?string
{
if ($this->db->tableExists('school_years')) {
$row = $this->db->table('school_years')->select('name')->where('status', 'active')->orderBy('id', 'DESC')->get()->getRowArray();
$name = trim((string) ($row['name'] ?? ''));
if (preg_match('/^\d{4}-\d{4}$/', $name)) return $name;
}
if ($this->db->tableExists('configuration')) {
$row = $this->db->table('configuration')->select('config_value')->where('config_key', 'school_year')->orderBy('id', 'DESC')->get()->getRowArray();
$name = trim((string) ($row['config_value'] ?? ''));
if (preg_match('/^\d{4}-\d{4}$/', $name)) return $name;
}
return null;
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddEducationCommitteeNoteToStudentAssessments extends Migration
{
public function up(): void
{
if (! $this->db->tableExists('student_assessments')
|| $this->db->fieldExists('education_committee_note', 'student_assessments')) {
return;
}
$this->forge->addColumn('student_assessments', [
'education_committee_note' => [
'type' => 'TEXT',
'null' => true,
'after' => 'score',
],
]);
}
public function down(): void
{
if ($this->db->tableExists('student_assessments')
&& $this->db->fieldExists('education_committee_note', 'student_assessments')) {
$this->forge->dropColumn('student_assessments', 'education_committee_note');
}
}
}
+3
View File
@@ -163,6 +163,9 @@ class AuthFilter implements FilterInterface
foreach ($alternatives as $alternative) {
$candidate = strtolower($alternative);
if ($candidate === 'admin_category' && (bool) session()->get('is_admin')) {
return true;
}
if (in_array($candidate, $normalizedRoles, true)) {
return true;
}
+3
View File
@@ -29,6 +29,9 @@ final class SchoolYearWritableFilter implements FilterInterface
'user/select_role',
'set-role',
'parent_dashboard/job-openings-popup',
// Assessments are globally shared and are not tied to a school-year record.
'administrator/assessments',
'student/assessments',
'processForgotPassword',
'user/forgot_password',
'user/processResetPassword',
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class AssessmentFormModel extends Model
{
protected $table = 'assessment_forms';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $allowedFields = ['name', 'pool_id', 'school_year', 'created_by', 'status', 'education_committee_note'];
protected $useTimestamps = true;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class AssessmentFormQuestionModel extends Model
{
protected $table = 'assessment_form_questions';
// CI's Model API accepts one primary-key field; the database enforces the
// actual composite key (form_id, question_id).
protected $primaryKey = 'form_id';
protected $useAutoIncrement = false;
protected $returnType = 'array';
protected $allowedFields = ['form_id', 'question_id', 'order_index'];
protected $useTimestamps = false;
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class AssessmentQuestionModel extends Model
{
protected $table = 'assessment_questions';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $allowedFields = ['pool_id', 'type', 'text', 'options', 'correct_answer', 'points', 'order_index'];
protected $useTimestamps = true;
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class QuestionPoolModel extends Model
{
protected $table = 'question_pools';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $allowedFields = ['name', 'subject_tag', 'created_by'];
protected $useTimestamps = true;
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class StudentAnswerModel extends Model
{
protected $table = 'student_answers';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $allowedFields = ['student_assessment_id', 'question_id', 'answer_value', 'is_correct', 'points_awarded'];
protected $useTimestamps = true;
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class StudentAssessmentModel extends Model
{
protected $table = 'student_assessments';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $allowedFields = ['form_id', 'student_id', 'status', 'assigned_at', 'started_at', 'submitted_at', 'graded_at', 'graded_by', 'score', 'education_committee_note'];
protected $useTimestamps = true;
}
+235
View File
@@ -0,0 +1,235 @@
<?php
namespace App\Services;
use CodeIgniter\Database\BaseConnection;
final class StudentScoreHistoryService
{
public function __construct(private readonly BaseConnection $db)
{
}
/**
* Build one score-history row per student and school year.
*
* @return array<int, array<int, array<string, mixed>>>
*/
public function forStudents(array $studentIds): array
{
$studentIds = array_values(array_unique(array_filter(
array_map('intval', $studentIds),
static fn(int $id): bool => $id > 0
)));
if ($studentIds === []) {
return [];
}
$history = [];
$semesterRows = $this->latestSemesterRows($studentIds);
foreach ($semesterRows as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
$schoolYear = trim((string) ($row['school_year'] ?? ''));
$semester = $this->normalizeSemester($row['semester'] ?? '');
if ($studentId <= 0 || $schoolYear === '' || !in_array($semester, ['fall', 'spring'], true)) {
continue;
}
$history[$studentId][$schoolYear] ??= $this->emptyYear($schoolYear);
if ($semester === 'fall') {
$history[$studentId][$schoolYear]['midterm_s1'] = $this->score($row['midterm_exam_score'] ?? null);
$history[$studentId][$schoolYear]['ptap_s1'] = $this->score($row['ptap_score'] ?? null);
$history[$studentId][$schoolYear]['attendance_s1'] = $this->score($row['attendance_score'] ?? null);
$history[$studentId][$schoolYear]['_semester_score_s1'] = $this->score($row['semester_score'] ?? null);
} else {
$finalScore = $this->score($row['final_exam_score'] ?? null);
if ($finalScore === null) {
// Some older Spring rows stored the final in this legacy column.
$finalScore = $this->score($row['midterm_exam_score'] ?? null);
}
$history[$studentId][$schoolYear]['final_s2'] = $finalScore;
$history[$studentId][$schoolYear]['ptap_s2'] = $this->score($row['ptap_score'] ?? null);
$history[$studentId][$schoolYear]['attendance_s2'] = $this->score($row['attendance_score'] ?? null);
$history[$studentId][$schoolYear]['_semester_score_s2'] = $this->score($row['semester_score'] ?? null);
}
}
foreach ($this->commentRows($studentIds) as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
$schoolYear = trim((string) ($row['school_year'] ?? ''));
$semester = $this->normalizeSemester($row['semester'] ?? '');
$type = $this->normalizeCommentType($row['score_type'] ?? '');
if ($studentId <= 0 || $schoolYear === '') {
continue;
}
$history[$studentId][$schoolYear] ??= $this->emptyYear($schoolYear);
$field = match ($semester . ':' . $type) {
'fall:midterm' => 'midterm_comment_s1',
'fall:ptap' => 'ptap_comment_s1',
'fall:attendance' => 'attendance_comment_s1',
'spring:final' => 'final_comment_s2',
'spring:ptap' => 'ptap_comment_s2',
'spring:attendance' => 'attendance_comment_s2',
default => null,
};
if ($field === null || $history[$studentId][$schoolYear][$field] !== '') {
continue;
}
$comment = trim((string) ($row['comment'] ?? ''));
$review = trim((string) ($row['comment_review'] ?? ''));
$history[$studentId][$schoolYear][$field] = $type === 'attendance'
? ($comment !== '' ? $comment : $review)
: ($review !== '' ? $review : $comment);
}
foreach ($this->decisionRows($studentIds) as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
$schoolYear = trim((string) ($row['school_year'] ?? ''));
if ($studentId <= 0 || $schoolYear === '') {
continue;
}
$history[$studentId][$schoolYear] ??= $this->emptyYear($schoolYear);
if ($history[$studentId][$schoolYear]['year_score'] === null) {
$history[$studentId][$schoolYear]['year_score'] = $this->score($row['year_score'] ?? null);
}
}
foreach ($history as &$studentHistory) {
foreach ($studentHistory as &$year) {
if ($year['year_score'] === null) {
$semesterScores = array_values(array_filter(
[$year['_semester_score_s1'], $year['_semester_score_s2']],
static fn($score): bool => $score !== null
));
if ($semesterScores !== []) {
$year['year_score'] = round(array_sum($semesterScores) / count($semesterScores), 1);
}
}
unset($year['_semester_score_s1'], $year['_semester_score_s2']);
}
unset($year);
krsort($studentHistory, SORT_STRING);
$studentHistory = array_values($studentHistory);
}
unset($studentHistory);
return $history;
}
private function latestSemesterRows(array $studentIds): array
{
if (!$this->db->tableExists('semester_scores')) {
return [];
}
$rows = $this->db->table('semester_scores')
->select('id, student_id, school_year, semester, midterm_exam_score, final_exam_score, ptap_score, attendance_score, semester_score, updated_at')
->whereIn('student_id', $studentIds)
->orderBy('school_year', 'DESC')
->orderBy('updated_at', 'DESC')
->orderBy('id', 'DESC')
->get()
->getResultArray();
$latest = [];
foreach ($rows as $row) {
$key = (int) ($row['student_id'] ?? 0)
. '|' . trim((string) ($row['school_year'] ?? ''))
. '|' . $this->normalizeSemester($row['semester'] ?? '');
$latest[$key] ??= $row;
}
return array_values($latest);
}
private function commentRows(array $studentIds): array
{
if (!$this->db->tableExists('score_comments')) {
return [];
}
$builder = $this->db->table('score_comments')
->select('id, student_id, school_year, semester, score_type, comment, comment_review')
->whereIn('student_id', $studentIds);
if ($this->db->fieldExists('updated_at', 'score_comments')) {
$builder->orderBy('updated_at', 'DESC');
} elseif ($this->db->fieldExists('created_at', 'score_comments')) {
$builder->orderBy('created_at', 'DESC');
}
return $builder->orderBy('id', 'DESC')->get()->getResultArray();
}
private function decisionRows(array $studentIds): array
{
if (!$this->db->tableExists('student_decisions')) {
return [];
}
return $this->db->table('student_decisions')
->select('id, student_id, school_year, year_score')
->whereIn('student_id', $studentIds)
->orderBy('school_year', 'DESC')
->orderBy('updated_at', 'DESC')
->orderBy('id', 'DESC')
->get()
->getResultArray();
}
private function emptyYear(string $schoolYear): array
{
return [
'school_year' => $schoolYear,
'midterm_s1' => null,
'midterm_comment_s1' => '',
'ptap_s1' => null,
'ptap_comment_s1' => '',
'attendance_s1' => null,
'attendance_comment_s1' => '',
'final_s2' => null,
'final_comment_s2' => '',
'ptap_s2' => null,
'ptap_comment_s2' => '',
'attendance_s2' => null,
'attendance_comment_s2' => '',
'year_score' => null,
'_semester_score_s1' => null,
'_semester_score_s2' => null,
];
}
private function normalizeSemester($value): string
{
$semester = strtolower(trim((string) $value));
return match ($semester) {
'fall', 'first', 'first semester', 'semester 1', '1' => 'fall',
'spring', 'second', 'second semester', 'semester 2', '2' => 'spring',
default => $semester,
};
}
private function normalizeCommentType($value): string
{
$type = strtolower(trim((string) $value));
$type = trim((string) preg_replace('/[^a-z0-9]+/', '_', $type), '_');
return match ($type) {
'midterm_comment', 'midterm_comments' => 'midterm',
'final_comment', 'final_comments' => 'final',
'ptap_comment', 'ptap_comments' => 'ptap',
'attendance_comment', 'attendance_comments', 'attendence', 'attendence_comment' => 'attendance',
default => $type,
};
}
private function score($value): ?float
{
return $value !== null && $value !== '' && is_numeric($value)
? round((float) $value, 2)
: null;
}
}
+17 -2
View File
@@ -91,6 +91,7 @@ $selectedYear = trim((string)($selectedYear ?? ''));
<th>Allergies</th>
<th>Photo Consent</th>
<th>Registration Date</th>
<th style="min-width: 170px;">Assessment</th>
<th style="min-width: 220px;">Action</th>
</tr>
</thead>
@@ -160,13 +161,13 @@ $selectedYear = trim((string)($selectedYear ?? ''));
<tr>
<td><?= esc($student['school_id']) ?></td>
<td>
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)($student['id'] ?? 0) ?>">
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)($student['id'] ?? 0) ?>" data-family-school-year="<?= esc($selectedYear) ?>">
<?= esc($student['firstname']) ?>
</a>
<?= student_enrollment_status_button($student, $selectedYear) ?>
</td>
<td>
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)($student['id'] ?? 0) ?>">
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)($student['id'] ?? 0) ?>" data-family-school-year="<?= esc($selectedYear) ?>">
<?= esc($student['lastname']) ?>
</a>
</td>
@@ -179,6 +180,20 @@ $selectedYear = trim((string)($selectedYear ?? ''));
<td><?= esc($student['allergies'] ?? '') ?></td>
<td><?= !empty($student['photo_consent']) ? 'Yes' : 'No' ?></td>
<td><?= esc($regDisp) ?></td>
<td>
<?php $studentAssessment = ($assessmentByStudent ?? [])[(int)$student['id']] ?? null; ?>
<?php if (!$studentAssessment): ?>
<form method="post" action="<?= site_url('administrator/assessments/students/' . $student['id'] . '/start') ?>"><?= csrf_field() ?><button class="btn btn-sm btn-outline-primary">Assign Assessment</button></form>
<?php elseif (in_array($studentAssessment['status'], ['not_started', 'in_progress'], true)): ?>
<a class="btn btn-sm btn-outline-secondary" href="<?= site_url('administrator/assessments/students/' . $student['id']) ?>">View Status</a>
<div class="small text-muted mt-1"><?= esc(ucwords(str_replace('_', ' ', $studentAssessment['status']))) ?></div>
<?php elseif ($studentAssessment['status'] === 'completed'): ?>
<a class="btn btn-sm btn-warning" href="<?= site_url('administrator/assessments/review/' . $studentAssessment['id']) ?>">Review</a>
<?php else: ?>
<a class="btn btn-sm btn-success" href="<?= site_url('administrator/assessments/results/' . $studentAssessment['id']) ?>">View Results</a>
<div class="small text-muted mt-1">Reviewed</div>
<?php endif; ?>
</td>
<td class="d-flex flex-wrap gap-2">
<!-- Contact Modal Trigger -->
<button class="btn btn-warning btn-sm" data-bs-toggle="modal" data-bs-target="#<?= $modalIdContact ?>">
+11
View File
@@ -0,0 +1,11 @@
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="alert alert-danger"><ul class="mb-0">
<?php foreach ((array) session()->getFlashdata('errors') as $error): ?><li><?= esc($error) ?></li><?php endforeach; ?>
</ul></div>
<?php endif; ?>
+21
View File
@@ -0,0 +1,21 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container py-4" style="max-width:900px">
<div class="d-flex justify-content-between align-items-start mb-3"><div><h2><?= esc($assessment['form_name']) ?></h2><p class="text-muted mb-0">Interviewing <?= esc($assessment['firstname'].' '.$assessment['lastname']) ?> · <?= esc($assessment['school_year']) ?></p></div><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/students') ?>">New Students</a></div>
<?= $this->include('assessments/_alerts') ?>
<?php if(!empty($assessment['form_education_committee_note'])): ?><div class="alert alert-info"><strong>Form instructions</strong><div class="mt-1" style="white-space:pre-wrap"><?= esc($assessment['form_education_committee_note']) ?></div></div><?php endif; ?>
<div id="saveNotice" class="alert alert-light border py-2">Responses save automatically as you type.</div>
<form id="adminAssessmentForm" method="post" action="<?= site_url('administrator/assessments/attempts/'.$assessment['id'].'/complete') ?>"><?= csrf_field() ?>
<?php foreach($questions as $index=>$q): ?><div class="card shadow-sm mb-3"><div class="card-body"><label class="form-label fw-semibold" for="answer-<?= (int)$q['id'] ?>"><?= $index+1 ?>. <?= esc($q['text']) ?></label>
<?php if($q['type']==='short_answer'): ?><input id="answer-<?= (int)$q['id'] ?>" class="form-control assessment-answer" name="answers[<?= (int)$q['id'] ?>]" value="<?= esc($q['answer_value']??'') ?>" autocomplete="off">
<?php else: ?><textarea id="answer-<?= (int)$q['id'] ?>" class="form-control assessment-answer" name="answers[<?= (int)$q['id'] ?>]" rows="4"><?= esc($q['answer_value']??'') ?></textarea><?php endif; ?>
</div></div><?php endforeach; ?>
<div class="card shadow-sm mb-3"><div class="card-body"><label class="form-label fw-semibold" for="educationCommitteeNote">Education Committee Note</label><textarea id="educationCommitteeNote" class="form-control assessment-answer" name="education_committee_note" rows="5" maxlength="10000" placeholder="Add the Education Committee's note for this student."><?= esc($assessment['education_committee_note'] ?? '') ?></textarea><div class="form-text">This note is saved automatically with the responses.</div></div></div>
<button class="btn btn-success" onclick="return confirm('Complete this assessment and review the responses?')">Complete Assessment</button>
</form>
</div>
<script>(function(){var form=document.getElementById('adminAssessmentForm'),notice=document.getElementById('saveNotice'),timer=null,savePromise=null,pending=false,completing=false;
async function save(){if(savePromise){pending=true;return savePromise}savePromise=(async function(){notice.textContent='Saving…';try{var response=await fetch('<?= site_url('administrator/assessments/attempts/'.$assessment['id'].'/progress') ?>',{method:'POST',body:new FormData(form),headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}}),data=await response.json();if(!response.ok)throw new Error(data.message||'Save failed');if(data.csrfName&&data.csrfHash){var token=form.querySelector('input[name="'+data.csrfName+'"]');if(token)token.value=data.csrfHash}notice.textContent='All responses saved.'}catch(e){notice.textContent='Autosave failed. Keep this page open and continue typing while it retries.'}})();await savePromise;savePromise=null;if(pending){pending=false;return save()}}
document.querySelectorAll('.assessment-answer').forEach(function(field){field.addEventListener('input',function(){clearTimeout(timer);timer=setTimeout(save,700)});field.addEventListener('change',save)});
form.addEventListener('submit',async function(event){if(completing)return;event.preventDefault();clearTimeout(timer);await save();completing=true;form.submit()});})();</script>
<?= $this->endSection() ?>
+32
View File
@@ -0,0 +1,32 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
$isEdit = !empty($form);
$singlePool = count($pools) === 1;
$action = $isEdit ? site_url('administrator/assessments/forms/' . $form['id']) : site_url('administrator/assessments/forms');
$yearNames = array_column($schoolYears ?? [], 'name');
if ($selectedSchoolYear !== '' && !in_array($selectedSchoolYear, $yearNames, true)) $schoolYears[] = ['name' => $selectedSchoolYear, 'status' => ''];
?>
<div class="container py-3" style="max-width:1000px">
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2><?= $isEdit ? 'Edit' : 'Create' ?> Assessment Form</h2><p class="text-muted mb-0">Choose questions from one shared pool and set their order.</p></div><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/forms') ?>">Back</a></div>
<?= $this->include('assessments/_alerts') ?>
<?php if ($locked): ?><div class="alert alert-info">This form has student assignments. Its school year, pool, and question list are locked, but its name, note, and status can still be changed.</div><?php endif; ?>
<form method="post" action="<?= $action ?>"><?= csrf_field() ?>
<div class="card shadow-sm mb-3"><div class="card-body"><div class="row g-3">
<div class="col-md-4"><label class="form-label">Form name</label><input class="form-control" name="name" maxlength="150" required value="<?= set_value('name', $form['name'] ?? '') ?>"></div>
<div class="col-md-3"><label class="form-label">Question pool</label><select id="poolSelect" class="form-select" name="pool_id" required <?= ($isEdit || $singlePool) ? 'disabled' : '' ?>><option value="">Select a pool</option><?php foreach ($pools as $pool): ?><option value="<?= (int)$pool['id'] ?>" <?= ($singlePool || (int)($form['pool_id'] ?? 0)===(int)$pool['id']) ? 'selected' : '' ?>><?= esc($pool['name']) ?></option><?php endforeach; ?></select><?php if ($isEdit || $singlePool): ?><input type="hidden" name="pool_id" value="<?= (int)($form['pool_id'] ?? $pools[0]['id'] ?? 0) ?>"><?php endif; ?></div>
<div class="col-md-3"><label class="form-label">School year</label><select class="form-select" name="school_year" required <?= $locked ? 'disabled' : '' ?>><option value="">Select school year</option><?php foreach (($schoolYears ?? []) as $year): ?><option value="<?= esc($year['name']) ?>" <?= set_select('school_year', $year['name'], $selectedSchoolYear === $year['name']) ?>><?= esc($year['name']) ?><?= !empty($year['status']) ? ' — '.esc(ucfirst($year['status'])) : '' ?></option><?php endforeach; ?></select><?php if($locked): ?><input type="hidden" name="school_year" value="<?= esc($selectedSchoolYear) ?>"><?php endif; ?></div>
<div class="col-md-2"><label class="form-label">Status</label><select class="form-select" name="status"><option value="draft" <?= ($form['status'] ?? 'draft')==='draft'?'selected':'' ?>>Draft</option><option value="published" <?= ($form['status'] ?? '')==='published'?'selected':'' ?>>Published</option><option value="archived" <?= ($form['status'] ?? '')==='archived'?'selected':'' ?>>Archived</option></select></div>
<div class="col-12"><label class="form-label">Education Committee Note <span class="text-muted">(optional, admin only)</span></label><textarea class="form-control" name="education_committee_note" rows="3" maxlength="10000" placeholder="Add context or instructions for the Education Committee."><?= set_value('education_committee_note', $form['education_committee_note'] ?? '') ?></textarea></div>
</div></div></div>
<?php if (!$isEdit): ?><div id="newFormHint" class="alert alert-secondary">Pick individual questions or select “Use all questions.”</div><?php endif; ?>
<div class="card shadow-sm"><div class="card-header d-flex justify-content-between"><strong>Questions</strong><?php if (!$locked): ?><label><input type="checkbox" name="use_all" value="1"> Use all questions</label><?php endif; ?></div><div class="card-body">
<?php if (empty($questions)): ?><p class="text-muted">There are no questions available. Add questions to a pool first.</p><?php endif; ?>
<p id="poolQuestionHint" class="text-muted <?= $isEdit ? 'd-none' : '' ?>">Select a pool to see its questions.</p>
<?php foreach ($questions as $index => $question): ?><div class="border rounded p-3 mb-2 question-choice" data-pool-id="<?= (int)$question['pool_id'] ?>"><div class="row align-items-start"><div class="col-auto"><input class="form-check-input" type="checkbox" name="question_ids[]" value="<?= (int)$question['id'] ?>" <?= in_array((int)$question['id'], $selectedIds, true)?'checked':'' ?> <?= $locked?'disabled':'' ?>></div><div class="col"><div><?= esc($question['text']) ?></div><small class="text-muted"><?= esc(str_replace('_',' ',ucfirst($question['type']))) ?></small></div><div class="col-auto"><label class="small">Order</label><input style="width:80px" class="form-control form-control-sm" type="number" min="1" name="order_<?= (int)$question['id'] ?>" value="<?= ($pos=array_search((int)$question['id'],$selectedIds,true))!==false ? $pos+1 : $index+1 ?>" <?= $locked?'disabled':'' ?>></div></div></div><?php endforeach; ?>
</div></div>
<button class="btn btn-primary mt-3" <?= $isEdit && empty($questions) && !$locked ? 'disabled' : '' ?>>Save Form</button>
</form>
</div>
<?php if (!$isEdit): ?><script>(function(){var select=document.getElementById('poolSelect'),hint=document.getElementById('poolQuestionHint');function sync(){var pool=select.value,shown=0;document.querySelectorAll('.question-choice').forEach(function(row){var active=pool!==''&&row.dataset.poolId===pool;row.classList.toggle('d-none',!active);row.querySelectorAll('input').forEach(function(input){input.disabled=!active;if(!active&&input.type==='checkbox')input.checked=false});if(active)shown++});hint.textContent=pool===''?'Select a pool to see its questions.':(shown?'':'This pool has no questions.');hint.classList.toggle('d-none',shown>0)}select.addEventListener('change',sync);sync()})();</script><?php endif; ?>
<?= $this->endSection() ?>
+55
View File
@@ -0,0 +1,55 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid py-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div><h2 class="mb-1">Assessment Forms</h2><p class="text-muted mb-0">Build, publish, preview, and track individual assessments.</p></div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/students') ?>">New Students</a>
<a class="btn btn-outline-primary" href="<?= site_url('administrator/assessments/pools') ?>">Question Pool</a>
<a class="btn btn-primary" href="<?= site_url('administrator/assessments/forms/new') ?>">Create Form</a>
</div>
</div>
<?= $this->include('assessments/_alerts') ?>
<div class="card border-primary-subtle shadow-sm mb-3">
<div class="card-header bg-primary-subtle"><strong>How to use assessments</strong></div>
<div class="card-body">
<div class="row g-3 small">
<div class="col-md"><span class="badge bg-primary me-1">1</span><strong>Questions</strong><div class="text-muted mt-1">Add or reorder questions in the shared <a href="<?= site_url('administrator/assessments/pools') ?>">Question Pool</a>.</div></div>
<div class="col-md"><span class="badge bg-primary me-1">2</span><strong>Create</strong><div class="text-muted mt-1">Create a form, choose its school year and questions, then publish it.</div></div>
<div class="col-md"><span class="badge bg-primary me-1">3</span><strong>Assess</strong><div class="text-muted mt-1">Open <a href="<?= site_url('administrator/assessments/students') ?>">New Students</a>, assign a form, and enter responses. Changes save automatically.</div></div>
<div class="col-md"><span class="badge bg-primary me-1">4</span><strong>Complete</strong><div class="text-muted mt-1">Add the student-specific Education Committee Note and complete the assessment.</div></div>
<div class="col-md"><span class="badge bg-primary me-1">5</span><strong>Review</strong><div class="text-muted mt-1">Review responses and edit the committee note at any time. Assessments are not scored.</div></div>
</div>
</div>
</div>
<div class="card shadow-sm"><div class="card-body"><div class="table-responsive">
<table class="table align-middle no-mgmt-sticky" data-no-mgmt-sticky>
<thead><tr><th>Name</th><th>School Year</th><th>Pool</th><th>Education Committee Note</th><th>Status</th><th>Questions</th><th>Assignments</th><th>Actions</th></tr></thead>
<tbody>
<?php if (empty($forms)): ?><tr><td colspan="8" class="text-muted text-center">No assessment forms yet.</td></tr><?php endif; ?>
<?php foreach ($forms as $form): ?>
<tr>
<td><?= esc($form['name']) ?></td>
<td><?= esc($form['school_year'] ?: '—') ?></td>
<td><?= esc($form['pool_name']) ?></td>
<td style="max-width:280px;white-space:pre-wrap"><?= esc($form['education_committee_note'] ?: '—') ?></td>
<td><span class="badge <?= $form['status'] === 'published' ? 'bg-success' : ($form['status'] === 'archived' ? 'bg-secondary' : 'bg-warning text-dark') ?>"><?= esc(ucfirst($form['status'])) ?></span></td>
<td><?= (int) $form['question_count'] ?></td>
<td><?= (int) $form['assignment_count'] ?></td>
<td><div class="d-flex flex-wrap gap-1">
<a class="btn btn-sm btn-outline-primary" href="<?= site_url('administrator/assessments/forms/' . $form['id'] . '/edit') ?>">Edit</a>
<a class="btn btn-sm btn-outline-secondary" href="<?= site_url('administrator/assessments/forms/' . $form['id'] . '/preview') ?>">Preview</a>
<?php if ($form['status'] !== 'published'): ?>
<form method="post" action="<?= site_url('administrator/assessments/forms/' . $form['id'] . '/publish') ?>" onsubmit="return confirm('Publish this assessment form?')">
<?= csrf_field() ?>
<button class="btn btn-sm btn-success" <?= (int)$form['question_count'] === 0 ? 'disabled title="Add questions before publishing"' : '' ?>>Publish</button>
</form>
<?php endif; ?>
</div></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div></div></div>
</div>
<?= $this->endSection() ?>
+9
View File
@@ -0,0 +1,9 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container py-4" style="max-width:950px"><div class="d-flex justify-content-between"><div><h2>Review: <?= esc($assessment['form_name']) ?></h2><p class="text-muted"><?= esc($assessment['firstname'].' '.$assessment['lastname']) ?> · <?= esc($assessment['school_year']) ?></p></div><a class="btn btn-outline-secondary align-self-start" href="<?= site_url('administrator/assessments/students/'.$assessment['student_id']) ?>">Back</a></div><?= $this->include('assessments/_alerts') ?>
<form method="post" action="<?= site_url('administrator/assessments/review/'.$assessment['id']) ?>"><?= csrf_field() ?>
<?php foreach($questions as $index=>$q): ?><div class="card shadow-sm mb-3"><div class="card-body"><h5><?= $index+1 ?>. <?= esc($q['text']) ?></h5><div class="p-3 bg-light rounded"><strong>Response</strong><div class="mt-1" style="white-space:pre-wrap"><?= esc($q['answer_value'] ?? 'No answer') ?></div></div></div></div><?php endforeach; ?>
<div class="card shadow-sm mb-3"><div class="card-body"><label class="form-label fw-semibold" for="reviewCommitteeNote">Education Committee Note</label><textarea id="reviewCommitteeNote" class="form-control" name="education_committee_note" rows="5" maxlength="10000"><?= esc($assessment['education_committee_note'] ?? '') ?></textarea><div id="noteSaveNotice" class="form-text">This note remains editable and saves automatically.</div></div></div>
<button class="btn btn-success">Complete Review</button></form></div>
<script>(function(){var form=document.querySelector('form[action*="/review/"]'),field=document.getElementById('reviewCommitteeNote'),notice=document.getElementById('noteSaveNotice'),timer;if(!form||!field)return;async function save(){notice.textContent='Saving note…';try{var data=new FormData();var token=form.querySelector('input[type="hidden"]');if(token)data.append(token.name,token.value);data.append('education_committee_note',field.value);var response=await fetch('<?= site_url('administrator/assessments/attempts/'.$assessment['id'].'/note') ?>',{method:'POST',body:data,headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}}),json=await response.json();if(!response.ok)throw new Error(json.message||'Save failed');if(json.csrfName&&json.csrfHash){var csrf=form.querySelector('input[name="'+json.csrfName+'"]');if(csrf)csrf.value=json.csrfHash}notice.textContent='Note saved.'}catch(e){notice.textContent='Note could not be saved. Keep this page open and try typing again.'}}field.addEventListener('input',function(){clearTimeout(timer);timer=setTimeout(save,700)});field.addEventListener('change',save)})();</script>
<?= $this->endSection() ?>
+9
View File
@@ -0,0 +1,9 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container py-4" style="max-width:900px"><h2>My Assessments</h2><p class="text-muted">Open assigned assessments, continue saved work, or review completed responses.</p><?= $this->include('assessments/_alerts') ?>
<div class="row g-3"><?php if(empty($assignments)): ?><div class="col-12"><div class="alert alert-info">There are no assigned assessments.</div></div><?php endif; ?>
<?php foreach($assignments as $a): ?><div class="col-md-6"><div class="card h-100 shadow-sm"><div class="card-body"><h5><?= esc($a['form_name']) ?></h5><p class="mb-1"><?= esc($a['firstname'].' '.$a['lastname']) ?></p><p class="text-muted mb-2"><?= esc($a['school_year'] ?? '—') ?></p><p><span class="badge <?= $a['status']==='graded'?'bg-success':($a['status']==='completed'?'bg-warning text-dark':'bg-primary') ?>"><?= $a['status']==='graded' ? 'Reviewed' : esc(ucwords(str_replace('_',' ',$a['status']))) ?></span></p>
<?php if(in_array($a['status'],['not_started','in_progress'],true)): ?><a class="btn btn-primary" href="<?= site_url('student/assessments/'.$a['id']) ?>"><?= $a['status']==='in_progress'?'Continue':'Start' ?></a><?php elseif($a['status']==='graded'): ?><a class="btn btn-success" href="<?= site_url('student/assessments/'.$a['id'].'/results') ?>">View Responses</a><?php else: ?><span class="text-muted">Awaiting review</span><?php endif; ?>
</div></div></div><?php endforeach; ?></div>
</div>
<?= $this->endSection() ?>
+25
View File
@@ -0,0 +1,25 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid py-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div><h2 class="mb-1">New Student Assessments</h2><p class="text-muted mb-0">New students from the <?= esc($schoolYear) ?> enrollment roster.</p></div>
<a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/forms') ?>">Assessment Forms</a>
</div>
<?= $this->include('assessments/_alerts') ?>
<div class="card shadow-sm"><div class="card-body"><div class="table-responsive">
<table id="assessmentStudentTable" class="table table-striped align-middle no-mgmt-sticky" data-no-mgmt-sticky><thead><tr><th>Student</th><th>School ID</th><th>Age</th><th>Current Class</th><th>Enrollment Status</th><th>Assessment</th></tr></thead><tbody>
<?php if(empty($new_students)): ?><tr><td colspan="6" class="text-center text-muted">No new students found for this school year.</td></tr><?php endif; ?>
<?php foreach($new_students as $student): $sid=(int)$student['id']; $a=($assessmentByStudent??[])[$sid]??null; ?><tr>
<td><?= esc($student['firstname'].' '.$student['lastname']) ?></td><td><?= esc($student['school_id']??'—') ?></td><td><?= esc($student['age']??'—') ?></td><td><?= esc($student['class_section']??'Class not Assigned') ?></td><td><?= esc(ucwords((string)($student['enrollment_status']??'unknown'))) ?></td><td>
<?php if(!$a): ?><form method="post" action="<?= site_url('administrator/assessments/students/'.$sid.'/start') ?>"><?= csrf_field() ?><button class="btn btn-sm btn-primary">Assign Assessment</button></form>
<?php elseif(in_array($a['status'],['not_started','in_progress'],true)): ?><a class="btn btn-sm btn-outline-secondary" href="<?= site_url('administrator/assessments/students/'.$sid) ?>">View Status</a><div class="small text-muted"><?= esc(ucwords(str_replace('_',' ',$a['status']))) ?></div>
<?php elseif($a['status']==='completed'): ?><a class="btn btn-sm btn-warning" href="<?= site_url('administrator/assessments/review/'.$a['id']) ?>">Review</a>
<?php else: ?><a class="btn btn-sm btn-success" href="<?= site_url('administrator/assessments/results/'.$a['id']) ?>">View Results</a><?php endif; ?>
</td></tr><?php endforeach; ?>
</tbody></table>
</div></div></div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>$(function(){if($.fn.DataTable)$('#assessmentStudentTable').DataTable({pageLength:100,order:[[0,'asc']],fixedHeader:false})});</script>
<?= $this->endSection() ?>
+35
View File
@@ -0,0 +1,35 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php $typeLabels = ['multiple_choice' => 'Multiple choice', 'short_answer' => 'Short answer', 'true_false' => 'True / False', 'essay' => 'Essay']; ?>
<div class="container-fluid py-3">
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2><?= esc($pool['name']) ?> Question Pool</h2><p class="text-muted mb-0">Add, edit, delete, or reorder questions in the shared assessment pool.</p></div><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/forms') ?>">Assessment Forms</a></div>
<?= $this->include('assessments/_alerts') ?>
<div class="card shadow-sm mb-4"><div class="card-header"><strong>Add a question</strong></div><div class="card-body">
<form method="post" action="<?= site_url('administrator/assessments/pools/' . $pool['id'] . '/questions') ?>"><?= csrf_field() ?>
<div class="row g-3"><div class="col-md-4"><label class="form-label">Type</label><select class="form-select question-type" name="type" required><?php foreach ($typeLabels as $value => $label): ?><option value="<?= $value ?>"><?= esc($label) ?></option><?php endforeach; ?></select></div>
<div class="col-md-8"><label class="form-label">Question</label><textarea class="form-control" name="text" rows="2" required><?= set_value('text') ?></textarea></div>
<div class="col-md-6 options-wrap"><label class="form-label">Choices <span class="text-muted">(one per line)</span></label><textarea class="form-control" name="options_text" rows="3"></textarea></div>
<div class="col-md-6"><label class="form-label">Correct answer <span class="text-muted">(reference only)</span></label><textarea class="form-control" name="correct_answer" rows="3"></textarea></div></div>
<button class="btn btn-primary mt-3">Add Question</button>
</form>
</div></div>
<?php if (empty($questions)): ?><div class="alert alert-info">This pool has no questions yet.</div><?php endif; ?>
<?php foreach ($questions as $index => $question): ?>
<?php $opts = implode("\n", (array) json_decode((string) ($question['options'] ?? '[]'), true)); ?>
<div class="card shadow-sm mb-3"><div class="card-header d-flex justify-content-between"><span><span class="badge bg-secondary me-2">#<?= $index + 1 ?></span><?= esc($typeLabels[$question['type']] ?? $question['type']) ?></span><span class="d-flex gap-1">
<form method="post" action="<?= site_url('administrator/assessments/questions/' . $question['id'] . '/move') ?>"><?= csrf_field() ?><input type="hidden" name="direction" value="up"><button class="btn btn-sm btn-outline-secondary" aria-label="Move up" <?= $index === 0 ? 'disabled' : '' ?>>↑</button></form>
<form method="post" action="<?= site_url('administrator/assessments/questions/' . $question['id'] . '/move') ?>"><?= csrf_field() ?><input type="hidden" name="direction" value="down"><button class="btn btn-sm btn-outline-secondary" aria-label="Move down" <?= $index === count($questions)-1 ? 'disabled' : '' ?>>↓</button></form>
</span></div><div class="card-body">
<form method="post" action="<?= site_url('administrator/assessments/questions/' . $question['id'] . '/update') ?>"><?= csrf_field() ?>
<div class="row g-3"><div class="col-md-4"><label class="form-label">Type</label><select class="form-select question-type" name="type"><?php foreach ($typeLabels as $value => $label): ?><option value="<?= $value ?>" <?= $question['type'] === $value ? 'selected' : '' ?>><?= esc($label) ?></option><?php endforeach; ?></select></div>
<div class="col-md-8"><label class="form-label">Question</label><textarea class="form-control" name="text" rows="2" required><?= esc($question['text']) ?></textarea></div>
<div class="col-md-6 options-wrap"><label class="form-label">Choices (one per line)</label><textarea class="form-control" name="options_text" rows="3"><?= esc($opts) ?></textarea></div>
<div class="col-md-6"><label class="form-label">Correct answer (reference only)</label><textarea class="form-control" name="correct_answer" rows="3"><?= esc($question['correct_answer'] ?? '') ?></textarea></div></div>
<button class="btn btn-sm btn-primary mt-3">Save Changes</button>
</form>
<form class="d-inline" method="post" action="<?= site_url('administrator/assessments/questions/' . $question['id'] . '/delete') ?>" onsubmit="return confirm('Delete this question?')"><?= csrf_field() ?><button class="btn btn-sm btn-outline-danger mt-2">Delete</button></form>
</div></div>
<?php endforeach; ?>
</div>
<script>document.querySelectorAll('.question-type').forEach(function(s){function sync(){var w=s.closest('form').querySelector('.options-wrap');if(w)w.style.display=s.value==='multiple_choice'?'block':'none'}s.addEventListener('change',sync);sync()});</script>
<?= $this->endSection() ?>
+9
View File
@@ -0,0 +1,9 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container py-4" style="max-width:950px">
<div class="d-flex justify-content-end mb-3"><a class="btn btn-outline-secondary" href="<?= !empty($adminView) ? site_url('administrator/assessments/students') : site_url('student/assessments') ?>"><?= !empty($adminView) ? 'Back to Students' : 'Back to My Assessments' ?></a></div>
<div class="card shadow-sm mb-4"><div class="card-body text-center"><h2><?= esc($assessment['form_name']) ?></h2><p class="mb-1"><?= esc($assessment['firstname'].' '.$assessment['lastname']) ?></p><p class="text-muted mb-2"><?= esc($assessment['school_year'] ?? '—') ?></p><span class="badge bg-success">Reviewed</span></div></div><?= $this->include('assessments/_alerts') ?>
<?php foreach($questions as $index=>$q): ?><div class="card mb-3"><div class="card-body"><h5><?= $index+1 ?>. <?= esc($q['text']) ?></h5><p class="mb-1"><strong>Response:</strong></p><div style="white-space:pre-wrap"><?= esc($q['answer_value'] ?? 'No answer') ?></div></div></div><?php endforeach; ?>
<?php if(!empty($adminView)): ?><div class="card mb-3"><div class="card-body"><form id="resultsCommitteeNoteForm"><?= csrf_field() ?><label class="form-label fw-semibold" for="resultsCommitteeNote">Education Committee Note</label><textarea id="resultsCommitteeNote" class="form-control" name="education_committee_note" rows="5" maxlength="10000"><?= esc($assessment['education_committee_note'] ?? '') ?></textarea><div id="resultsNoteSaveNotice" class="form-text">This note remains editable and saves automatically.</div></form></div></div><?php endif; ?></div>
<?php if(!empty($adminView)): ?><script>(function(){var form=document.getElementById('resultsCommitteeNoteForm'),field=document.getElementById('resultsCommitteeNote'),notice=document.getElementById('resultsNoteSaveNotice'),timer;async function save(){notice.textContent='Saving note…';try{var response=await fetch('<?= site_url('administrator/assessments/attempts/'.$assessment['id'].'/note') ?>',{method:'POST',body:new FormData(form),headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}}),json=await response.json();if(!response.ok)throw new Error(json.message||'Save failed');if(json.csrfName&&json.csrfHash){var csrf=form.querySelector('input[name="'+json.csrfName+'"]');if(csrf)csrf.value=json.csrfHash}notice.textContent='Note saved.'}catch(e){notice.textContent='Note could not be saved. Keep this page open and try typing again.'}}field.addEventListener('input',function(){clearTimeout(timer);timer=setTimeout(save,700)});field.addEventListener('change',save)})();</script><?php endif; ?>
<?= $this->endSection() ?>
@@ -0,0 +1,17 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php $byForm=[]; foreach($assignments as $a)$byForm[(int)$a['form_id']]=$a; ?>
<div class="container py-3" style="max-width:1000px">
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2>Assessments: <?= esc($student['firstname'].' '.$student['lastname']) ?></h2><p class="text-muted mb-0"><?= esc($student['school_id'] ?? '') ?></p></div><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/students') ?>">Back to Students</a></div>
<?= $this->include('assessments/_alerts') ?>
<div class="card shadow-sm mb-4"><div class="card-header"><strong>Assign a published form</strong></div><div class="card-body">
<?php if(empty($forms)): ?><p class="text-muted mb-0">No published assessment forms are available.</p><?php else: ?><div class="table-responsive"><table class="table align-middle no-mgmt-sticky" data-no-mgmt-sticky><thead><tr><th>Form</th><th>Pool</th><th>Questions</th><th>Status / Action</th></tr></thead><tbody>
<?php foreach($forms as $form): $existing=$byForm[(int)$form['id']]??null; ?><tr><td><?= esc($form['name']) ?></td><td><?= esc($form['pool_name']) ?></td><td><?= (int)$form['question_count'] ?></td><td><?php if($existing): ?><span class="badge bg-secondary"><?= esc(ucwords(str_replace('_',' ',$existing['status']))) ?></span><?php else: ?><form method="post" action="<?= site_url('administrator/assessments/students/'.$student['id'].'/assign') ?>"><?= csrf_field() ?><input type="hidden" name="form_id" value="<?= (int)$form['id'] ?>"><button class="btn btn-sm btn-primary">Assign</button></form><?php endif; ?></td></tr><?php endforeach; ?>
</tbody></table></div><?php endif; ?>
</div></div>
<div class="card shadow-sm"><div class="card-header"><strong>Assignment history</strong></div><div class="card-body"><div class="table-responsive"><table class="table align-middle no-mgmt-sticky" data-no-mgmt-sticky><thead><tr><th>Form</th><th>School Year</th><th>Assigned</th><th>Status</th><th>Action</th></tr></thead><tbody>
<?php if(empty($assignments)): ?><tr><td colspan="5" class="text-center text-muted">Nothing assigned yet.</td></tr><?php endif; ?>
<?php foreach($assignments as $a): ?><tr><td><?= esc($a['form_name']) ?></td><td><?= esc($a['school_year'] ?? '—') ?></td><td><?= esc(local_datetime($a['assigned_at'],'m-d-Y H:i')) ?></td><td><?= $a['status']==='graded' ? 'Reviewed' : esc(ucwords(str_replace('_',' ',$a['status']))) ?></td><td><?php if($a['status']==='completed'): ?><a class="btn btn-sm btn-warning" href="<?= site_url('administrator/assessments/review/'.$a['id']) ?>">Review</a><?php elseif($a['status']==='graded'): ?><a class="btn btn-sm btn-success" href="<?= site_url('administrator/assessments/results/'.$a['id']) ?>">View Results</a><?php else: ?><span class="text-muted">View Status</span><?php endif; ?></td></tr><?php endforeach; ?>
</tbody></table></div></div></div>
</div>
<?= $this->endSection() ?>
+22
View File
@@ -0,0 +1,22 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php $preview=$preview??false; $assessment=$assessment??null; $answers=$answers??[]; ?>
<div class="container py-4" style="max-width:900px"><div class="d-flex justify-content-between align-items-center mb-3"><div><h2><?= esc($form['name']) ?></h2><p class="text-muted mb-0"><?= esc($form['school_year'] ?? '—') ?> · <?= count($questions) ?> questions</p></div><?php if($preview): ?><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/forms') ?>">Exit Preview</a><?php endif; ?></div>
<?php if($preview): ?><div class="alert alert-info">Preview mode — answers cannot be saved or submitted.</div><?php else: ?><div id="saveNotice" class="alert alert-light border py-2">Changes are saved as you work.</div><?php endif; ?>
<form id="assessmentForm" method="post" action="<?= !$preview ? site_url('student/assessments/'.$assessment['id'].'/submit') : '#' ?>"><?= csrf_field() ?>
<?php foreach($questions as $index=>$q): $options=(array)json_decode((string)($q['options']??'[]'),true); $value=(string)($answers[$q['id']]??''); ?>
<fieldset class="card shadow-sm mb-3" <?= $preview?'disabled':'' ?>><div class="card-body"><legend class="fs-6 mb-3"><span class="badge bg-secondary me-2"><?= $index+1 ?></span><?= esc($q['text']) ?></legend>
<?php if(in_array($q['type'],['multiple_choice','true_false'],true)): foreach($options as $option): ?><div class="form-check mb-2"><input class="form-check-input assessment-answer" type="radio" name="answers[<?= (int)$q['id'] ?>]" value="<?= esc($option) ?>" <?= $value===(string)$option?'checked':'' ?>><label class="form-check-label"><?= esc($option) ?></label></div><?php endforeach; ?>
<?php elseif($q['type']==='short_answer'): ?><input class="form-control assessment-answer" name="answers[<?= (int)$q['id'] ?>]" value="<?= esc($value) ?>">
<?php else: ?><textarea class="form-control assessment-answer" rows="6" name="answers[<?= (int)$q['id'] ?>]"><?= esc($value) ?></textarea><?php endif; ?>
</div></fieldset>
<?php endforeach; ?>
<?php if(!$preview): ?><div class="d-flex gap-2"><button type="button" id="saveButton" class="btn btn-outline-primary">Save Progress</button><button class="btn btn-success" onclick="return confirm('Submit this assessment? You cannot change answers after submission.')">Submit Assessment</button></div><?php endif; ?>
</form>
</div>
<?php if(!$preview): ?><script>
(function(){var form=document.getElementById('assessmentForm'),notice=document.getElementById('saveNotice'),timer;
async function save(){var fd=new FormData(form);notice.textContent='Saving…';try{var r=await fetch('<?= site_url('student/assessments/'.$assessment['id'].'/progress') ?>',{method:'POST',body:fd,headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}});var j=await r.json();if(!r.ok)throw new Error(j.message||'Save failed');if(j.csrfName&&j.csrfHash){var token=form.querySelector('input[name="'+j.csrfName+'"]');if(token)token.value=j.csrfHash}notice.textContent='Progress saved.'}catch(e){notice.textContent='Could not save automatically. Use Save Progress to retry.'}}
document.querySelectorAll('.assessment-answer').forEach(function(el){el.addEventListener('change',function(){clearTimeout(timer);timer=setTimeout(save,500)});if(el.tagName==='TEXTAREA'||el.type==='text')el.addEventListener('input',function(){clearTimeout(timer);timer=setTimeout(save,1200)})});document.getElementById('saveButton').addEventListener('click',save);
})();</script><?php endif; ?>
<?= $this->endSection() ?>
+20 -60
View File
@@ -19,7 +19,7 @@
<?php endif; ?>
<div class="table-responsive">
<table id="enrollmentTable" class="table table-bordered table-striped mt-4 align-middle w-100">
<table id="enrollmentTable" class="table table-bordered table-striped mt-4 align-middle w-100 no-mgmt-sticky" data-no-mgmt-sticky>
<thead>
<tr>
@@ -30,6 +30,7 @@
<th>Actual Status</th>
<th>Age</th> <!-- NEW -->
<th>Registration Date</th> <!-- NEW -->
<th>Assessment</th>
<th>Action</th>
</tr>
@@ -107,6 +108,20 @@
<td><?= esc($student['age'] ?? '-') ?></td>
<td><?= esc(!empty($student['registration_date']) ? local_date($student['registration_date'], 'm-d-Y') : '-') ?></td>
<td>
<?php $studentAssessment = ($assessmentByStudent ?? [])[$sid] ?? null; ?>
<?php if (!$studentAssessment): ?>
<form method="post" action="<?= site_url('administrator/assessments/students/' . $sid . '/start') ?>"><?= csrf_field() ?><button class="btn btn-sm btn-outline-primary">Assign Assessment</button></form>
<?php elseif (in_array($studentAssessment['status'], ['not_started', 'in_progress'], true)): ?>
<a class="btn btn-sm btn-outline-secondary" href="<?= site_url('administrator/assessments/students/' . $sid) ?>">View Status</a>
<div class="small text-muted mt-1"><?= esc(ucwords(str_replace('_', ' ', $studentAssessment['status']))) ?></div>
<?php elseif ($studentAssessment['status'] === 'completed'): ?>
<a class="btn btn-sm btn-warning" href="<?= site_url('administrator/assessments/review/' . $studentAssessment['id']) ?>">Review</a>
<?php else: ?>
<a class="btn btn-sm btn-success" href="<?= site_url('administrator/assessments/results/' . $studentAssessment['id']) ?>">View Results</a>
<?php endif; ?>
</td>
<td class="d-flex gap-2">
<!-- Contact info modal trigger ONLY -->
<button class="btn btn-warning btn-sm" data-bs-toggle="modal" data-bs-target="#<?= esc($modalIdContact) ?>">
@@ -156,7 +171,7 @@
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="8" class="text-center">No students found.</td>
<td colspan="9" class="text-center">No students found.</td>
</tr>
<?php endif; ?>
</tbody>
@@ -169,52 +184,7 @@
<?= $this->section('scripts') ?>
<script>
$(function() {
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 = !!($.fn.dataTable && $.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(() => {});
}
function initTable() {
$('#enrollmentTable').DataTable({
$('#enrollmentTable').DataTable({
pageLength: 100,
lengthMenu: [10, 25, 50, 100],
stateSave: true,
@@ -222,18 +192,8 @@
columnDefs: [
{ targets: [1, 2, 3], searchable: false }
],
fixedHeader: {
header: true,
headerOffset: getFixedHeaderOffset()
}
});
}
if ($.fn.dataTable && $.fn.dataTable.FixedHeader) {
initTable();
} else {
ensureFixedHeaderAssets().then(() => initTable()).catch(() => initTable());
}
fixedHeader: false
});
});
</script>
<?= $this->endSection() ?>
+253 -22
View File
@@ -4,6 +4,41 @@
$gCount = count($f['guardians'] ?? []);
$sCount = count($f['students'] ?? []);
$sum = $f['finance_summary'] ?? ['invoices_count'=>0,'total_amount'=>0,'paid_amount'=>0,'balance'=>0];
$canViewInvoices = !empty($f['can_view_invoices']);
$selectedStudentId = (int)($f['selected_student_id'] ?? 0);
$scoreDefaultStudentId = $selectedStudentId > 0
? $selectedStudentId
: (int)($f['students'][0]['id'] ?? 0);
$displayValue = static function ($value, string $fallback = 'Not provided'): string {
if ($value === null || trim((string)$value) === '') {
return $fallback;
}
return (string)$value;
};
$formatDate = static function ($value, bool $includeTime = false) use ($displayValue): string {
if ($value === null || trim((string)$value) === '') {
return 'Not provided';
}
try {
return $includeTime
? local_datetime((string)$value, 'm-d-Y g:i A')
: (new \DateTime((string)$value))->format('m-d-Y');
} catch (\Throwable $e) {
return $displayValue($value);
}
};
$formatScore = static function ($value): string {
if ($value === null || $value === '' || !is_numeric($value)) {
return '—';
}
return rtrim(rtrim(number_format((float)$value, 2, '.', ''), '0'), '.');
};
// Title: prefer provided household_name unless it's generic like "Family of User 53" or empty.
$titleRaw = trim((string)($f['household_name'] ?? ''));
@@ -41,6 +76,14 @@ if ($returnUrl === '') {
.family-card-root .fc-title { font-size: 1.3rem; letter-spacing: .2px; }
.family-card-root .fc-name { font-size: 1.08rem; font-weight: 600; color: #0b5ed7; }
.family-card-root .fc-name:hover { color: #084298; text-decoration: underline; }
.family-card-root .fc-student-details { background: #f8fafc; border-top: 1px solid #e5e7eb; }
.family-card-root .fc-detail-label { color: #64748b; font-size: .76rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.family-card-root .fc-detail-value { color: #12344d; overflow-wrap: anywhere; }
.family-card-root .fc-health-card { border-left: 4px solid #2d89ec !important; }
.family-card-root .fc-score-table th { white-space: normal; min-width: 105px; vertical-align: middle; }
.family-card-root .fc-score-table td { white-space: normal; vertical-align: top; }
.family-card-root .fc-score-table .fc-score-comment { min-width: 220px; max-width: 320px; }
.family-card-root .fc-score-student-nav { flex-wrap: nowrap; overflow-x: auto; }
.family-card-root .fc-badges .badge { background: rgba(255,255,255,.18); color: #fff; font-weight: 500; }
.family-card-root .nav-tabs { padding-left: .5rem; padding-right: .5rem; }
.family-card-root .nav-tabs .nav-link { color: #2161a7; font-weight: 600; }
@@ -92,10 +135,12 @@ if ($returnUrl === '') {
<div class="d-flex flex-wrap gap-2 fc-badges">
<span class="badge">Guardians: <?= (int)$gCount ?></span>
<span class="badge">Students: <?= (int)$sCount ?></span>
<span class="badge">Invoices: <?= (int)($sum['invoices_count'] ?? 0) ?></span>
<span class="badge">
Balance: $<?= number_format((float)($sum['balance'] ?? 0), 2) ?>
</span>
<?php if ($canViewInvoices): ?>
<span class="badge">Invoices: <?= (int)($sum['invoices_count'] ?? 0) ?></span>
<span class="badge">
Balance: $<?= number_format((float)($sum['balance'] ?? 0), 2) ?>
</span>
<?php endif; ?>
</div>
</div>
</div>
@@ -105,15 +150,20 @@ if ($returnUrl === '') {
<li class="nav-item" role="presentation">
<button class="nav-link active" id="fc-tab-overview" data-bs-toggle="tab" data-bs-target="#fc-overview" type="button" role="tab">Students</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="fc-tab-scores" data-bs-toggle="tab" data-bs-target="#fc-scores" type="button" role="tab">Scores History</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="fc-tab-guardians" data-bs-toggle="tab" data-bs-target="#fc-guardians" type="button" role="tab">Parents/Guardians</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="fc-tab-ec" data-bs-toggle="tab" data-bs-target="#fc-ec" type="button" role="tab">Emergency</button>
</li>
<!--li class="nav-item" role="presentation">
<button class="nav-link" id="fc-tab-fin" data-bs-toggle="tab" data-bs-target="#fc-fin" type="button" role="tab">Financials</button>
</li-->
<?php if ($canViewInvoices): ?>
<li class="nav-item" role="presentation">
<button class="nav-link" id="fc-tab-fin" data-bs-toggle="tab" data-bs-target="#fc-fin" type="button" role="tab">Invoices</button>
</li>
<?php endif; ?>
</ul>
<div class="tab-content">
@@ -126,26 +176,202 @@ if ($returnUrl === '') {
<?php if (empty($f['students'])): ?>
<div class="text-muted small">No students linked.</div>
<?php else: ?>
<ul class="list-group">
<div class="accordion" id="fc-students-<?= (int)($f['id'] ?? 0) ?>">
<?php foreach ($f['students'] as $s): ?>
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<a href="#" class="text-decoration-none fc-name" data-family-student-id="<?= (int)($s['id'] ?? 0) ?>">
<?= esc(($s['firstname'] ?? '').' '.($s['lastname'] ?? '')) ?>
</a>
<?php
$studentId = (int)($s['id'] ?? 0);
$detailId = 'fc-student-details-' . (int)($f['id'] ?? 0) . '-' . $studentId;
$isSelected = $selectedStudentId > 0 && $selectedStudentId === $studentId;
$allergies = array_values(array_filter(array_map('trim', (array)($s['allergies'] ?? []))));
$conditions = array_values(array_filter(array_map('trim', (array)($s['medical_conditions'] ?? []))));
?>
<div class="accordion-item">
<h2 class="accordion-header">
<button
type="button"
class="accordion-button <?= $isSelected ? '' : 'collapsed' ?>"
data-bs-toggle="collapse"
data-bs-target="#<?= esc($detailId) ?>"
aria-expanded="<?= $isSelected ? 'true' : 'false' ?>"
aria-controls="<?= esc($detailId) ?>"
>
<span class="fc-name">
<?= esc(trim(($s['firstname'] ?? '').' '.($s['lastname'] ?? ''))) ?>
</span>
<?php if (!empty($s['grade'])): ?>
<span class="badge text-bg-secondary ms-2"><?= esc($s['grade']) ?></span>
<?php endif; ?>
</button>
</h2>
<div
id="<?= esc($detailId) ?>"
class="accordion-collapse collapse <?= $isSelected ? 'show' : '' ?>"
data-bs-parent="#fc-students-<?= (int)($f['id'] ?? 0) ?>"
>
<div class="accordion-body fc-student-details">
<h6 class="mb-3">Student Details</h6>
<div class="row g-3">
<?php
$details = [
'School ID' => $displayValue($s['school_id'] ?? null),
'Date of Birth' => $formatDate($s['dob'] ?? null),
'Age' => $displayValue($s['age'] ?? null),
'Gender' => $displayValue($s['gender'] ?? null),
'Assigned Class / Section' => $displayValue($s['grade'] ?? null),
'Enrollment Status' => $displayValue($s['enrollment_status'] ?? null),
'Registration Grade' => $displayValue($s['registration_grade'] ?? null),
'Status' => ((int)($s['is_active'] ?? 0) === 1) ? 'Active' : 'Inactive',
'Photo Consent' => ((int)($s['photo_consent'] ?? 0) === 1) ? 'Yes' : 'No',
'Registration Date' => $formatDate($s['registration_date'] ?? null, true),
'Year of Registration' => $displayValue($s['year_of_registration'] ?? null),
'RFID Tag' => $displayValue($s['rfid_tag'] ?? null),
'Tuition Paid' => ((int)($s['tuition_paid'] ?? 0) === 1) ? 'Yes' : 'No',
];
?>
<?php foreach ($details as $label => $value): ?>
<div class="col-12 col-sm-6 col-lg-4">
<div class="fc-detail-label"><?= esc($label) ?></div>
<div class="fc-detail-value"><?= esc($value) ?></div>
</div>
<?php endforeach; ?>
</div>
<div class="row g-3 mt-1">
<div class="col-12 col-lg-6">
<div class="card h-100 fc-health-card">
<div class="card-body">
<div class="fc-detail-label mb-2">Medical Conditions</div>
<?php if (empty($conditions)): ?>
<div class="text-muted">None on file</div>
<?php else: ?>
<ul class="mb-0 ps-3">
<?php foreach ($conditions as $condition): ?>
<li><?= esc($condition) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100 fc-health-card">
<div class="card-body">
<div class="fc-detail-label mb-2">Allergies</div>
<?php if (empty($allergies)): ?>
<div class="text-muted">None on file</div>
<?php else: ?>
<ul class="mb-0 ps-3">
<?php foreach ($allergies as $allergy): ?>
<li><?= esc($allergy) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
</div>
<?php if (!empty($s['grade'])): ?>
<span class="badge text-bg-secondary"><?= esc($s['grade']) ?></span>
<?php endif; ?>
</li>
</div>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<!-- Scores History -->
<div class="tab-pane fade" id="fc-scores" role="tabpanel" aria-labelledby="fc-tab-scores">
<div class="p-3">
<?php if (empty($f['students'])): ?>
<div class="alert alert-light border mb-0">No students linked.</div>
<?php else: ?>
<ul class="nav nav-pills fc-score-student-nav gap-2 mb-3" role="tablist">
<?php foreach ($f['students'] as $s): ?>
<?php
$scoreStudentId = (int)($s['id'] ?? 0);
$scoreStudentActive = $scoreStudentId === $scoreDefaultStudentId;
$scorePaneId = 'fc-score-student-' . (int)($f['id'] ?? 0) . '-' . $scoreStudentId;
?>
<li class="nav-item" role="presentation">
<button
class="nav-link text-nowrap <?= $scoreStudentActive ? 'active' : '' ?>"
data-bs-toggle="pill"
data-bs-target="#<?= esc($scorePaneId) ?>"
type="button"
role="tab"
aria-selected="<?= $scoreStudentActive ? 'true' : 'false' ?>"
>
<?= esc(trim(($s['firstname'] ?? '') . ' ' . ($s['lastname'] ?? ''))) ?>
</button>
</li>
<?php endforeach; ?>
</ul>
<div class="tab-content">
<?php foreach ($f['students'] as $s): ?>
<?php
$scoreStudentId = (int)($s['id'] ?? 0);
$scoreStudentActive = $scoreStudentId === $scoreDefaultStudentId;
$scorePaneId = 'fc-score-student-' . (int)($f['id'] ?? 0) . '-' . $scoreStudentId;
$scoreHistory = (array)($s['score_history'] ?? []);
?>
<div class="tab-pane fade <?= $scoreStudentActive ? 'show active' : '' ?>" id="<?= esc($scorePaneId) ?>" role="tabpanel">
<?php if (empty($scoreHistory)): ?>
<div class="alert alert-light border mb-0">No score history found for this student.</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm table-bordered table-striped align-middle fc-score-table mb-0">
<thead class="table-light">
<tr>
<th>School Year</th>
<th>Midterm S1</th>
<th class="fc-score-comment">Midterm Comment S1</th>
<th>PTAP S1</th>
<th class="fc-score-comment">PTAP Comment S1</th>
<th>Attendance S1</th>
<th class="fc-score-comment">Attendance Comment S1</th>
<th>Final S2</th>
<th class="fc-score-comment">Final Comment S2</th>
<th>PTAP S2</th>
<th class="fc-score-comment">PTAP Comment S2</th>
<th>Attendance S2</th>
<th class="fc-score-comment">Attendance Comment S2</th>
<th>Year Score</th>
</tr>
</thead>
<tbody>
<?php foreach ($scoreHistory as $history): ?>
<tr>
<td data-label="School Year" class="text-nowrap fw-semibold"><?= esc($history['school_year'] ?? '—') ?></td>
<td data-label="Midterm S1"><?= esc($formatScore($history['midterm_s1'] ?? null)) ?></td>
<td data-label="Midterm Comment S1" class="fc-score-comment"><?= esc($displayValue($history['midterm_comment_s1'] ?? null, '—')) ?></td>
<td data-label="PTAP S1"><?= esc($formatScore($history['ptap_s1'] ?? null)) ?></td>
<td data-label="PTAP Comment S1" class="fc-score-comment"><?= esc($displayValue($history['ptap_comment_s1'] ?? null, '—')) ?></td>
<td data-label="Attendance S1"><?= esc($formatScore($history['attendance_s1'] ?? null)) ?></td>
<td data-label="Attendance Comment S1" class="fc-score-comment"><?= esc($displayValue($history['attendance_comment_s1'] ?? null, '—')) ?></td>
<td data-label="Final S2"><?= esc($formatScore($history['final_s2'] ?? null)) ?></td>
<td data-label="Final Comment S2" class="fc-score-comment"><?= esc($displayValue($history['final_comment_s2'] ?? null, '—')) ?></td>
<td data-label="PTAP S2"><?= esc($formatScore($history['ptap_s2'] ?? null)) ?></td>
<td data-label="PTAP Comment S2" class="fc-score-comment"><?= esc($displayValue($history['ptap_comment_s2'] ?? null, '—')) ?></td>
<td data-label="Attendance S2"><?= esc($formatScore($history['attendance_s2'] ?? null)) ?></td>
<td data-label="Attendance Comment S2" class="fc-score-comment"><?= esc($displayValue($history['attendance_comment_s2'] ?? null, '—')) ?></td>
<td data-label="Year Score" class="fw-semibold"><?= esc($formatScore($history['year_score'] ?? null)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<!-- Guardians -->
<div class="tab-pane fade" id="fc-guardians" role="tabpanel" aria-labelledby="fc-tab-guardians">
<div class="p-3">
@@ -281,7 +507,8 @@ if ($returnUrl === '') {
</div>
</div>
<!-- Financials -->
<?php if ($canViewInvoices): ?>
<!-- Invoices (administrative roles only) -->
<div class="tab-pane fade" id="fc-fin" role="tabpanel" aria-labelledby="fc-tab-fin">
<div class="p-3">
<div class="row g-3 mb-2">
@@ -313,7 +540,7 @@ if ($returnUrl === '') {
<div class="row g-3">
<div class="col-md-6">
<h6 class="mb-2">Invoices</h6>
<h6 class="mb-2">Parent Invoices</h6>
<?php if (empty($f['invoices'])): ?>
<div class="alert alert-light border">No invoices on record.</div>
<?php else: ?>
@@ -321,18 +548,20 @@ if ($returnUrl === '') {
<table class="table table-sm table-striped table-hover align-middle fc-table-stack">
<thead class="table-light">
<tr>
<th>#</th><th>Status</th><th>Total</th><th>Paid</th><th>Balance</th><th>Date</th>
<th>Parent</th><th>#</th><th>Status</th><th>Total</th><th>Paid</th><th>Balance</th><th>Issued</th><th>Due</th>
</tr>
</thead>
<tbody>
<?php foreach ($f['invoices'] as $iv): ?>
<tr>
<td data-label="Parent"><?= esc($iv['parent_name'] ?? ('Parent #' . (int)($iv['parent_id'] ?? 0))) ?></td>
<td data-label="Invoice"><?= esc($iv['invoice_number']) ?></td>
<td data-label="Status"><?= esc($iv['status']) ?></td>
<td data-label="Total">$<?= number_format((float)($iv['total_amount'] ?? 0), 2) ?></td>
<td data-label="Paid">$<?= number_format((float)($iv['paid_amount'] ?? 0), 2) ?></td>
<td data-label="Balance">$<?= number_format((float)($iv['balance'] ?? 0), 2) ?></td>
<td data-label="Date"><?= esc(!empty($iv['issue_date']) ? local_date($iv['issue_date'], 'm-d-Y') : '') ?></td>
<td data-label="Due"><?= esc(!empty($iv['due_date']) ? local_date($iv['due_date'], 'm-d-Y') : '—') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
@@ -350,13 +579,14 @@ if ($returnUrl === '') {
<table class="table table-sm table-striped table-hover align-middle fc-table-stack">
<thead class="table-light">
<tr>
<th>Invoice</th><th>Amount</th><th>Invoice Balance</th><th>Method</th><th>Date</th><th>Payment Status</th><th>Invoice Status</th>
<th>Parent</th><th>Invoice</th><th>Amount</th><th>Invoice Balance</th><th>Method</th><th>Date</th><th>Payment Status</th><th>Invoice Status</th>
</tr>
</thead>
<tbody>
<?php $imap = $f['invoice_map'] ?? []; ?>
<?php foreach ($f['payments'] as $p): ?>
<tr>
<td data-label="Parent"><?= esc($p['parent_name'] ?? ('Parent #' . (int)($p['parent_id'] ?? 0))) ?></td>
<td data-label="Invoice"><?php $iid = (int)($p['invoice_id'] ?? 0); echo esc($p['invoice_number'] ?? $imap[$iid] ?? ('#'.$iid)); ?></td>
<td data-label="Amount">$<?= number_format((float)($p['paid_amount'] ?? 0), 2) ?></td>
<td data-label="Invoice Balance">$<?= number_format((float)($p['invoice_current_balance'] ?? 0), 2) ?></td>
@@ -374,5 +604,6 @@ if ($returnUrl === '') {
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
+3
View File
@@ -369,6 +369,7 @@ html, body { overflow-x: hidden; }
let sid = link.getAttribute('data-family-student-id');
let gid = link.getAttribute('data-family-guardian-id');
let fid = link.getAttribute('data-family-id');
let schoolYear = link.getAttribute('data-family-school-year');
if (!sid && !gid && !fid && link.matches('a[href]')) {
try {
const url = new URL(link.getAttribute('href'), window.location.origin);
@@ -378,6 +379,7 @@ html, body { overflow-x: hidden; }
sid = url.searchParams.get('student_id');
gid = url.searchParams.get('guardian_id');
fid = url.searchParams.get('family_id');
schoolYear = url.searchParams.get('school_year');
} catch (_) {
return null;
}
@@ -386,6 +388,7 @@ html, body { overflow-x: hidden; }
if (fid) params.family_id = fid;
else if (sid) params.student_id = sid;
else if (gid) params.guardian_id = gid;
if (schoolYear) params.school_year = schoolYear;
return Object.keys(params).length ? params : null;
}
+3
View File
@@ -632,6 +632,7 @@
let sid = link.getAttribute('data-family-student-id');
let gid = link.getAttribute('data-family-guardian-id');
let fid = link.getAttribute('data-family-id');
let schoolYear = link.getAttribute('data-family-school-year');
if (!sid && !gid && !fid && link.matches('a[href]')) {
try {
const url = new URL(link.getAttribute('href'), window.location.origin);
@@ -641,6 +642,7 @@
sid = url.searchParams.get('student_id');
gid = url.searchParams.get('guardian_id');
fid = url.searchParams.get('family_id');
schoolYear = url.searchParams.get('school_year');
} catch (_) {
return null;
}
@@ -649,6 +651,7 @@
if (fid) params.family_id = fid;
else if (sid) params.student_id = sid;
else if (gid) params.guardian_id = gid;
if (schoolYear) params.school_year = schoolYear;
return Object.keys(params).length ? params : null;
}
@@ -0,0 +1,155 @@
<?php
namespace Tests\App\Config;
use CodeIgniter\Test\CIUnitTestCase;
final class AssessmentFeatureIntegrityTest extends CIUnitTestCase
{
public function testAssessmentRoutesAreExplicitAndRoleProtected(): void
{
$routes = file_get_contents(ROOTPATH . 'app/Config/Routes.php') ?: '';
$this->assertStringContainsString("group('administrator/assessments'", $routes);
$this->assertStringContainsString("\$assessmentAdminFilter = 'auth:admin_category'", $routes);
$this->assertStringContainsString('AssessmentController::saveGrade', $routes);
$this->assertStringContainsString("group('student/assessments'", $routes);
$this->assertStringContainsString("'filter' => 'auth:student|parent'", $routes);
}
public function testMigrationEnforcesSingleAssignmentAndAnswerPerQuestion(): void
{
$migration = file_get_contents(ROOTPATH . 'app/Database/Migrations/2026-09-09-000100_CreateStudentAssessmentTables.php') ?: '';
$this->assertStringContainsString("addUniqueKey(['form_id', 'student_id'], 'uq_assessment_form_student')", $migration);
$this->assertStringContainsString("addUniqueKey(['student_assessment_id', 'question_id'], 'uq_student_answer_question')", $migration);
$this->assertStringContainsString("['not_started', 'in_progress', 'completed', 'graded']", $migration);
}
public function testStudentProfilesExposeAssessmentActions(): void
{
$view = file_get_contents(ROOTPATH . 'app/Views/administrator/student_profiles.php') ?: '';
foreach (['Assign Assessment', 'View Status', 'Review', 'View Results'] as $label) {
$this->assertStringContainsString($label, $view);
}
}
public function testAssessmentRosterUsesTheNewStudentEnrollmentSource(): void
{
$controller = file_get_contents(ROOTPATH . 'app/Controllers/View/AssessmentController.php') ?: '';
$newStudentView = file_get_contents(ROOTPATH . 'app/Views/enroll_withdraw/new-students.php') ?: '';
$this->assertStringContainsString("service('enrollmentWithdrawal')->newStudents(\$schoolYear)", $controller);
$this->assertStringContainsString('administrator/assessments/students/', $newStudentView);
$this->assertStringContainsString('Assign Assessment', $newStudentView);
}
public function testSharedAssessmentsAreNotBlockedByHistoricalYearContext(): void
{
$filter = file_get_contents(ROOTPATH . 'app/Filters/SchoolYearWritableFilter.php') ?: '';
$this->assertStringContainsString("'administrator/assessments'", $filter);
$this->assertStringContainsString("'student/assessments'", $filter);
}
public function testNewStudentQuestionPoolSeedsAllIntakeQuestions(): void
{
$migration = file_get_contents(ROOTPATH . 'app/Database/Migrations/2026-09-10-000100_SeedNewStudentAssessmentQuestions.php') ?: '';
$this->assertStringContainsString("POOL_NAME = 'New Student Assessment'", $migration);
preg_match_all("/^ 'text' => '[^\n]+',$/m", $migration, $questionLines);
$this->assertCount(9, $questionLines[0]);
$this->assertStringContainsString('Arabic competency level', $migration);
$this->assertStringContainsString('questions / concerns', $migration);
}
public function testAdminInterviewAutosavesWithoutSaveButton(): void
{
$view = file_get_contents(ROOTPATH . 'app/Views/assessments/admin_take.php') ?: '';
$this->assertStringContainsString('Responses save automatically as you type.', $view);
$this->assertStringContainsString('/progress', $view);
$this->assertStringContainsString('Complete Assessment', $view);
$this->assertStringNotContainsString('Save Progress', $view);
}
public function testAssessmentFormsIncludeEducationCommitteeNote(): void
{
$builder = file_get_contents(ROOTPATH . 'app/Views/assessments/form_builder.php') ?: '';
$migration = file_get_contents(ROOTPATH . 'app/Database/Migrations/2026-09-10-000200_AddEducationCommitteeNoteToAssessmentForms.php') ?: '';
$this->assertStringContainsString('Education Committee Note', $builder);
$this->assertStringContainsString('education_committee_note', $builder);
$this->assertStringContainsString("'type' => 'TEXT'", $migration);
}
public function testStudentAssessmentHasAutosavingEducationCommitteeNote(): void
{
$view = file_get_contents(ROOTPATH . 'app/Views/assessments/admin_take.php') ?: '';
$review = file_get_contents(ROOTPATH . 'app/Views/assessments/grade.php') ?: '';
$results = file_get_contents(ROOTPATH . 'app/Views/assessments/results.php') ?: '';
$routes = file_get_contents(ROOTPATH . 'app/Config/Routes.php') ?: '';
$controller = file_get_contents(ROOTPATH . 'app/Controllers/View/AssessmentController.php') ?: '';
$migration = file_get_contents(ROOTPATH . 'app/Database/Migrations/2026-09-10-000400_AddEducationCommitteeNoteToStudentAssessments.php') ?: '';
$this->assertStringContainsString('Education Committee Note', $view);
$this->assertStringContainsString('name="education_committee_note"', $view);
$this->assertStringContainsString('assessment-answer', $view);
$this->assertStringContainsString('persistEducationCommitteeNote', $controller);
$this->assertStringContainsString("addColumn('student_assessments'", $migration);
$this->assertStringContainsString('name="education_committee_note"', $review);
$this->assertStringContainsString('name="education_committee_note"', $results);
$this->assertStringContainsString("attempts/(:num)/note", $routes);
$this->assertStringContainsString('saveAdminNote', $controller);
$this->assertStringContainsString('saves automatically', $review);
}
public function testSchoolYearBelongsOnlyToAssessmentForms(): void
{
$migration = file_get_contents(ROOTPATH . 'app/Database/Migrations/2026-09-10-000300_AddSchoolYearToAssessmentForms.php') ?: '';
$model = file_get_contents(ROOTPATH . 'app/Models/AssessmentFormModel.php') ?: '';
$controller = file_get_contents(ROOTPATH . 'app/Controllers/View/AssessmentController.php') ?: '';
$this->assertStringContainsString("addColumn('assessment_forms'", $migration);
$this->assertStringNotContainsString("addColumn('student_assessments'", $migration);
$this->assertStringNotContainsString("addColumn('student_answers'", $migration);
$this->assertStringContainsString("'school_year'", $model);
$this->assertStringContainsString("where('f.school_year', \$schoolYear)", $controller);
}
public function testAssessmentsAreQualitativeAndHaveNoPointControls(): void
{
$pool = file_get_contents(ROOTPATH . 'app/Views/assessments/pool.php') ?: '';
$take = file_get_contents(ROOTPATH . 'app/Views/assessments/take.php') ?: '';
$review = file_get_contents(ROOTPATH . 'app/Views/assessments/grade.php') ?: '';
$results = file_get_contents(ROOTPATH . 'app/Views/assessments/results.php') ?: '';
$this->assertStringNotContainsString('name="points"', $pool);
$this->assertStringNotContainsString('total points', $take);
$this->assertStringNotContainsString('points_awarded', $review);
$this->assertStringNotContainsString('is_correct', $review);
$this->assertStringNotContainsString("assessment['score']", $results);
$this->assertStringContainsString('Complete Review', $review);
}
public function testAssessmentResultsProvideReturnNavigation(): void
{
$results = file_get_contents(ROOTPATH . 'app/Views/assessments/results.php') ?: '';
$this->assertStringContainsString('Back to Students', $results);
$this->assertStringContainsString("site_url('administrator/assessments/students')", $results);
$this->assertStringContainsString('Back to My Assessments', $results);
}
public function testAssessmentLandingPageIncludesAdminGuide(): void
{
$forms = file_get_contents(ROOTPATH . 'app/Views/assessments/forms.php') ?: '';
$this->assertStringContainsString('How to use assessments', $forms);
foreach (['Questions', 'Create', 'Assess', 'Complete', 'Review'] as $step) {
$this->assertStringContainsString("<strong>{$step}</strong>", $forms);
}
$this->assertStringContainsString('Assessments are not scored.', $forms);
}
}
@@ -0,0 +1,30 @@
<?php
namespace Tests\App\Config;
use CodeIgniter\Test\CIUnitTestCase;
final class FamilyCardRouteIntegrityTest extends CIUnitTestCase
{
public function testFamilyCardAllowsTeachersWhileFamilyManagementRemainsAdministrative(): void
{
$routes = file_get_contents(ROOTPATH . 'app/Config/Routes.php') ?: '';
$this->assertStringContainsString(
"\$routes->group('family', ['filter' => 'auth:admin|principal']",
$routes
);
$this->assertStringContainsString(
"\$routes->get('family/card', 'View\\FamilyAdminController::card'",
$routes
);
$this->assertStringContainsString(
"auth:admin|administrator|administrative staff|principal|teacher|teacher_assistant",
$routes
);
$this->assertStringNotContainsString(
"\$routes->get('card', 'View\\FamilyAdminController::card');",
$routes
);
}
}
@@ -0,0 +1,38 @@
<?php
namespace Tests\App\Controllers\View;
use App\Controllers\View\FamilyAdminController;
use CodeIgniter\Test\CIUnitTestCase;
final class FamilyAdminControllerAccessTest extends CIUnitTestCase
{
protected function tearDown(): void
{
session()->remove(['role', 'roles']);
parent::tearDown();
}
public function testTeachersAndAssistantsCannotViewFamilyInvoices(): void
{
$controller = new FamilyAdminController();
$method = new \ReflectionMethod($controller, 'canViewFamilyInvoices');
session()->set('role', 'teacher');
$this->assertFalse($method->invoke($controller));
session()->set('role', 'teacher_assistant');
$this->assertFalse($method->invoke($controller));
}
public function testAdministrativeRolesCanViewFamilyInvoices(): void
{
$controller = new FamilyAdminController();
$method = new \ReflectionMethod($controller, 'canViewFamilyInvoices');
foreach (['admin', 'administrator', 'administrative staff', 'principal'] as $role) {
session()->set('role', $role);
$this->assertTrue($method->invoke($controller), $role);
}
}
}
@@ -40,7 +40,7 @@ class InventorySchemaNormalizationTest extends CIUnitTestCase
{
$routes = file_get_contents(ROOTPATH . 'app/Config/Routes.php');
$this->assertStringContainsString("\$routes->get('card', 'View\\FamilyAdminController::card')", $routes);
$this->assertStringContainsString("\$routes->get('family/card', 'View\\FamilyAdminController::card'", $routes);
$this->assertStringContainsString("\$routes->get('summary-all', 'View\\InventoryController::summaryAll')", $routes);
$this->assertStringContainsString("\$routes->get('/', 'View\\InventoryController::index')", $routes);
$this->assertStringContainsString("\$routes->get('(classroom|book|office|kitchen)', 'View\\InventoryController::index/\$1')", $routes);
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace Tests\App\Views;
use CodeIgniter\Test\CIUnitTestCase;
final class FamilyCardViewTest extends CIUnitTestCase
{
public function testStudentNameExpandsCompleteStudentDetails(): void
{
helper('time');
$html = view('family/card', [
'f' => [
'id' => 9,
'household_name' => 'Family of Tester',
'guardians' => [],
'students' => [[
'id' => 12,
'school_id' => 'STU-12',
'firstname' => 'Test',
'lastname' => 'Student',
'dob' => '2015-01-02',
'age' => 11,
'gender' => 'Female',
'grade' => '5 / 5-A',
'enrollment_status' => 'enrolled',
'registration_grade' => '5',
'is_active' => 1,
'photo_consent' => 1,
'registration_date' => '2025-09-02 14:30:00',
'year_of_registration' => '2025',
'rfid_tag' => 'RF-12',
'tuition_paid' => 1,
'allergies' => ['Peanut'],
'medical_conditions' => ['Asthma'],
'score_history' => [[
'school_year' => '2025-2026',
'midterm_s1' => 88.5,
'midterm_comment_s1' => 'Strong first semester.',
'ptap_s1' => 91,
'ptap_comment_s1' => 'Participates consistently.',
'attendance_s1' => 95,
'attendance_comment_s1' => 'Excellent attendance.',
'final_s2' => 92,
'final_comment_s2' => 'Excellent finish.',
'ptap_s2' => 94,
'ptap_comment_s2' => 'Continued strong participation.',
'attendance_s2' => 100,
'attendance_comment_s2' => 'Perfect attendance.',
'year_score' => 93.25,
]],
]],
'selected_student_id' => 12,
'emergency_contacts' => [],
'finance_summary' => [],
],
]);
$this->assertStringContainsString('Test Student', $html);
$this->assertStringContainsString('data-bs-target="#fc-student-details-9-12"', $html);
$this->assertStringContainsString('accordion-collapse collapse show', $html);
$this->assertStringContainsString('Student Details', $html);
$this->assertStringContainsString('STU-12', $html);
$this->assertStringContainsString('5 / 5-A', $html);
$this->assertStringContainsString('Enrollment Status', $html);
$this->assertStringNotContainsString('Enrollment School Year', $html);
$this->assertStringNotContainsString('Enrollment Semester', $html);
$this->assertStringContainsString('Peanut', $html);
$this->assertStringContainsString('Asthma', $html);
$this->assertStringContainsString('Scores History', $html);
$this->assertStringContainsString('Midterm Comment S1', $html);
$this->assertStringContainsString('Attendance Comment S2', $html);
$this->assertStringContainsString('Strong first semester.', $html);
$this->assertStringContainsString('93.25', $html);
$this->assertStringNotContainsString('data-family-student-id="12"', $html);
$this->assertStringNotContainsString('id="fc-tab-fin"', $html);
$this->assertStringNotContainsString('Invoices:', $html);
}
public function testAdministrativeFamilyCardDisplaysParentInvoices(): void
{
helper('time');
$html = view('family/card', [
'f' => [
'id' => 10,
'household_name' => 'Family of Admin Test',
'guardians' => [],
'students' => [],
'emergency_contacts' => [],
'can_view_invoices' => true,
'finance_summary' => [
'invoices_count' => 1,
'total_amount' => 500,
'paid_amount' => 200,
'balance' => 300,
],
'invoices' => [[
'parent_id' => 88,
'parent_name' => 'Parent Person',
'invoice_number' => 'INV-2026-001',
'status' => 'Partially Paid',
'total_amount' => 500,
'paid_amount' => 200,
'balance' => 300,
'issue_date' => '2026-09-01 12:00:00',
'due_date' => '2026-09-30 12:00:00',
]],
'payments' => [],
],
]);
$this->assertStringContainsString('id="fc-tab-fin"', $html);
$this->assertStringContainsString('Parent Invoices', $html);
$this->assertStringContainsString('Parent Person', $html);
$this->assertStringContainsString('INV-2026-001', $html);
$this->assertStringContainsString('Invoices: 1', $html);
}
}