Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b2dd4bdf8 | |||
| b5e719382d | |||
| ac19226bf0 |
+44
-1
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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 student’s Islamic education background?',
|
||||
],
|
||||
[
|
||||
'type' => 'short_answer',
|
||||
'text' => 'What is the student’s Arabic competency level?',
|
||||
],
|
||||
[
|
||||
'type' => 'short_answer',
|
||||
'text' => 'What is the student’s 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ?>">
|
||||
|
||||
@@ -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; ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user