merge prod to main
This commit is contained in:
Regular → Executable
+552
-35
@@ -28,6 +28,8 @@ use App\Models\ScoreCommentModel;
|
||||
use App\Models\SemesterScoreModel;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\TeacherSubmissionNotificationHistoryModel;
|
||||
use App\Models\ExamDraftModel;
|
||||
use App\Models\HomeworkModel;
|
||||
use App\Services\SemesterRangeService;
|
||||
|
||||
use CodeIgniter\Events\Events;
|
||||
@@ -416,8 +418,10 @@ class AdministratorController extends BaseController
|
||||
$totalStudents = (int) (
|
||||
$this->db->table('student_class')
|
||||
->select('COUNT(DISTINCT student_class.student_id) AS cnt')
|
||||
->join('students', 'students.id = student_class.student_id', 'inner')
|
||||
->where('student_class.school_year', $this->schoolYear)
|
||||
->where('student_class.class_section_id IS NOT NULL', null, false)
|
||||
->where('students.is_active', 1)
|
||||
->get()
|
||||
->getRow('cnt')
|
||||
?? 0
|
||||
@@ -694,15 +698,26 @@ class AdministratorController extends BaseController
|
||||
|
||||
public function teacherSubmissionsReport()
|
||||
{
|
||||
$semester = (string)($this->semester ?? '');
|
||||
$schoolYear = (string)($this->schoolYear ?? '');
|
||||
$semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? '');
|
||||
$schoolYear = (string)($this->configModel->getConfig('school_year') ?? $this->schoolYear ?? '');
|
||||
$semesterResolver = new SemesterRangeService($this->configModel);
|
||||
$semesterNorm = $semesterResolver->normalizeSemester($semester);
|
||||
$semesterFilter = $semesterNorm !== '' ? $semesterNorm : $semester;
|
||||
$semesterCandidates = $this->buildSemesterCandidates($semesterFilter);
|
||||
$lowProgressRaw = (string) $this->request->getGet('low_progress_sections');
|
||||
$lowProgressSectionIds = array_values(array_unique(array_filter(array_map(
|
||||
'intval',
|
||||
preg_split('/\s*,\s*/', $lowProgressRaw, -1, PREG_SPLIT_NO_EMPTY)
|
||||
))));
|
||||
|
||||
$scoreComments = new ScoreCommentModel();
|
||||
$semesterScores = new SemesterScoreModel();
|
||||
$attendanceDays = new AttendanceDayModel();
|
||||
$examDrafts = new ExamDraftModel();
|
||||
$homeworkModel = new HomeworkModel();
|
||||
$historyModel = new TeacherSubmissionNotificationHistoryModel();
|
||||
|
||||
$assignmentRows = $this->db->table('teacher_class tc')
|
||||
$assignmentQuery = $this->db->table('teacher_class tc')
|
||||
->select([
|
||||
'tc.class_section_id',
|
||||
'cs.class_section_name',
|
||||
@@ -713,11 +728,97 @@ class AdministratorController extends BaseController
|
||||
])
|
||||
->join('classSection cs', 'cs.class_section_id = tc.class_section_id', 'left')
|
||||
->join('users u', 'u.id = tc.teacher_id', 'left')
|
||||
->where('tc.school_year', $schoolYear)
|
||||
->where('tc.semester', $semester)
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
->orderBy('cs.class_section_name', 'ASC');
|
||||
|
||||
$filteredQuery = clone $assignmentQuery;
|
||||
if ($schoolYear !== '') {
|
||||
$filteredQuery = $filteredQuery->where('tc.school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($semesterCandidates)) {
|
||||
$filteredQuery = $filteredQuery->whereIn('tc.semester', $semesterCandidates);
|
||||
}
|
||||
|
||||
$assignmentRows = $filteredQuery->get()->getResultArray();
|
||||
if (empty($assignmentRows) && ($schoolYear !== '' || $semester !== '')) {
|
||||
$assignmentRows = $assignmentQuery->get()->getResultArray();
|
||||
}
|
||||
|
||||
$studentCounts = $this->studentClassModel->getStudentCountsBySection($schoolYear !== '' ? $schoolYear : null);
|
||||
$sectionRows = $this->classSectionModel
|
||||
->select('class_section_id, class_section_name')
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
$sectionMap = [];
|
||||
foreach ($sectionRows as $sectionRow) {
|
||||
$sectionId = (int) ($sectionRow['class_section_id'] ?? 0);
|
||||
if ($sectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (empty($studentCounts[$sectionId])) {
|
||||
continue;
|
||||
}
|
||||
$sectionMap[$sectionId] = $sectionRow['class_section_name'] ?? "Section {$sectionId}";
|
||||
}
|
||||
$sectionIds = array_keys($sectionMap);
|
||||
|
||||
[$progressExpectedWeeks, $progressSubmittedBySection] = $this->buildClassProgressStats($sectionIds);
|
||||
$examDraftCounts = [];
|
||||
$examDraftDeadline = $this->resolveTeacherDashboardExamDraftDeadline($semester, $schoolYear);
|
||||
$examDraftDeadlineConfig = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
|
||||
$examDraftDeadlineFormatted = '';
|
||||
if ($examDraftDeadlineConfig !== '') {
|
||||
$parsedUi = $this->parseExamDraftDeadlineConfigValue();
|
||||
$examDraftDeadlineFormatted = $parsedUi !== null ? $parsedUi->format('M j, Y') : '';
|
||||
}
|
||||
$homeworkCounts = [];
|
||||
if (! empty($sectionIds)) {
|
||||
$draftBuilder = $examDrafts
|
||||
->select('class_section_id')
|
||||
->whereIn('class_section_id', $sectionIds);
|
||||
if ($schoolYear !== '') {
|
||||
$draftBuilder->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($semesterCandidates)) {
|
||||
$draftBuilder->whereIn('semester', $semesterCandidates);
|
||||
}
|
||||
if ($this->db->fieldExists('is_legacy', 'exam_drafts')) {
|
||||
$draftBuilder->where('is_legacy', 0);
|
||||
}
|
||||
$draftRows = $draftBuilder->findAll();
|
||||
foreach ($draftRows as $draft) {
|
||||
$sectionId = (int) ($draft['class_section_id'] ?? 0);
|
||||
if ($sectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$examDraftCounts[$sectionId] = ($examDraftCounts[$sectionId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$homeworkBuilder = $homeworkModel
|
||||
->select('class_section_id, homework_index')
|
||||
->whereIn('class_section_id', $sectionIds);
|
||||
if ($schoolYear !== '') {
|
||||
$homeworkBuilder->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($semesterCandidates)) {
|
||||
$homeworkBuilder->whereIn('semester', $semesterCandidates);
|
||||
}
|
||||
$homeworkRows = $homeworkBuilder
|
||||
->where('score IS NOT NULL', null, false)
|
||||
->where('score !=', '')
|
||||
->groupBy('class_section_id, homework_index')
|
||||
->findAll();
|
||||
foreach ($homeworkRows as $row) {
|
||||
$sectionId = (int) ($row['class_section_id'] ?? 0);
|
||||
if ($sectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$homeworkCounts[$sectionId] = ($homeworkCounts[$sectionId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($lowProgressSectionIds)) {
|
||||
$lowProgressSectionIds = $this->resolveLowProgressSectionIds($sectionIds);
|
||||
}
|
||||
|
||||
$teachersBySection = [];
|
||||
foreach ($assignmentRows as $assignment) {
|
||||
@@ -743,7 +844,7 @@ class AdministratorController extends BaseController
|
||||
$entry = &$teachersBySection[$sectionId];
|
||||
if (!isset($entry)) {
|
||||
$entry = [
|
||||
'class_section' => $assignment['class_section_name'] ?? "Section {$sectionId}",
|
||||
'class_section' => $assignment['class_section_name'] ?? ($sectionMap[$sectionId] ?? "Section {$sectionId}"),
|
||||
'teachers' => [],
|
||||
];
|
||||
}
|
||||
@@ -763,35 +864,49 @@ class AdministratorController extends BaseController
|
||||
$missingItemCount = 0;
|
||||
$allTeacherIds = [];
|
||||
$allClassSectionIds = [];
|
||||
foreach ($teachersBySection as $classSectionId => $section) {
|
||||
$examTerm = $this->resolveExamTermLabel($semester);
|
||||
$examScoreField = $examTerm === 'final' ? 'final_exam_score' : 'midterm_exam_score';
|
||||
|
||||
foreach ($sectionMap as $classSectionId => $sectionName) {
|
||||
$classSectionId = (int)$classSectionId;
|
||||
if ($classSectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$studentEntries = $this->studentClassModel
|
||||
$studentQuery = $this->studentClassModel
|
||||
->select('student_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
->where('school_year', $schoolYear);
|
||||
if (!empty($semesterCandidates)) {
|
||||
$studentQuery->whereIn('semester', $semesterCandidates);
|
||||
}
|
||||
$studentEntries = $studentQuery->findAll();
|
||||
if (empty($studentEntries)) {
|
||||
$studentEntries = $this->studentClassModel
|
||||
->select('student_id')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
}
|
||||
$studentIds = array_filter(array_map(static fn($entry) => (int)($entry['student_id'] ?? 0), $studentEntries));
|
||||
$expected = count($studentIds);
|
||||
|
||||
$midtermStudents = [];
|
||||
$participationStudents = [];
|
||||
if ($classSectionId > 0) {
|
||||
$scoreRecords = $semesterScores
|
||||
$scoreQuery = $semesterScores
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
->where('school_year', $schoolYear);
|
||||
if (!empty($semesterCandidates)) {
|
||||
$scoreQuery->whereIn('semester', $semesterCandidates);
|
||||
}
|
||||
$scoreRecords = $scoreQuery->findAll();
|
||||
foreach ($scoreRecords as $score) {
|
||||
$sid = (int)($score['student_id'] ?? 0);
|
||||
if ($sid <= 0 || ($expected > 0 && !in_array($sid, $studentIds, true))) {
|
||||
continue;
|
||||
}
|
||||
$midtermValue = trim((string)($score['midterm_exam_score'] ?? ''));
|
||||
$midtermValue = trim((string)($score[$examScoreField] ?? ''));
|
||||
if ($midtermValue !== '') {
|
||||
$midtermStudents[$sid] = true;
|
||||
}
|
||||
@@ -805,13 +920,15 @@ class AdministratorController extends BaseController
|
||||
$midtermCommentStudents = [];
|
||||
$ptapCommentStudents = [];
|
||||
if (!empty($studentIds)) {
|
||||
$comments = $scoreComments
|
||||
$commentQuery = $scoreComments
|
||||
->select('student_id, score_type, comment')
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('score_type', ['midterm', 'ptap'])
|
||||
->findAll();
|
||||
->whereIn('score_type', [$examTerm, 'ptap']);
|
||||
if (!empty($semesterCandidates)) {
|
||||
$commentQuery->whereIn('semester', $semesterCandidates);
|
||||
}
|
||||
$comments = $commentQuery->findAll();
|
||||
foreach ($comments as $comment) {
|
||||
$sid = (int)($comment['student_id'] ?? 0);
|
||||
if ($sid <= 0) {
|
||||
@@ -822,7 +939,7 @@ class AdministratorController extends BaseController
|
||||
continue;
|
||||
}
|
||||
$type = strtolower(trim((string)($comment['score_type'] ?? '')));
|
||||
if ($type === 'midterm') {
|
||||
if ($type === $examTerm) {
|
||||
$midtermCommentStudents[$sid] = true;
|
||||
}
|
||||
if ($type === 'ptap') {
|
||||
@@ -831,14 +948,17 @@ class AdministratorController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
$attendanceRow = $attendanceDays
|
||||
$attendanceQuery = $attendanceDays
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('date', $today)
|
||||
->first();
|
||||
->where('date', $today);
|
||||
if (!empty($semesterCandidates)) {
|
||||
$attendanceQuery->whereIn('semester', $semesterCandidates);
|
||||
}
|
||||
$attendanceRow = $attendanceQuery->first();
|
||||
$attendanceSubmitted = $attendanceRow && in_array(strtolower((string)($attendanceRow['status'] ?? '')), ['submitted', 'published', 'finalized'], true);
|
||||
|
||||
$section = $teachersBySection[$classSectionId] ?? ['teachers' => []];
|
||||
$teacherList = $section['teachers'] ?? [];
|
||||
if (!empty($teacherList)) {
|
||||
usort($teacherList, function ($a, $b) {
|
||||
@@ -859,18 +979,27 @@ class AdministratorController extends BaseController
|
||||
$participationStatus = $this->submissionStatus(count($participationStudents), $expected);
|
||||
$ptapCommentStatus = $this->submissionStatus(count($ptapCommentStudents), $expected);
|
||||
$attendanceStatus = $this->attendanceStatus($attendanceSubmitted);
|
||||
$progressSubmitted = (int) ($progressSubmittedBySection[$classSectionId] ?? 0);
|
||||
$classProgressStatus = $this->progressStatus($progressSubmitted, $progressExpectedWeeks);
|
||||
$draftSubmitted = (int) ($examDraftCounts[$classSectionId] ?? 0);
|
||||
$examDraftStatus = $this->draftStatus($draftSubmitted, $examDraftDeadline);
|
||||
$homeworkSubmitted = (int) ($homeworkCounts[$classSectionId] ?? 0);
|
||||
$homeworkStatus = $this->homeworkStatus($homeworkSubmitted);
|
||||
$statusDetails = [
|
||||
'midterm_score_status' => $midtermScoreStatus,
|
||||
'midterm_comment_status' => $midtermCommentStatus,
|
||||
'participation_status' => $participationStatus,
|
||||
'ptap_comment_status' => $ptapCommentStatus,
|
||||
'class_progress_status' => $classProgressStatus,
|
||||
'exam_draft_status' => $examDraftStatus,
|
||||
'homework_status' => $homeworkStatus,
|
||||
];
|
||||
$missingItemsForSection = $this->buildMissingItems($statusDetails);
|
||||
$missingItemsForSection = $this->buildMissingItems($statusDetails, $semester);
|
||||
$missingItemCount += count($missingItemsForSection);
|
||||
$totalStatuses += count($statusDetails);
|
||||
|
||||
$rows[] = [
|
||||
'class_section' => $section['class_section'] ?? "Section {$classSectionId}",
|
||||
'class_section' => $sectionMap[$classSectionId] ?? ($section['class_section'] ?? "Section {$classSectionId}"),
|
||||
'class_section_id' => $classSectionId,
|
||||
'teachers' => $teacherList,
|
||||
'midterm_score_status' => $midtermScoreStatus,
|
||||
@@ -878,6 +1007,9 @@ class AdministratorController extends BaseController
|
||||
'participation_status' => $participationStatus,
|
||||
'ptap_comment_status' => $ptapCommentStatus,
|
||||
'attendance_status' => $attendanceStatus,
|
||||
'class_progress_status' => $classProgressStatus,
|
||||
'exam_draft_status' => $examDraftStatus,
|
||||
'homework_status' => $homeworkStatus,
|
||||
'missing_items' => $missingItemsForSection,
|
||||
'student_count' => $expected,
|
||||
];
|
||||
@@ -939,15 +1071,183 @@ class AdministratorController extends BaseController
|
||||
'schoolYear' => $schoolYear,
|
||||
'notificationHistory' => $historyMap,
|
||||
'summary' => $summary,
|
||||
'lowProgressSectionIds' => $lowProgressSectionIds,
|
||||
'examDraftDeadlineConfig' => $examDraftDeadlineConfig,
|
||||
'examDraftDeadlineFormatted' => $examDraftDeadlineFormatted,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveLowProgressSectionIds(array $sectionIds): array
|
||||
{
|
||||
[$expectedWeeks, $submittedBySection] = $this->buildClassProgressStats($sectionIds);
|
||||
if ($expectedWeeks <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lowProgressSectionIds = [];
|
||||
foreach ($sectionIds as $sectionId) {
|
||||
$submitted = (int) ($submittedBySection[$sectionId] ?? 0);
|
||||
$percent = ($submitted / $expectedWeeks) * 100;
|
||||
if ($percent < 50) {
|
||||
$lowProgressSectionIds[] = $sectionId;
|
||||
}
|
||||
}
|
||||
|
||||
return $lowProgressSectionIds;
|
||||
}
|
||||
|
||||
private function buildClassProgressStats(array $sectionIds): array
|
||||
{
|
||||
$sectionIds = array_values(array_unique(array_filter(array_map('intval', $sectionIds))));
|
||||
if (empty($sectionIds)) {
|
||||
return [0, []];
|
||||
}
|
||||
|
||||
$semesterResolver = new SemesterRangeService($this->configModel);
|
||||
$schoolYear = (string)($this->configModel->getConfig('school_year') ?? '');
|
||||
$semester = (string)($this->configModel->getConfig('semester') ?? '');
|
||||
$schoolYearForRange = $schoolYear !== '' ? $schoolYear : (string)($this->configModel->getConfig('school_year') ?? '');
|
||||
[$rangeStart, $rangeEnd] = $semesterResolver->getSchoolYearRange($schoolYearForRange);
|
||||
$semesterNorm = $semesterResolver->normalizeSemester($semester);
|
||||
if ($semesterNorm !== '' && $schoolYearForRange !== '') {
|
||||
$semRange = $semesterResolver->getSemesterRange($schoolYearForRange, $semesterNorm);
|
||||
if ($semRange) {
|
||||
[$rangeStart, $rangeEnd] = $semRange;
|
||||
}
|
||||
}
|
||||
|
||||
$dateList = [];
|
||||
try {
|
||||
$start = new \DateTimeImmutable($rangeStart);
|
||||
$end = new \DateTimeImmutable($rangeEnd);
|
||||
$cursor = $start;
|
||||
$w = (int) $cursor->format('w');
|
||||
if ($w !== 0) {
|
||||
$cursor = $cursor->modify('next sunday');
|
||||
}
|
||||
while ($cursor <= $end) {
|
||||
$dateList[] = $cursor->format('Y-m-d');
|
||||
$cursor = $cursor->modify('+7 days');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$dateList = [];
|
||||
}
|
||||
|
||||
$noSchoolDays = [];
|
||||
$events = [];
|
||||
try {
|
||||
$calendarModel = new \App\Models\CalendarModel();
|
||||
$events = $calendarModel->getEvents();
|
||||
} catch (\Throwable $e) {
|
||||
$events = [];
|
||||
}
|
||||
foreach ($events as $event) {
|
||||
$d = substr((string) ($event['date'] ?? ''), 0, 10);
|
||||
if ($d === '' || empty($event['no_school'])) {
|
||||
continue;
|
||||
}
|
||||
if ($d < $rangeStart || $d > $rangeEnd) {
|
||||
continue;
|
||||
}
|
||||
$eventYear = trim((string) ($event['school_year'] ?? ''));
|
||||
if ($schoolYearForRange !== '' && $eventYear !== '' && $eventYear !== $schoolYearForRange) {
|
||||
continue;
|
||||
}
|
||||
$noSchoolDays[$d] = true;
|
||||
}
|
||||
|
||||
$anchorSundayYmd = '';
|
||||
try {
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tzObj = new \DateTimeZone($tzName ?: 'UTC');
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$tzObj = new \DateTimeZone(user_timezone() ?: 'UTC');
|
||||
} catch (\Throwable $e2) {
|
||||
$tzObj = new \DateTimeZone('UTC');
|
||||
}
|
||||
}
|
||||
try {
|
||||
$nowDate = new \DateTime('now', $tzObj);
|
||||
} catch (\Throwable $e) {
|
||||
$nowDate = new \DateTime('now');
|
||||
}
|
||||
$weekday = (int) $nowDate->format('w');
|
||||
$anchorSundayYmd = $weekday === 0
|
||||
? $nowDate->format('Y-m-d')
|
||||
: $nowDate->modify('next sunday')->format('Y-m-d');
|
||||
|
||||
$activeDatesSet = [];
|
||||
if (! empty($dateList) && $anchorSundayYmd !== '') {
|
||||
foreach ($dateList as $d) {
|
||||
if ($d <= $anchorSundayYmd && empty($noSchoolDays[$d])) {
|
||||
$activeDatesSet[$d] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$expectedWeeks = count($activeDatesSet);
|
||||
if ($expectedWeeks === 0) {
|
||||
return [0, []];
|
||||
}
|
||||
|
||||
$builder = $this->db->table('class_progress_reports')
|
||||
->select('class_section_id, week_start')
|
||||
->whereIn('class_section_id', $sectionIds);
|
||||
if (! empty($activeDatesSet)) {
|
||||
$builder->whereIn('week_start', array_keys($activeDatesSet));
|
||||
}
|
||||
$rows = $builder->get()->getResultArray();
|
||||
|
||||
$submittedBySection = [];
|
||||
foreach ($rows as $row) {
|
||||
$sectionId = (int) ($row['class_section_id'] ?? 0);
|
||||
$weekStart = (string) ($row['week_start'] ?? '');
|
||||
if ($sectionId === 0 || $weekStart === '' || empty($activeDatesSet[$weekStart])) {
|
||||
continue;
|
||||
}
|
||||
$submittedBySection[$sectionId][$weekStart] = true;
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
foreach ($sectionIds as $sectionId) {
|
||||
$counts[$sectionId] = isset($submittedBySection[$sectionId])
|
||||
? count($submittedBySection[$sectionId])
|
||||
: 0;
|
||||
}
|
||||
|
||||
return [$expectedWeeks, $counts];
|
||||
}
|
||||
|
||||
public function sendTeacherSubmissionNotifications()
|
||||
{$notify = $this->request->getPost('notify');
|
||||
if (!is_array($notify)) {
|
||||
return redirect()->back()->with('info', 'Select at least one teacher to notify.');
|
||||
}
|
||||
$semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? '');
|
||||
$missingItemsPayload = $this->request->getPost('missing_items') ?? [];
|
||||
$homeworkNotifyAll = (bool) $this->request->getPost('homework_notify_all');
|
||||
$examTerm = $this->resolveExamTermLabel($semester);
|
||||
$examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores';
|
||||
$examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments';
|
||||
$forcedItems = [];
|
||||
if ($this->request->getPost('notify_midterm_score')) {
|
||||
$forcedItems[] = $examScoreLabel;
|
||||
}
|
||||
if ($this->request->getPost('notify_midterm_comment')) {
|
||||
$forcedItems[] = $examCommentLabel;
|
||||
}
|
||||
if ($this->request->getPost('notify_participation')) {
|
||||
$forcedItems[] = 'participation';
|
||||
}
|
||||
if ($this->request->getPost('notify_ptap_comment')) {
|
||||
$forcedItems[] = 'PTAP comments';
|
||||
}
|
||||
if ($this->request->getPost('notify_class_progress')) {
|
||||
$forcedItems[] = 'class progress';
|
||||
}
|
||||
if ($this->request->getPost('notify_exam_draft')) {
|
||||
$forcedItems[] = 'exam draft';
|
||||
}
|
||||
|
||||
$targets = [];
|
||||
foreach ($notify as $sectionIdRaw => $teachers) {
|
||||
@@ -1004,6 +1304,10 @@ class AdministratorController extends BaseController
|
||||
|
||||
$historyModel = new TeacherSubmissionNotificationHistoryModel();
|
||||
$scoreUrl = site_url('/');
|
||||
$progressUrl = site_url('teacher/progress/history');
|
||||
$examDraftUrl = site_url('teacher/exam-drafts');
|
||||
$homeworkUrl = site_url('teacher/addHomework');
|
||||
$examDraftDeadlineEmailHtml = $this->buildExamDraftDeadlineEmailHtml();
|
||||
$sentCount = 0;
|
||||
$failCount = 0;
|
||||
|
||||
@@ -1019,6 +1323,13 @@ class AdministratorController extends BaseController
|
||||
$subject = "Reminder: Complete submissions for {$sectionName}";
|
||||
$missingPayload = $missingItemsPayload[$classSectionId][$teacherId] ?? '';
|
||||
$missingItems = $this->parseMissingItemsPayload((string)$missingPayload);
|
||||
$selectedItems = $forcedItems;
|
||||
if ($homeworkNotifyAll && !in_array('homework', $selectedItems, true)) {
|
||||
$selectedItems[] = 'homework';
|
||||
}
|
||||
if (!empty($selectedItems)) {
|
||||
$missingItems = array_values(array_unique($selectedItems));
|
||||
}
|
||||
if (!empty($missingItems)) {
|
||||
$missingText = htmlspecialchars(
|
||||
$this->formatMissingItemsText($missingItems),
|
||||
@@ -1031,10 +1342,46 @@ class AdministratorController extends BaseController
|
||||
}
|
||||
|
||||
$subject = "Reminder: Complete submissions for {$sectionName}";
|
||||
$progressNote = '';
|
||||
if (in_array('class progress', $missingItems, true)) {
|
||||
$progressNote = "<p>Class progress submissions can be updated at <a href=\"{$progressUrl}\">Teacher Progress History</a>.</p>";
|
||||
}
|
||||
$examDraftNote = '';
|
||||
if (in_array('exam draft', $missingItems, true)) {
|
||||
$semesterLabel = strtolower(trim((string) $semester));
|
||||
if ($semesterLabel === 'fall') {
|
||||
$draftLabel = 'midterm exam draft';
|
||||
} elseif ($semesterLabel === 'spring') {
|
||||
$draftLabel = 'final exam draft';
|
||||
} else {
|
||||
$draftLabel = 'exam draft';
|
||||
}
|
||||
$examDraftNote = "<p>" . ucfirst($draftLabel) . " submissions can be updated at <a href=\"{$examDraftUrl}\">Teacher Exam Drafts</a>.</p>"
|
||||
. $examDraftDeadlineEmailHtml;
|
||||
}
|
||||
$homeworkNote = '';
|
||||
if (in_array('homework', $missingItems, true)) {
|
||||
$homeworkNote = "<p>Homework scores can be submitted at <a href=\"{$homeworkUrl}\">Teacher Homework</a>.</p>";
|
||||
}
|
||||
$hasScoreItems = (bool) array_intersect($missingItems, [
|
||||
'midterm scores',
|
||||
'midterm comments',
|
||||
'final scores',
|
||||
'final comments',
|
||||
'participation',
|
||||
'PTAP comments',
|
||||
'homework',
|
||||
]);
|
||||
$nonScoreOnly = ! empty($missingItems) && ! $hasScoreItems;
|
||||
$body = "<p>Dear {$teacherName},</p>"
|
||||
. "<p>Administration is gently reminding you to wrap up any remaining score submissions and/or comments for {$sectionName}.</p>"
|
||||
. "<p>Administration is gently reminding you to wrap up any remaining "
|
||||
. ($nonScoreOnly ? "submissions for {$sectionName}." : "score submissions, comments, and related items for {$sectionName}.")
|
||||
. "</p>"
|
||||
. $missingNote
|
||||
. "<p>Visit <a href=\"{$scoreUrl}\">Teacher Score Submission</a> to address any remaining items.</p>"
|
||||
. $progressNote
|
||||
. $examDraftNote
|
||||
. $homeworkNote
|
||||
. ($nonScoreOnly ? '' : "<p>Visit <a href=\"{$scoreUrl}\">Teacher Score Submission</a> to address any remaining items.</p>")
|
||||
. "<p>Thank you,<br>Al Rahma Administration</p>";
|
||||
|
||||
$email = $teacher['email'] ?? '';
|
||||
@@ -1096,6 +1443,170 @@ class AdministratorController extends BaseController
|
||||
];
|
||||
}
|
||||
|
||||
private function progressStatus(int $submitted, int $expected): array
|
||||
{
|
||||
if ($expected <= 0) {
|
||||
return [
|
||||
'label' => 'N/A',
|
||||
'badge' => 'bg-secondary',
|
||||
'detail' => '',
|
||||
'completed' => true,
|
||||
];
|
||||
}
|
||||
$completed = $submitted >= $expected;
|
||||
return [
|
||||
'label' => $completed ? 'Submitted' : 'Missing',
|
||||
'badge' => $completed ? 'bg-success' : 'bg-danger',
|
||||
'detail' => "{$submitted}/{$expected}",
|
||||
'completed' => $completed,
|
||||
];
|
||||
}
|
||||
|
||||
private function homeworkStatus(int $submitted): array
|
||||
{
|
||||
$completed = $submitted > 0;
|
||||
return [
|
||||
'label' => $completed ? 'Submitted' : 'Missing',
|
||||
'badge' => $completed ? 'bg-success' : 'bg-danger',
|
||||
'detail' => $completed ? (string) $submitted : '0',
|
||||
'completed' => $completed,
|
||||
];
|
||||
}
|
||||
|
||||
private function draftStatus(int $submitted, ?\DateTimeImmutable $deadline): array
|
||||
{
|
||||
if ($deadline !== null) {
|
||||
$today = new \DateTimeImmutable('today');
|
||||
if ($today < $deadline) {
|
||||
return [
|
||||
'label' => 'Pending',
|
||||
'badge' => 'bg-secondary',
|
||||
'detail' => 'Not due',
|
||||
'completed' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
$completed = $submitted > 0;
|
||||
return [
|
||||
'label' => $completed ? 'Submitted' : 'Missing',
|
||||
'badge' => $completed ? 'bg-success' : 'bg-danger',
|
||||
'detail' => $completed ? (string) $submitted : '0',
|
||||
'completed' => $completed,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Exam draft due date for the teacher submissions dashboard: prefers the configuration key
|
||||
* `exam_draft_deadline` (same as automated reminders); otherwise fall/spring exam deadlines.
|
||||
*/
|
||||
private function resolveTeacherDashboardExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable
|
||||
{
|
||||
$fromExamDraftKey = $this->parseExamDraftDeadlineConfigValue();
|
||||
if ($fromExamDraftKey !== null) {
|
||||
return $fromExamDraftKey;
|
||||
}
|
||||
|
||||
return $this->resolveExamDraftDeadline($semester, $schoolYear);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the `exam_draft_deadline` configuration value using the application timezone (midnight that calendar day).
|
||||
*/
|
||||
private function parseExamDraftDeadlineConfigValue(): ?\DateTimeImmutable
|
||||
{
|
||||
$raw = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
$tz = new \DateTimeZone(config('App')->appTimezone ?? 'UTC');
|
||||
try {
|
||||
$deadline = new \DateTimeImmutable($raw, $tz);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $deadline->setTime(0, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML snippet for reminder emails when exam draft is included (deadline from exam_draft_deadline config).
|
||||
*/
|
||||
private function buildExamDraftDeadlineEmailHtml(): string
|
||||
{
|
||||
$raw = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
$parsed = $this->parseExamDraftDeadlineConfigValue();
|
||||
$display = $parsed !== null
|
||||
? htmlspecialchars($parsed->format('l, F j, Y'), ENT_QUOTES, 'UTF-8')
|
||||
: htmlspecialchars($raw, ENT_QUOTES, 'UTF-8');
|
||||
$rawEsc = htmlspecialchars($raw, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return '<p><strong>Exam draft submission deadline</strong> (<code>exam_draft_deadline</code>): '
|
||||
. "<strong>{$display}</strong>"
|
||||
. ($parsed !== null && $rawEsc !== $display ? " <span style=\"color:#555;\">(configured value: {$rawEsc})</span>" : '')
|
||||
. '.</p>';
|
||||
}
|
||||
|
||||
private function resolveExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable
|
||||
{
|
||||
$semesterKey = strtolower(trim($semester));
|
||||
if ($semesterKey === 'fall') {
|
||||
$deadlineValue = (string)($this->configModel->getConfig('fall_exam_deadline') ?? '');
|
||||
} elseif ($semesterKey === 'spring') {
|
||||
$deadlineValue = (string)($this->configModel->getConfig('spring_exam_deadline') ?? '');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
$deadlineValue = trim($deadlineValue);
|
||||
if ($deadlineValue === '') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$deadline = new \DateTimeImmutable($deadlineValue);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
if ($schoolYear !== '' && preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
|
||||
$deadlineYear = $deadline->format('Y');
|
||||
if ($deadlineYear === '1970') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return $deadline->setTime(0, 0, 0);
|
||||
}
|
||||
|
||||
private function resolveExamTermLabel(string $semester): string
|
||||
{
|
||||
$semesterKey = strtolower(trim($semester));
|
||||
if ($semesterKey === '') {
|
||||
return 'midterm';
|
||||
}
|
||||
if (str_contains($semesterKey, 'spring')) {
|
||||
return 'final';
|
||||
}
|
||||
if (str_contains($semesterKey, 'fall')) {
|
||||
return 'midterm';
|
||||
}
|
||||
return 'midterm';
|
||||
}
|
||||
|
||||
private function buildSemesterCandidates(string $semester): array
|
||||
{
|
||||
$semester = trim((string) $semester);
|
||||
if ($semester === '') {
|
||||
return [];
|
||||
}
|
||||
$candidates = [
|
||||
$semester,
|
||||
strtolower($semester),
|
||||
strtoupper($semester),
|
||||
ucfirst(strtolower($semester)),
|
||||
];
|
||||
$candidates = array_values(array_unique(array_filter($candidates, static fn ($v) => $v !== '')));
|
||||
return $candidates;
|
||||
}
|
||||
private function attendanceStatus(bool $submitted): array
|
||||
{
|
||||
return [
|
||||
@@ -1105,14 +1616,20 @@ class AdministratorController extends BaseController
|
||||
];
|
||||
}
|
||||
|
||||
private function buildMissingItems(array $statusMap): array
|
||||
private function buildMissingItems(array $statusMap, string $semester): array
|
||||
{
|
||||
$examTerm = $this->resolveExamTermLabel($semester);
|
||||
$examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores';
|
||||
$examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments';
|
||||
$labels = [
|
||||
'midterm_score_status' => 'midterm scores',
|
||||
'midterm_comment_status' => 'midterm comments',
|
||||
'midterm_score_status' => $examScoreLabel,
|
||||
'midterm_comment_status' => $examCommentLabel,
|
||||
'participation_status' => 'participation',
|
||||
'ptap_comment_status' => 'PTAP comments',
|
||||
'attendance_status' => 'attendance',
|
||||
'class_progress_status' => 'class progress',
|
||||
'exam_draft_status' => 'exam draft',
|
||||
'homework_status' => 'homework',
|
||||
];
|
||||
|
||||
$items = [];
|
||||
|
||||
Regular → Executable
+6
@@ -119,7 +119,12 @@ class AssignmentController extends BaseController
|
||||
}
|
||||
|
||||
$students = [];
|
||||
$seenStudentIds = [];
|
||||
foreach ($studentClasses as $studentClass) {
|
||||
$sid = (int)($studentClass['student_id'] ?? 0);
|
||||
if ($sid <= 0 || isset($seenStudentIds[$sid])) {
|
||||
continue;
|
||||
}
|
||||
if ($sectionSemester === '' && !empty($studentClass['semester'])) {
|
||||
$sectionSemester = (string)$studentClass['semester'];
|
||||
}
|
||||
@@ -149,6 +154,7 @@ class AssignmentController extends BaseController
|
||||
'tuition_paid' => esc($student['tuition_paid'] ? 'Yes' : 'No'),
|
||||
'school_id' => esc($student['school_id']),
|
||||
];
|
||||
$seenStudentIds[$sid] = true;
|
||||
}
|
||||
|
||||
$sectionSemesterDisplay = $sectionSemester !== '' ? $sectionSemester : ((string)($this->semester ?? ''));
|
||||
|
||||
Regular → Executable
Regular → Executable
+21
-17
@@ -797,26 +797,25 @@ public function showUpdateAttendanceForm()
|
||||
}
|
||||
|
||||
$noSchoolDays = [];
|
||||
if ($schoolYearForRange !== '') {
|
||||
$events = [];
|
||||
try {
|
||||
$events = $this->calendarModel->getEvents();
|
||||
} catch (\Throwable $e) {
|
||||
$events = [];
|
||||
try {
|
||||
$events = $this->calendarModel->getEventsBySchoolYearAndSemester(
|
||||
$schoolYearForRange,
|
||||
$semesterNorm !== '' ? $semesterNorm : null
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$events = [];
|
||||
}
|
||||
foreach ($events as $event) {
|
||||
$d = substr((string)($event['date'] ?? ''), 0, 10);
|
||||
if ($d === '' || empty($event['no_school'])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($events as $event) {
|
||||
$d = substr((string)($event['date'] ?? ''), 0, 10);
|
||||
if ($d === '' || empty($event['no_school'])) {
|
||||
continue;
|
||||
}
|
||||
if ($d < $rangeStart || $d > $rangeEnd) {
|
||||
continue;
|
||||
}
|
||||
$noSchoolDays[$d] = true;
|
||||
if ($d < $rangeStart || $d > $rangeEnd) {
|
||||
continue;
|
||||
}
|
||||
$eventYear = trim((string)($event['school_year'] ?? ''));
|
||||
if ($schoolYearForRange !== '' && $eventYear !== '' && $eventYear !== $schoolYearForRange) {
|
||||
continue;
|
||||
}
|
||||
$noSchoolDays[$d] = true;
|
||||
}
|
||||
|
||||
// Total passed attendance days up to this Sunday (inclusive), excluding no-school days.
|
||||
@@ -1005,9 +1004,13 @@ public function showUpdateAttendanceForm()
|
||||
}
|
||||
|
||||
$hasRoster = false;
|
||||
$seenStudents = [];
|
||||
|
||||
foreach ($students as $sc) {
|
||||
$studentId = (int)$sc['student_id'];
|
||||
if ($studentId <= 0 || isset($seenStudents[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
$student = $this->studentModel
|
||||
->select('id, firstname, lastname, school_id')
|
||||
->find($studentId);
|
||||
@@ -1015,6 +1018,7 @@ public function showUpdateAttendanceForm()
|
||||
|
||||
$studentsBySection[$secCode][] = $student;
|
||||
$hasRoster = true;
|
||||
$seenStudents[$studentId] = true;
|
||||
|
||||
// Attendance history
|
||||
$qb = $this->attendanceDataModel
|
||||
|
||||
Regular → Executable
+54
-2
@@ -2003,6 +2003,58 @@ class AttendanceTrackingController extends BaseController
|
||||
return [$subject, $body];
|
||||
}
|
||||
|
||||
private function buildFallbackTemplate(string $code, string $variant, array $context): array
|
||||
{
|
||||
$studentName = (string) ($context['{{student_name}}'] ?? 'the student');
|
||||
$incident = (string) ($context['{{incident_date}}'] ?? date('Y-m-d'));
|
||||
$parentName = (string) ($context['{{parent_name}}'] ?? 'Parent/Guardian');
|
||||
$phone = (string) ($context['{{school_phone}}'] ?? 'the school office');
|
||||
$voicemail = (string) ($context['{{voicemail_phone}}'] ?? 'your voicemail');
|
||||
|
||||
$subject = match (true) {
|
||||
str_starts_with($code, 'ABS_1') => 'Attendance Notice: Unreported Absence',
|
||||
str_starts_with($code, 'ABS_2') => 'Attendance Follow-Up: Two Consecutive Absences',
|
||||
str_starts_with($code, 'ABS_3') => 'Attendance Follow-Up: Three Absences',
|
||||
str_starts_with($code, 'LATE_2') => 'Attendance Notice: Repeated Lateness',
|
||||
str_starts_with($code, 'LATE_3'),
|
||||
str_starts_with($code, 'LATE_4'),
|
||||
str_starts_with($code, 'MIX') => 'Attendance Follow-Up: Repeated Lateness and Absence',
|
||||
default => 'Attendance Notice',
|
||||
};
|
||||
|
||||
$intro = "<p>Insha Allah this email finds you well";
|
||||
if ($variant === 'answered') {
|
||||
$intro .= ", <strong>{$parentName}</strong>. Jazakum Allahu khayran for taking the time to speak with us today.</p>";
|
||||
} elseif ($variant === 'no_answer') {
|
||||
$intro .= ".</p><p>We tried to reach you but could not connect and left a voice message at <strong>{$voicemail}</strong>.</p>";
|
||||
} else {
|
||||
$intro .= ".</p>";
|
||||
}
|
||||
|
||||
$details = match (true) {
|
||||
str_starts_with($code, 'ABS_1')
|
||||
=> "<p>This is to let you know that <strong>{$studentName}</strong> was absent on <strong>{$incident}</strong> without prior notice.</p>",
|
||||
str_starts_with($code, 'ABS_2')
|
||||
=> "<p>This is a reminder that <strong>{$studentName}</strong> has had repeated absences, most recently on <strong>{$incident}</strong>, without prior notice.</p>",
|
||||
str_starts_with($code, 'ABS_3')
|
||||
=> "<p>This is a reminder that <strong>{$studentName}</strong> has been absent three times, most recently on <strong>{$incident}</strong>, without prior notice.</p>",
|
||||
str_starts_with($code, 'LATE_2')
|
||||
=> "<p>This is a reminder that <strong>{$studentName}</strong> has been late multiple times, most recently on <strong>{$incident}</strong>.</p>",
|
||||
str_starts_with($code, 'LATE_3'),
|
||||
str_starts_with($code, 'LATE_4')
|
||||
=> "<p>This is a follow-up that <strong>{$studentName}</strong> has had repeated lateness concerns, most recently on <strong>{$incident}</strong>.</p>",
|
||||
str_starts_with($code, 'MIX')
|
||||
=> "<p>This is a follow-up that <strong>{$studentName}</strong> has had a mix of repeated lateness and absence concerns, most recently on <strong>{$incident}</strong>.</p>",
|
||||
default
|
||||
=> "<p>We'd like to inform you about <strong>{$studentName}</strong>'s recent attendance concern dated <strong>{$incident}</strong>.</p>",
|
||||
};
|
||||
|
||||
$closing = "<p>If your child will be absent or late due to illness or family commitments, please let us know ahead of time.</p>"
|
||||
. "<p>You can email/call/text us at <strong>{$phone}</strong>.</p>";
|
||||
|
||||
return [$subject, $intro . $details . $closing];
|
||||
}
|
||||
|
||||
|
||||
public function compose()
|
||||
{
|
||||
@@ -2033,8 +2085,8 @@ class AttendanceTrackingController extends BaseController
|
||||
$rendered = $this->renderTemplate($code, $variant, $ctx);
|
||||
|
||||
if (!$rendered) {
|
||||
return redirect()->to(site_url('attendance/violations'))
|
||||
->with('error', 'Template not found for ' . $code . ' (' . $variant . ').');
|
||||
log_message('warning', 'Attendance email template missing; using fallback compose body. code=' . $code . ' variant=' . $variant);
|
||||
$rendered = $this->buildFallbackTemplate($code, $variant, $ctx);
|
||||
}
|
||||
|
||||
[$subject, $body] = $rendered;
|
||||
|
||||
Regular → Executable
+136
-22
@@ -10,6 +10,8 @@ use CodeIgniter\I18n\Time;
|
||||
|
||||
class AuthorizedUsersController extends ResourceController
|
||||
{
|
||||
private const TOKEN_TTL_HOURS = 24;
|
||||
|
||||
protected $userModel;
|
||||
protected $authorizedUserModel;
|
||||
|
||||
@@ -18,6 +20,30 @@ class AuthorizedUsersController extends ResourceController
|
||||
$this->userModel = new UserModel();
|
||||
$this->authorizedUserModel = new AuthorizedUserModel();
|
||||
}
|
||||
|
||||
private function requireLogin()
|
||||
{
|
||||
if (!session()->get('is_logged_in')) {
|
||||
return $this->failUnauthorized('Authentication required.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function requireOwnership(array $authorizedUser)
|
||||
{
|
||||
$userId = (int) session()->get('user_id');
|
||||
if ($userId <= 0 || (int) ($authorizedUser['user_id'] ?? 0) !== $userId) {
|
||||
return $this->failForbidden('You do not have access to this resource.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function hashToken(string $token): string
|
||||
{
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
/**
|
||||
* Return a list of authorized users for the logged-in main user.
|
||||
*
|
||||
@@ -25,7 +51,10 @@ class AuthorizedUsersController extends ResourceController
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
|
||||
if ($resp = $this->requireLogin()) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$userId = session()->get('user_id');
|
||||
$authorizedUsers = $this->authorizedUserModel->where('user_id', $userId)->findAll();
|
||||
|
||||
@@ -40,12 +69,20 @@ class AuthorizedUsersController extends ResourceController
|
||||
*/
|
||||
public function show($id = null)
|
||||
{
|
||||
if ($resp = $this->requireLogin()) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$authorizedUser = $this->authorizedUserModel->find($id);
|
||||
|
||||
if (!$authorizedUser) {
|
||||
return $this->failNotFound('Authorized user not found.');
|
||||
}
|
||||
|
||||
if ($resp = $this->requireOwnership($authorizedUser)) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
return $this->respond($authorizedUser);
|
||||
}
|
||||
|
||||
@@ -56,6 +93,10 @@ class AuthorizedUsersController extends ResourceController
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
if ($resp = $this->requireLogin()) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$email = strtolower($this->request->getPost('email'));
|
||||
|
||||
// Validate email
|
||||
@@ -66,19 +107,20 @@ class AuthorizedUsersController extends ResourceController
|
||||
$user = $this->userModel->where('email', $email)->first();
|
||||
|
||||
if (!$user) {
|
||||
return $this->failNotFound('No user found with this email.');
|
||||
return $this->respondCreated(['message' => 'Authorized user added. A confirmation email has been sent.']);
|
||||
}
|
||||
|
||||
// Generate a token for confirmation
|
||||
helper('text');
|
||||
$token = bin2hex(random_bytes(48));
|
||||
$tokenHash = $this->hashToken($token);
|
||||
|
||||
// Add entry to the authorized_users table
|
||||
$this->authorizedUserModel->insert([
|
||||
'user_id' => session()->get('user_id'), // Main user ID
|
||||
'authorized_user_id' => $user['id'],
|
||||
'email' => $email,
|
||||
'token' => $token,
|
||||
'token' => $tokenHash,
|
||||
'status' => 'Pending'
|
||||
]);
|
||||
|
||||
@@ -96,6 +138,10 @@ class AuthorizedUsersController extends ResourceController
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
if ($resp = $this->requireLogin()) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
// Fetch the authorized user
|
||||
$authorizedUser = $this->authorizedUserModel->find($id);
|
||||
|
||||
@@ -103,6 +149,10 @@ class AuthorizedUsersController extends ResourceController
|
||||
return $this->failNotFound('Authorized user not found.');
|
||||
}
|
||||
|
||||
if ($resp = $this->requireOwnership($authorizedUser)) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
// Update the authorized user’s information (e.g., email)
|
||||
$email = strtolower($this->request->getPost('email'));
|
||||
if ($email && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
@@ -122,12 +172,20 @@ class AuthorizedUsersController extends ResourceController
|
||||
*/
|
||||
public function delete($id = null)
|
||||
{
|
||||
if ($resp = $this->requireLogin()) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$authorizedUser = $this->authorizedUserModel->find($id);
|
||||
|
||||
if (!$authorizedUser) {
|
||||
return $this->failNotFound('Authorized user not found.');
|
||||
}
|
||||
|
||||
if ($resp = $this->requireOwnership($authorizedUser)) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
// Delete the authorized user record
|
||||
$this->authorizedUserModel->delete($id);
|
||||
|
||||
@@ -147,16 +205,28 @@ class AuthorizedUsersController extends ResourceController
|
||||
return $this->fail('Invalid confirmation link.');
|
||||
}
|
||||
|
||||
$authorizedUser = $this->authorizedUserModel->where('token', $token)->first();
|
||||
$tokenHash = $this->hashToken($token);
|
||||
$authorizedUser = $this->authorizedUserModel
|
||||
->groupStart()
|
||||
->where('token', $tokenHash)
|
||||
->orWhere('token', $token)
|
||||
->groupEnd()
|
||||
->where('created_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString())
|
||||
->first();
|
||||
|
||||
if (!$authorizedUser) {
|
||||
return $this->fail('Invalid or expired confirmation link.');
|
||||
}
|
||||
|
||||
// Mark the authorized user as active
|
||||
$this->authorizedUserModel->update($authorizedUser['id'], ['status' => 'Active', 'token' => null]);
|
||||
// Mark the authorized user as active and rotate token for password setup
|
||||
$nextToken = bin2hex(random_bytes(48));
|
||||
$nextTokenHash = $this->hashToken($nextToken);
|
||||
$this->authorizedUserModel->update($authorizedUser['id'], [
|
||||
'status' => 'Active',
|
||||
'token' => $nextTokenHash,
|
||||
]);
|
||||
|
||||
return redirect()->to('/set_authorized_user_password/' . $authorizedUser['authorized_user_id']);
|
||||
return redirect()->to('/set_authorized_user_password/' . $authorizedUser['authorized_user_id'] . '?token=' . $nextToken);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,13 +237,36 @@ class AuthorizedUsersController extends ResourceController
|
||||
*/
|
||||
public function setPassword($authorizedUserId)
|
||||
{
|
||||
$token = (string) $this->request->getGet('token');
|
||||
if ($token === '') {
|
||||
return $this->fail('Invalid confirmation link.');
|
||||
}
|
||||
|
||||
$tokenHash = $this->hashToken($token);
|
||||
$authorizedUser = $this->authorizedUserModel
|
||||
->groupStart()
|
||||
->where('token', $tokenHash)
|
||||
->orWhere('token', $token)
|
||||
->groupEnd()
|
||||
->where('authorized_user_id', $authorizedUserId)
|
||||
->where('status', 'Active')
|
||||
->where('updated_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString())
|
||||
->first();
|
||||
|
||||
if (!$authorizedUser) {
|
||||
return $this->fail('Invalid or expired confirmation link.');
|
||||
}
|
||||
|
||||
$user = $this->userModel->find($authorizedUserId);
|
||||
|
||||
if (!$user) {
|
||||
return $this->failNotFound('User not found.');
|
||||
}
|
||||
|
||||
return view('user/set_authorized_user_password', ['userId' => $authorizedUserId]);
|
||||
return view('user/set_authorized_user_password', [
|
||||
'userId' => $authorizedUserId,
|
||||
'token' => $token,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,38 +274,59 @@ class AuthorizedUsersController extends ResourceController
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
/*
|
||||
public function savePassword()
|
||||
public function savePassword($authorizedUserId = null)
|
||||
{
|
||||
// Validate the request
|
||||
$validation = \Config\Services::validation();
|
||||
$validation->setRules([
|
||||
'password' => 'required|min_length[6]',
|
||||
'password' => [
|
||||
'label' => 'Password',
|
||||
'rules' => 'required|min_length[8]|regex_match[/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@\\-=\\+*#$%&!?])[A-Za-z\\d@\\-=\\+*#$%&!?]{8,}$/]',
|
||||
],
|
||||
'password_confirm' => 'required|matches[password]',
|
||||
'user_id' => 'required|integer'
|
||||
'user_id' => 'required|integer',
|
||||
'token' => 'required',
|
||||
]);
|
||||
|
||||
if (!$this->validate($validation->getRules())) {
|
||||
return $this->failValidationErrors($validation->getErrors());
|
||||
}
|
||||
|
||||
// Get the validated input
|
||||
$userId = $this->request->getPost('user_id');
|
||||
$password = $this->request->getPost('password');
|
||||
$userId = (int) $this->request->getPost('user_id');
|
||||
$token = (string) $this->request->getPost('token');
|
||||
$authorizedUserId = $authorizedUserId !== null ? (int) $authorizedUserId : $userId;
|
||||
|
||||
$model = new UserModel();
|
||||
$user = $model->find($userId);
|
||||
if ($userId <= 0 || $authorizedUserId <= 0 || $userId !== $authorizedUserId) {
|
||||
return $this->fail('Invalid request.');
|
||||
}
|
||||
|
||||
$tokenHash = $this->hashToken($token);
|
||||
$authorizedUser = $this->authorizedUserModel
|
||||
->groupStart()
|
||||
->where('token', $tokenHash)
|
||||
->orWhere('token', $token)
|
||||
->groupEnd()
|
||||
->where('authorized_user_id', $authorizedUserId)
|
||||
->where('status', 'Active')
|
||||
->where('updated_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString())
|
||||
->first();
|
||||
|
||||
if (!$authorizedUser) {
|
||||
return $this->fail('Invalid or expired confirmation link.');
|
||||
}
|
||||
|
||||
$user = $this->userModel->find($authorizedUserId);
|
||||
if (!$user) {
|
||||
return $this->failNotFound('User not found.');
|
||||
}
|
||||
|
||||
// Save the password
|
||||
$model->update($userId, ['password' => password_hash($password, PASSWORD_DEFAULT)]);
|
||||
$password = (string) $this->request->getPost('password');
|
||||
$hashedPassword = pbkdf2_hash($password);
|
||||
|
||||
$this->userModel->update($authorizedUserId, ['password' => $hashedPassword]);
|
||||
$this->authorizedUserModel->update($authorizedUser['id'], ['token' => null]);
|
||||
|
||||
return $this->respond(['message' => 'Password has been successfully set.']);
|
||||
}
|
||||
*/
|
||||
/**
|
||||
* Sends a confirmation email to the authorized user.
|
||||
*
|
||||
@@ -242,4 +356,4 @@ class AuthorizedUsersController extends ResourceController
|
||||
log_message('error', 'Failed to send authorized user confirmation email to ' . $email);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+30
-22
@@ -60,13 +60,15 @@ class ClassPreparationController extends BaseController
|
||||
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
|
||||
|
||||
// 1) Get student count per class-section (distinct students, correct semester)
|
||||
$scQ = $this->studentClassModel
|
||||
->select('class_section_id, COUNT(DISTINCT student_id) AS student_count')
|
||||
->where('school_year', $schoolYear);
|
||||
$scQ = $this->db->table('student_class sc')
|
||||
->select('sc.class_section_id, COUNT(DISTINCT sc.student_id) AS student_count', false)
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.school_year', $schoolYear);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$scQ->where('semester', $semester);
|
||||
$scQ->where('sc.semester', $semester);
|
||||
}
|
||||
$classSections = $scQ->groupBy('class_section_id')->findAll();
|
||||
$classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray();
|
||||
|
||||
// 2) Inventory availability — prefer good_qty when present, else condition='good' quantity.
|
||||
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed);
|
||||
@@ -298,14 +300,16 @@ class ClassPreparationController extends BaseController
|
||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
||||
|
||||
// Distinct student count for this term
|
||||
$studentQ = $this->studentClassModel
|
||||
->select('COUNT(DISTINCT student_id) AS cnt')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear);
|
||||
$studentQ = $this->db->table('student_class sc')
|
||||
->select('COUNT(DISTINCT sc.student_id) AS cnt', false)
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.class_section_id', $classSectionId)
|
||||
->where('sc.school_year', $schoolYear);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$studentQ->where('semester', $semester);
|
||||
$studentQ->where('sc.semester', $semester);
|
||||
}
|
||||
$studentRow = $studentQ->first();
|
||||
$studentRow = $studentQ->get()->getRowArray();
|
||||
$studentCount = (int)($studentRow['cnt'] ?? 0);
|
||||
|
||||
// Live calc + adjustments
|
||||
@@ -355,13 +359,15 @@ class ClassPreparationController extends BaseController
|
||||
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
|
||||
|
||||
// Student counts
|
||||
$scQ = $this->studentClassModel
|
||||
->select('class_section_id, COUNT(DISTINCT student_id) AS student_count')
|
||||
->where('school_year', $schoolYear);
|
||||
$scQ = $this->db->table('student_class sc')
|
||||
->select('sc.class_section_id, COUNT(DISTINCT sc.student_id) AS student_count', false)
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.school_year', $schoolYear);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$scQ->where('semester', $semester);
|
||||
$scQ->where('sc.semester', $semester);
|
||||
}
|
||||
$classSections = $scQ->groupBy('class_section_id')->findAll();
|
||||
$classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray();
|
||||
|
||||
// Build inventory availability maps
|
||||
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed);
|
||||
@@ -448,14 +454,16 @@ class ClassPreparationController extends BaseController
|
||||
$now = utc_now();
|
||||
$count = 0;
|
||||
foreach ($ids as $classSectionId) {
|
||||
$studentQ = $this->studentClassModel
|
||||
->select('COUNT(DISTINCT student_id) AS cnt')
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear);
|
||||
$studentQ = $this->db->table('student_class sc')
|
||||
->select('COUNT(DISTINCT sc.student_id) AS cnt', false)
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.class_section_id', $classSectionId)
|
||||
->where('sc.school_year', $schoolYear);
|
||||
if ($limitToSemester && $semester !== '') {
|
||||
$studentQ->where('semester', $semester);
|
||||
$studentQ->where('sc.semester', $semester);
|
||||
}
|
||||
$studentRow = $studentQ->first();
|
||||
$studentRow = $studentQ->get()->getRowArray();
|
||||
$studentCount = (int)($studentRow['cnt'] ?? 0);
|
||||
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
|
||||
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+5
-2
@@ -750,7 +750,9 @@ class DiscountController extends BaseController
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
$newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
|
||||
$discountableTotal = $tuitionSubtotal + $additionalSubtotal;
|
||||
$nonDiscountableTotal = $eventSubtotal;
|
||||
$newTotal = round($discountableTotal + $nonDiscountableTotal, 2);
|
||||
|
||||
// ---- Payments / Discounts / Refunds ----
|
||||
$db = $this->db;
|
||||
@@ -794,7 +796,8 @@ class DiscountController extends BaseController
|
||||
->get()->getRowArray();
|
||||
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
|
||||
|
||||
$newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid);
|
||||
$appliedDiscount = min($totalDisc, $discountableTotal);
|
||||
$newBalance = max(0.0, $newTotal - $appliedDiscount - $totalPaid - $totalRefundPaid);
|
||||
$newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid');
|
||||
|
||||
$updateData = [
|
||||
|
||||
Regular → Executable
Regular → Executable
+157
@@ -170,6 +170,163 @@ class EmailController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one email with a single To recipient and many CC recipients.
|
||||
*
|
||||
* @param string $recipient
|
||||
* @param string[] $ccRecipients
|
||||
* @param string $subject
|
||||
* @param string $htmlMessage
|
||||
* @param string|null $profile
|
||||
* @param string|null $replyToEmail
|
||||
* @param string|null $replyToName
|
||||
* @param array $attachments
|
||||
*/
|
||||
public function sendEmailWithCc(
|
||||
string $recipient,
|
||||
array $ccRecipients,
|
||||
string $subject,
|
||||
string $htmlMessage,
|
||||
?string $profile = null,
|
||||
?string $replyToEmail = null,
|
||||
?string $replyToName = null,
|
||||
array $attachments = []
|
||||
): bool {
|
||||
$ccRecipients = array_values(array_unique(array_filter(array_map(static function ($email) {
|
||||
$email = trim((string) $email);
|
||||
return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
|
||||
}, $ccRecipients))));
|
||||
|
||||
if ($ccRecipients === []) {
|
||||
return $this->sendEmail($recipient, $subject, $htmlMessage, $profile, $replyToEmail, $replyToName, $attachments);
|
||||
}
|
||||
|
||||
$autoload = APPPATH . '../vendor/autoload.php';
|
||||
if (is_file($autoload)) {
|
||||
require_once $autoload;
|
||||
}
|
||||
|
||||
$profile = $this->resolveProfile($profile);
|
||||
$cfg = $this->getProfileConfig($profile);
|
||||
|
||||
if (empty($cfg['host']) || empty($cfg['user']) || $cfg['pass'] === '') {
|
||||
log_message('error', "[mail:$profile] Missing SMTP config (host/user/pass). Check .env MAIL_{$this->envKeyFromProfile($profile)}_* or MAIL_DEFAULT_*.");
|
||||
return false;
|
||||
}
|
||||
|
||||
$debugEnabled = (bool) env('MAIL_DEBUG', false);
|
||||
$timeout = (int) env('MAIL_TIMEOUT', 15);
|
||||
$keepAlive = (bool) env('MAIL_KEEPALIVE', false);
|
||||
$verifyPeer = filter_var(env('MAIL_VERIFY_PEER', 'true'), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
$targetHost = $cfg['host'];
|
||||
$targetPort = (int) $cfg['port'];
|
||||
|
||||
$resolved = @gethostbyname($targetHost);
|
||||
if (!$resolved || $resolved === $targetHost) {
|
||||
log_message('debug', "[mail:$profile] DNS resolve note: host=$targetHost, resolved=$resolved");
|
||||
}
|
||||
|
||||
$sockOk = @fsockopen($targetHost, $targetPort, $errno, $errstr, 5);
|
||||
if (!$sockOk) {
|
||||
log_message('error', "[mail:$profile] Socket preflight failed to {$targetHost}:{$targetPort} (errno=$errno, err=$errstr). Likely firewall/port/encryption mismatch or wrong host.");
|
||||
} else {
|
||||
fclose($sockOk);
|
||||
}
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
|
||||
try {
|
||||
if ($debugEnabled) {
|
||||
ob_start();
|
||||
}
|
||||
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $cfg['host'];
|
||||
$mail->Port = $cfg['port'];
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = $cfg['user'];
|
||||
$mail->Password = $cfg['pass'];
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->Timeout = $timeout;
|
||||
$mail->SMTPKeepAlive = $keepAlive;
|
||||
$mail->SMTPAutoTLS = true;
|
||||
|
||||
if ($cfg['encryption'] === 'ssl') {
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
|
||||
} else {
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
}
|
||||
|
||||
$mail->SMTPOptions = [
|
||||
'ssl' => [
|
||||
'verify_peer' => $verifyPeer,
|
||||
'verify_peer_name' => $verifyPeer,
|
||||
'allow_self_signed' => !$verifyPeer,
|
||||
],
|
||||
];
|
||||
|
||||
$mail->SMTPDebug = $debugEnabled ? 3 : 0;
|
||||
$mail->Debugoutput = 'error_log';
|
||||
|
||||
$mail->setFrom($cfg['fromEmail'], $cfg['fromName']);
|
||||
if (!empty($cfg['returnPath'])) {
|
||||
$mail->Sender = $cfg['returnPath'];
|
||||
}
|
||||
|
||||
$mail->clearReplyTos();
|
||||
$rtEmail = env('MAIL_DEFAULT_REPLY_TO');
|
||||
$rtName = env('MAIL_DEFAULT_REPLY_TO_NAME');
|
||||
if (!$rtEmail || !filter_var($rtEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
$rtEmail = $replyToEmail ?: ($cfg['replyTo'] ?: $cfg['fromEmail']);
|
||||
}
|
||||
if (!$rtName) {
|
||||
$rtName = $replyToName ?: ($cfg['replyToName'] ?: $cfg['fromName']);
|
||||
}
|
||||
$rtName = $this->sanitizeReplyToName($rtName, $cfg['fromName']);
|
||||
if ($rtEmail) {
|
||||
$mail->addReplyTo($rtEmail, $rtName);
|
||||
}
|
||||
|
||||
if (!empty($cfg['dkim']['domain']) && !empty($cfg['dkim']['private']) && !empty($cfg['dkim']['selector'])) {
|
||||
$mail->DKIM_domain = $cfg['dkim']['domain'];
|
||||
$mail->DKIM_private = $cfg['dkim']['private'];
|
||||
$mail->DKIM_selector = $cfg['dkim']['selector'];
|
||||
$mail->DKIM_identity = $cfg['fromEmail'];
|
||||
}
|
||||
|
||||
$mail->addAddress($recipient);
|
||||
foreach ($ccRecipients as $ccRecipient) {
|
||||
$mail->addCC($ccRecipient);
|
||||
}
|
||||
|
||||
foreach ($attachments as $att) {
|
||||
if (!empty($att['path']) && is_file($att['path'])) {
|
||||
$mail->addAttachment($att['path'], $att['name'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = $subject;
|
||||
$mail->Body = $htmlMessage;
|
||||
|
||||
$ok = $mail->send();
|
||||
|
||||
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
|
||||
if ($ok) {
|
||||
log_message('info', "[mail:$profile] Sent to {$recipient} with " . count($ccRecipients) . " CC recipient(s), subj='{$subject}' via {$cfg['host']}:{$cfg['port']}/{$cfg['encryption']}");
|
||||
return true;
|
||||
}
|
||||
|
||||
log_message('error', "[mail:$profile] Failed: {$mail->ErrorInfo}. Debug: {$dbg}");
|
||||
return false;
|
||||
} catch (Exception $e) {
|
||||
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
|
||||
log_message('error', "[mail:$profile] Exception: {$e->getMessage()} | PHPMailer: {$mail->ErrorInfo} | Debug: {$dbg}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve null/alias profile names to a canonical env prefix. */
|
||||
private function resolveProfile(?string $profile): string
|
||||
{
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
+893
-63
File diff suppressed because it is too large
Load Diff
Regular → Executable
+1062
-122
File diff suppressed because it is too large
Load Diff
Regular → Executable
Regular → Executable
Regular → Executable
+78
@@ -406,4 +406,82 @@ class FamilyAdminController extends BaseController
|
||||
|
||||
return service('response')->setBody(view('family/card', ['f' => $family]));
|
||||
}
|
||||
|
||||
public function composeEmail()
|
||||
{
|
||||
$to = trim((string)$this->request->getGet('to'));
|
||||
$name = trim((string)$this->request->getGet('name'));
|
||||
$returnUrl = trim((string)$this->request->getGet('return_url'));
|
||||
if ($returnUrl === '') {
|
||||
$returnUrl = trim((string)$this->request->getServer('HTTP_REFERER'));
|
||||
}
|
||||
if ($returnUrl === '') {
|
||||
$returnUrl = site_url('family');
|
||||
}
|
||||
|
||||
return view('family/compose_email', [
|
||||
'to' => $to,
|
||||
'name' => $name,
|
||||
'return_url' => $returnUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendComposeEmail()
|
||||
{
|
||||
if (!$this->request->is('post')) {
|
||||
return redirect()->to(site_url('family'));
|
||||
}
|
||||
|
||||
$to = trim((string)$this->request->getPost('to'));
|
||||
$subject = trim((string)$this->request->getPost('subject'));
|
||||
$html = (string)($this->request->getPost('html') ?? '');
|
||||
$returnUrl = trim((string)$this->request->getPost('return_url'));
|
||||
|
||||
if ($returnUrl === '' || !$this->isLocalReturnUrl($returnUrl)) {
|
||||
$returnUrl = site_url('family');
|
||||
}
|
||||
|
||||
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Please enter a valid email address.');
|
||||
}
|
||||
if ($subject === '') {
|
||||
return redirect()->back()->withInput()->with('error', 'Subject is required.');
|
||||
}
|
||||
if (trim($html) === '') {
|
||||
return redirect()->back()->withInput()->with('error', 'Email body is required.');
|
||||
}
|
||||
|
||||
$wrapped = view('emails/custom_html', [
|
||||
'subject' => $subject,
|
||||
'body_html' => $html,
|
||||
]);
|
||||
|
||||
$mailer = new \App\Controllers\View\EmailController();
|
||||
$ok = $mailer->sendEmail($to, $subject, $wrapped, 'communication');
|
||||
|
||||
if ($ok) {
|
||||
return redirect()->to($returnUrl)->with('status', 'Email sent.');
|
||||
}
|
||||
|
||||
return redirect()->to($returnUrl)->with('error', 'Unable to send email.');
|
||||
}
|
||||
|
||||
private function isLocalReturnUrl(string $url): bool
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '') return false;
|
||||
|
||||
$parts = parse_url($url);
|
||||
if ($parts === false) return false;
|
||||
|
||||
if (!empty($parts['scheme']) || !empty($parts['host'])) {
|
||||
$base = parse_url(site_url('/'));
|
||||
if (!$base || empty($base['host'])) return false;
|
||||
$hostMatch = strcasecmp((string)($parts['host'] ?? ''), (string)$base['host']) === 0;
|
||||
return $hostMatch;
|
||||
}
|
||||
|
||||
// Relative URL (e.g., /family?x=1 or family?x=1)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
+125
-1
@@ -224,6 +224,10 @@ class FilesController extends Controller
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
if (!$this->canAccessExamDraftFile($name, $subdir)) {
|
||||
return $this->response->setStatusCode(403, 'You are not allowed to access this file.');
|
||||
}
|
||||
|
||||
$mime = 'application/octet-stream';
|
||||
if (function_exists('finfo_open')) {
|
||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
||||
@@ -271,8 +275,8 @@ class FilesController extends Controller
|
||||
|
||||
private function buildDraftDownloadName(string $filename, string $subdir): string
|
||||
{
|
||||
$column = $subdir === 'finals' ? 'final_file' : 'teacher_file';
|
||||
$db = Database::connect();
|
||||
$column = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
|
||||
$row = $db->table('exam_drafts ed')
|
||||
->select('ed.version, ed.exam_type, ed.class_section_id, cs.class_section_name')
|
||||
->join('classSection cs', 'cs.class_section_id = ed.class_section_id', 'left')
|
||||
@@ -301,4 +305,124 @@ class FilesController extends Controller
|
||||
$value = trim($value, '_');
|
||||
return $value === '' ? 'Exam' : mb_strtolower($value);
|
||||
}
|
||||
|
||||
private function resolveExamDraftFileColumn($db): string
|
||||
{
|
||||
try {
|
||||
$fields = $db->getFieldNames('exam_drafts');
|
||||
if (in_array('teacher_file', $fields, true)) {
|
||||
return 'teacher_file';
|
||||
}
|
||||
if (in_array('author_file', $fields, true)) {
|
||||
return 'author_file';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'FilesController::resolveExamDraftFileColumn error: ' . $e->getMessage());
|
||||
}
|
||||
return 'teacher_file';
|
||||
}
|
||||
|
||||
private function resolveExamDraftAuthorIdColumn($db): string
|
||||
{
|
||||
try {
|
||||
$fields = $db->getFieldNames('exam_drafts');
|
||||
if (in_array('teacher_id', $fields, true)) {
|
||||
return 'teacher_id';
|
||||
}
|
||||
if (in_array('author_id', $fields, true)) {
|
||||
return 'author_id';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'FilesController::resolveExamDraftAuthorIdColumn error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return 'teacher_id';
|
||||
}
|
||||
|
||||
private function canAccessExamDraftFile(string $filename, string $subdir): bool
|
||||
{
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = strtolower((string) (session()->get('role') ?? ''));
|
||||
if (in_array($role, ['admin', 'administrator', 'principal'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$db = Database::connect();
|
||||
$fileColumn = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
|
||||
$authorIdColumn = $this->resolveExamDraftAuthorIdColumn($db);
|
||||
|
||||
$draft = $db->table('exam_drafts ed')
|
||||
->select('ed.class_section_id, ed.school_year, ed.semester, ed.status, ed.' . $authorIdColumn . ' AS draft_author_id')
|
||||
->where('ed.' . $fileColumn, $filename)
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if (empty($draft)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$authorId = (int) ($draft['draft_author_id'] ?? 0);
|
||||
if ($authorId > 0 && $authorId === $userId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$classSectionId = (int) ($draft['class_section_id'] ?? 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = strtolower(trim((string) ($draft['status'] ?? '')));
|
||||
if ($subdir === 'drafts' && $status === 'draft') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentSchoolYear = trim((string) (session()->get('school_year') ?? ''));
|
||||
$currentSemester = trim((string) (session()->get('semester') ?? ''));
|
||||
$draftSchoolYear = trim((string) ($draft['school_year'] ?? ''));
|
||||
$draftSemester = trim((string) ($draft['semester'] ?? ''));
|
||||
|
||||
$hasCurrentAssignment = $db->table('teacher_class')
|
||||
->select('id')
|
||||
->where('teacher_id', $userId)
|
||||
->where('class_section_id', $classSectionId);
|
||||
|
||||
if ($currentSchoolYear !== '') {
|
||||
$hasCurrentAssignment->where('school_year', $currentSchoolYear);
|
||||
}
|
||||
|
||||
$hasCurrentAssignment = $hasCurrentAssignment->limit(1)->countAllResults() > 0;
|
||||
|
||||
if ($hasCurrentAssignment) {
|
||||
if ($subdir === 'finals') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $draftSchoolYear === '' || $currentSchoolYear === '' || $draftSchoolYear === $currentSchoolYear;
|
||||
}
|
||||
|
||||
$assignmentQuery = $db->table('teacher_class')
|
||||
->select('id')
|
||||
->where('teacher_id', $userId)
|
||||
->where('class_section_id', $classSectionId);
|
||||
|
||||
if ($draftSchoolYear !== '') {
|
||||
$assignmentQuery->where('school_year', $draftSchoolYear);
|
||||
}
|
||||
|
||||
$hasDraftYearAssignment = $assignmentQuery->limit(1)->countAllResults() > 0;
|
||||
if (!$hasDraftYearAssignment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($subdir === 'finals') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $draftSemester === '' || $currentSemester === '' || $draftSemester === $currentSemester;
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
+103
-22
@@ -68,17 +68,45 @@ public function financialReport()
|
||||
}
|
||||
|
||||
// === Invoices ===
|
||||
$invoices = $invoiceModel
|
||||
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
|
||||
->join('users', 'users.id = invoices.parent_id')
|
||||
->findAll();
|
||||
// Business rule: one active invoice per parent per school year.
|
||||
// If legacy data has multiple invoices, show only the latest one to avoid reporting older invoices as "Unpaid"
|
||||
// after new charges/payments update the current invoice.
|
||||
$db = \Config\Database::connect();
|
||||
if (!empty($schoolYear)) {
|
||||
$latestSub = $db->table('invoices')
|
||||
->select('parent_id, MAX(id) AS max_id')
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('parent_id')
|
||||
->getCompiledSelect();
|
||||
|
||||
$invBuilder = $db->table('invoices')
|
||||
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name", false)
|
||||
->join("($latestSub) latest", 'latest.max_id = invoices.id', 'inner', false)
|
||||
->join('users', 'users.id = invoices.parent_id', 'left');
|
||||
|
||||
if ($hasFrom) {
|
||||
$invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom);
|
||||
}
|
||||
if ($hasTo) {
|
||||
$invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo);
|
||||
}
|
||||
|
||||
$invoices = $invBuilder->get()->getResultArray();
|
||||
} else {
|
||||
$invoices = $invoiceModel
|
||||
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
|
||||
->join('users', 'users.id = invoices.parent_id')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
$invoiceIds = array_values(array_unique(array_filter(array_map(static function ($inv) {
|
||||
$id = (int)($inv['id'] ?? 0);
|
||||
return $id > 0 ? $id : null;
|
||||
}, $invoices))));
|
||||
|
||||
// Helper to build a fresh, filtered PaymentModel each time (so filters don't get lost between queries)
|
||||
$buildPaymentModel = function () use ($schoolYear, $dateFrom, $dateTo) {
|
||||
$pm = new PaymentModel();
|
||||
if ($schoolYear) {
|
||||
$pm->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($dateFrom)) {
|
||||
$pm->where('DATE(payment_date) >=', $dateFrom);
|
||||
}
|
||||
@@ -114,7 +142,13 @@ public function financialReport()
|
||||
};
|
||||
|
||||
// === Payments aggregated by invoice_id (for the "Paid" column) ===
|
||||
$payments = $applyPaymentFilters($buildPaymentModel())
|
||||
$paymentsQuery = $applyPaymentFilters($buildPaymentModel());
|
||||
if (!empty($invoiceIds)) {
|
||||
$paymentsQuery->whereIn('invoice_id', $invoiceIds);
|
||||
} else {
|
||||
$paymentsQuery->where('invoice_id', -1);
|
||||
}
|
||||
$payments = $paymentsQuery
|
||||
->select('invoice_id, SUM(paid_amount) AS paid_amount')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy('invoice_id')
|
||||
@@ -138,6 +172,7 @@ public function financialReport()
|
||||
SUM(paid_amount) AS amount
|
||||
")
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->whereIn('invoice_id', $invoiceIds ?: [-1])
|
||||
->groupBy('invoice_id, method')
|
||||
->findAll();
|
||||
|
||||
@@ -161,6 +196,7 @@ public function financialReport()
|
||||
SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN paid_amount ELSE 0 END) AS total_credit
|
||||
")
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->whereIn('invoice_id', $invoiceIds ?: [-1])
|
||||
->first() ?? [];
|
||||
|
||||
$paymentTotals = [
|
||||
@@ -216,25 +252,28 @@ public function financialReport()
|
||||
$schoolYears[] = (string)$schoolYear;
|
||||
}
|
||||
|
||||
$eventFeesTotal = $this->getEventFeesTotal($schoolYear, $dateFrom, $dateTo);
|
||||
|
||||
// JSON API support
|
||||
if ($this->wantsJson() || strtolower((string)($this->request->getGet('format') ?? '')) === 'json') {
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'selectedYear' => $schoolYear,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'selectedYear' => $schoolYear,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'schoolYears' => $schoolYears,
|
||||
'invoices' => $invoices,
|
||||
'payments' => $payments,
|
||||
'paymentBreakdown' => $paymentBreakdown,
|
||||
'paymentTotals' => $paymentTotals,
|
||||
'refunds' => $refunds,
|
||||
'expenses' => $expenses,
|
||||
'reimbursements' => $reimbursements,
|
||||
'discounts' => $discounts,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
'refunds' => $refunds,
|
||||
'expenses' => $expenses,
|
||||
'reimbursements' => $reimbursements,
|
||||
'discounts' => $discounts,
|
||||
'eventFeesTotal' => $eventFeesTotal,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('payment/financial_report', [
|
||||
@@ -246,6 +285,7 @@ public function financialReport()
|
||||
'expenses' => $expenses,
|
||||
'reimbursements' => $reimbursements,
|
||||
'discounts' => $discounts,
|
||||
'eventFeesTotal' => $eventFeesTotal,
|
||||
'selectedYear' => $schoolYear,
|
||||
'schoolYears' => $schoolYears,
|
||||
'dateFrom' => $dateFrom,
|
||||
@@ -1058,6 +1098,7 @@ public function financialReport()
|
||||
$amountCollected = $totalPaid;
|
||||
$netAmount = ($totalCharges - $totalDiscounts - $totalRefunds);
|
||||
|
||||
$totalEventFees = $this->getEventFeesTotal($schoolYear, $invoiceDateFrom, $invoiceDateTo);
|
||||
return [
|
||||
'schoolYear' => $schoolYear,
|
||||
'dateFrom' => $dateFrom,
|
||||
@@ -1073,9 +1114,28 @@ public function financialReport()
|
||||
'amountCollected' => $amountCollected,
|
||||
'totalUnpaid' => $totalUnpaid,
|
||||
'netAmount' => $netAmount,
|
||||
'totalEventFees' => $totalEventFees,
|
||||
];
|
||||
}
|
||||
|
||||
private function getEventFeesTotal(?string $schoolYear, ?string $dateFrom, ?string $dateTo): float
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$builder = $db->table('event_charges ec')
|
||||
->select('COALESCE(SUM(ec.charged),0) AS amount', false);
|
||||
if (!empty($schoolYear)) {
|
||||
$builder->where('ec.school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($dateFrom)) {
|
||||
$builder->where('DATE(ec.created_at) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$builder->where('DATE(ec.created_at) <=', $dateTo);
|
||||
}
|
||||
$row = $builder->get()->getRowArray();
|
||||
return $row ? (float)($row['amount'] ?? 0) : 0.0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Management page: list parents with outstanding balances (> 0) for a school year.
|
||||
@@ -1226,6 +1286,23 @@ public function financialReport()
|
||||
$byParent[$pid]['total_balance'] += $extra;
|
||||
}
|
||||
|
||||
$eventFeesPerParent = [];
|
||||
try {
|
||||
$eventFeesRows = $db->table('event_charges ec')
|
||||
->select('ec.parent_id, COALESCE(SUM(ec.charged),0) AS event_fees', false)
|
||||
->where('ec.school_year', $schoolYear)
|
||||
->groupBy('ec.parent_id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($eventFeesRows as $row) {
|
||||
$pid = (int)($row['parent_id'] ?? 0);
|
||||
if ($pid <= 0) continue;
|
||||
$eventFeesPerParent[$pid] = (float)($row['event_fees'] ?? 0);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore, fallback to no event fees data
|
||||
}
|
||||
|
||||
// Reduce into rows list; only parents with positive balance
|
||||
// Also compute remaining installments and suggested monthly amount
|
||||
$rows = [];
|
||||
@@ -1283,10 +1360,11 @@ public function financialReport()
|
||||
$parentIds = array_values(array_unique(array_map(static fn($r) => (int)($r['parent_id'] ?? 0), $rows)));
|
||||
$hasPayments = [];
|
||||
$paidTotals = [];
|
||||
$paymentCounts = [];
|
||||
if (!empty($parentIds)) {
|
||||
try {
|
||||
$pRows = $db->table('payments')
|
||||
->select('parent_id, SUM(paid_amount) AS total_paid')
|
||||
->select('parent_id, SUM(paid_amount) AS total_paid, COUNT(*) AS payment_count')
|
||||
->whereIn('parent_id', $parentIds)
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('parent_id')
|
||||
@@ -1296,6 +1374,7 @@ public function financialReport()
|
||||
if ($pid > 0) {
|
||||
$hasPayments[$pid] = true;
|
||||
$paidTotals[$pid] = (float)($pr['total_paid'] ?? 0);
|
||||
$paymentCounts[$pid] = (int)($pr['payment_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
@@ -1303,7 +1382,7 @@ public function financialReport()
|
||||
}
|
||||
}
|
||||
|
||||
$dataRows = array_map(function(array $r) use ($hasPayments, $paidTotals, $nextInstallmentYmd) {
|
||||
$dataRows = array_map(function(array $r) use ($hasPayments, $paidTotals, $paymentCounts, $nextInstallmentYmd, $eventFeesPerParent) {
|
||||
$pid = (int)($r['parent_id'] ?? 0);
|
||||
$name = trim((string)($r['firstname'] ?? '') . ' ' . (string)($r['lastname'] ?? ''));
|
||||
return [
|
||||
@@ -1317,8 +1396,10 @@ public function financialReport()
|
||||
'installment_amount' => (float)($r['installment_amount'] ?? 0),
|
||||
'type' => isset($hasPayments[$pid]) ? 'installment' : 'no_payment',
|
||||
'total_paid' => isset($paidTotals[$pid]) ? (float)$paidTotals[$pid] : 0.0,
|
||||
'payment_count' => isset($paymentCounts[$pid]) ? (int)$paymentCounts[$pid] : 0,
|
||||
'has_installment'=> isset($hasPayments[$pid]) ? 1 : 0,
|
||||
'next_installment' => $nextInstallmentYmd,
|
||||
'event_fees' => (float)($eventFeesPerParent[$pid] ?? 0),
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
|
||||
Regular → Executable
+29
-1
@@ -421,17 +421,45 @@ class FlagController extends Controller
|
||||
log_message('debug', 'Flag state: ' . $this->request->getPost('flag_state'));
|
||||
|
||||
$currentFlagModel = new CurrentFlagModel();
|
||||
$userId = session()->get('user_id');
|
||||
|
||||
// Get the new flag state from the form
|
||||
$newState = $this->request->getPost('flag_state');
|
||||
$stateDescription = (string) ($this->request->getPost('state_description') ?? '');
|
||||
$actionTaken = (string) ($this->request->getPost('action_taken') ?? '');
|
||||
|
||||
if (!$newState) {
|
||||
session()->setFlashdata('error', 'incident state not provided.');
|
||||
return $this->index();
|
||||
}
|
||||
|
||||
$update = ['flag_state' => $newState];
|
||||
if ($newState === 'Closed') {
|
||||
$update['updated_by_closed'] = $userId;
|
||||
if ($stateDescription !== '') {
|
||||
$update['close_description'] = $stateDescription;
|
||||
}
|
||||
if ($actionTaken !== '') {
|
||||
$update['action_taken'] = $actionTaken;
|
||||
}
|
||||
} elseif ($newState === 'Canceled') {
|
||||
$update['updated_by_canceled'] = $userId;
|
||||
if ($stateDescription !== '') {
|
||||
$update['cancel_description'] = $stateDescription;
|
||||
}
|
||||
if ($actionTaken !== '') {
|
||||
$update['action_taken'] = $actionTaken;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the flag state in the database
|
||||
if ($currentFlagModel->update($id, ['flag_state' => $newState])) {
|
||||
if ($currentFlagModel->update($id, $update)) {
|
||||
if ($newState === 'Closed' || $newState === 'Canceled') {
|
||||
$flagData = $currentFlagModel->find($id);
|
||||
if ($flagData) {
|
||||
return $this->moveToHistory($flagData);
|
||||
}
|
||||
}
|
||||
session()->setFlashdata('success', 'Incident state updated successfully!');
|
||||
} else {
|
||||
$errors = $currentFlagModel->errors();
|
||||
|
||||
Regular → Executable
Regular → Executable
+203
-11
@@ -28,6 +28,7 @@ use App\Models\PlacementLevelModel;
|
||||
use App\Models\PlacementBatchModel;
|
||||
use App\Models\PlacementScoreModel;
|
||||
use App\Models\GradingLockModel;
|
||||
use App\Services\NavbarService;
|
||||
|
||||
//use App\Models\ScoreModel;
|
||||
|
||||
@@ -277,7 +278,7 @@ class GradingController extends Controller
|
||||
$semEsc = $this->db->escape($semester);
|
||||
$yrEsc = $this->db->escape($schoolYear);
|
||||
|
||||
$rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear);
|
||||
$rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear, $semester);
|
||||
|
||||
// Preload quiz/homework/project/participation/midterm score counts to distinguish true zeros from empty scores
|
||||
$quizCounts = [];
|
||||
@@ -423,7 +424,7 @@ class GradingController extends Controller
|
||||
}
|
||||
}
|
||||
// Reload rows after refresh
|
||||
$rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear);
|
||||
$rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear, $semester);
|
||||
}
|
||||
|
||||
// Build structures keyed by BUSINESS section id
|
||||
@@ -1079,19 +1080,22 @@ class GradingController extends Controller
|
||||
$schoolYears = $this->getSchoolYearsForScores($schoolYear);
|
||||
$rows = $this->fetchBelowSixtyRows($schoolYear, $semester);
|
||||
|
||||
$canViewGrading = $this->userHasMenuUrl('grading');
|
||||
|
||||
return view('grading/below_sixty', [
|
||||
'rows' => $rows,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
'schoolYears' => $schoolYears,
|
||||
'canViewGrading' => $canViewGrading,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendBelowSixtyEmail()
|
||||
public function editBelowSixtyEmail()
|
||||
{
|
||||
$studentId = (int)$this->request->getPost('student_id');
|
||||
$semester = trim((string)$this->request->getPost('semester'));
|
||||
$schoolYear = trim((string)$this->request->getPost('school_year'));
|
||||
$studentId = (int)$this->request->getGet('student_id');
|
||||
$semester = trim((string)$this->request->getGet('semester'));
|
||||
$schoolYear = trim((string)$this->request->getGet('school_year'));
|
||||
|
||||
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
|
||||
return redirect()->back()->with('error', 'Missing student or term.');
|
||||
@@ -1103,6 +1107,65 @@ class GradingController extends Controller
|
||||
}
|
||||
|
||||
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||
$subject = $this->buildBelowSixtySubject($studentName, $semester, $schoolYear);
|
||||
$parentName = $this->fetchBelowSixtyParentName($studentId);
|
||||
|
||||
$scores = [
|
||||
'homework_avg' => $row['homework_avg'] ?? null,
|
||||
'project_avg' => $row['project_avg'] ?? null,
|
||||
'participation_score' => $row['participation_score'] ?? null,
|
||||
'test_avg' => $row['test_avg'] ?? null,
|
||||
'ptap_score' => $row['ptap_score'] ?? null,
|
||||
'attendance_score' => $row['attendance_score'] ?? null,
|
||||
'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
|
||||
'semester_score' => $row['semester_score'] ?? null,
|
||||
];
|
||||
|
||||
$emailData = [
|
||||
'title' => $subject,
|
||||
'parent_name' => $parentName,
|
||||
'student_name' => $studentName !== '' ? $studentName : 'your student',
|
||||
'class_section_name' => $row['class_section_name'] ?? '',
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'scores' => $scores,
|
||||
'comment' => $row['comment'] ?? '',
|
||||
'sent_at' => utc_now(),
|
||||
];
|
||||
|
||||
$html = view('emails/below_sixty_performance', $emailData, ['saveData' => true]);
|
||||
|
||||
return view('grading/below_sixty_email_editor', [
|
||||
'studentId' => $studentId,
|
||||
'studentName' => $studentName,
|
||||
'semester' => $semester,
|
||||
'schoolYear' => $schoolYear,
|
||||
'subject' => $subject,
|
||||
'html' => $html,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendBelowSixtyEmail()
|
||||
{
|
||||
$studentId = (int)$this->request->getPost('student_id');
|
||||
$semester = trim((string)$this->request->getPost('semester'));
|
||||
$schoolYear = trim((string)$this->request->getPost('school_year'));
|
||||
$subjectInput = trim((string)$this->request->getPost('subject'));
|
||||
$htmlInput = (string)($this->request->getPost('html') ?? '');
|
||||
|
||||
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
|
||||
return redirect()->back()->with('error', 'Missing student or term.');
|
||||
}
|
||||
|
||||
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
|
||||
if (empty($row)) {
|
||||
return redirect()->back()->with('error', 'Student record not found for the selected term.');
|
||||
}
|
||||
|
||||
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||
$subject = $subjectInput !== ''
|
||||
? $subjectInput
|
||||
: $this->buildBelowSixtySubject($studentName, $semester, $schoolYear);
|
||||
$scores = [
|
||||
'homework_avg' => $row['homework_avg'] ?? null,
|
||||
'project_avg' => $row['project_avg'] ?? null,
|
||||
@@ -1122,8 +1185,13 @@ class GradingController extends Controller
|
||||
'school_year' => $schoolYear,
|
||||
'scores' => $scores,
|
||||
'comment' => $row['comment'] ?? '',
|
||||
'subject' => $subject,
|
||||
];
|
||||
|
||||
if (trim($htmlInput) !== '') {
|
||||
$payload['html'] = $htmlInput;
|
||||
}
|
||||
|
||||
Events::trigger('below60.email', $payload);
|
||||
|
||||
return redirect()->back()->with('status', 'Email sent to parent(s).');
|
||||
@@ -1148,6 +1216,14 @@ class GradingController extends Controller
|
||||
|
||||
$flagModel = new CurrentFlagModel();
|
||||
$semKey = strtolower(trim($semester));
|
||||
$redirectUrl = base_url('grading/below-60');
|
||||
$query = http_build_query([
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
]);
|
||||
if ($query !== '') {
|
||||
$redirectUrl .= '?' . $query;
|
||||
}
|
||||
|
||||
$existing = $flagModel
|
||||
->where('student_id', $studentId)
|
||||
@@ -1158,11 +1234,14 @@ class GradingController extends Controller
|
||||
|
||||
$userId = (int)(session()->get('user_id') ?? 0) ?: null;
|
||||
$now = utc_now();
|
||||
$ok = true;
|
||||
|
||||
if ($existing) {
|
||||
$data = [
|
||||
'flag_state' => $status,
|
||||
'flag_datetime' => $now,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if ($status === 'Open') {
|
||||
@@ -1178,7 +1257,7 @@ class GradingController extends Controller
|
||||
$data['close_description'] = trim($prev . PHP_EOL . $note);
|
||||
}
|
||||
}
|
||||
$flagModel->update((int)$existing['id'], $data);
|
||||
$ok = (bool) $flagModel->update((int)$existing['id'], $data);
|
||||
} else {
|
||||
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
|
||||
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||
@@ -1201,10 +1280,21 @@ class GradingController extends Controller
|
||||
$data['updated_by_closed'] = $userId;
|
||||
if ($note !== '') $data['close_description'] = $note;
|
||||
}
|
||||
$flagModel->insert($data);
|
||||
$ok = (bool) $flagModel->insert($data);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('status', 'Status updated.');
|
||||
if (!$ok) {
|
||||
log_message('error', 'updateBelowSixtyStatus failed', [
|
||||
'student_id' => $studentId,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'status' => $status,
|
||||
'errors' => $flagModel->errors(),
|
||||
]);
|
||||
return redirect()->to($redirectUrl)->with('error', 'Failed to update status.');
|
||||
}
|
||||
|
||||
return redirect()->to($redirectUrl)->with('status', 'Status updated.');
|
||||
}
|
||||
|
||||
public function scheduleBelowSixty()
|
||||
@@ -1487,7 +1577,7 @@ class GradingController extends Controller
|
||||
* @param string $schoolYear Raw school year value for filtering student_class
|
||||
* @return array
|
||||
*/
|
||||
private function buildGradingRows(string $semEsc, string $yrEsc, string $schoolYear): array
|
||||
private function buildGradingRows(string $semEsc, string $yrEsc, string $schoolYear, string $semesterRaw): array
|
||||
{
|
||||
$builder = $this->db->table('student_class sc')
|
||||
->select([
|
||||
@@ -1515,6 +1605,7 @@ class GradingController extends Controller
|
||||
'ss_b.class_section_id AS matched_biz_csid',
|
||||
'ss_p.class_section_id AS matched_pk_csid'
|
||||
])
|
||||
->distinct()
|
||||
->join('`classSection` cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
||||
->join('students s', 's.id = sc.student_id', 'inner')
|
||||
->join(
|
||||
@@ -1729,9 +1820,10 @@ class GradingController extends Controller
|
||||
}
|
||||
|
||||
$statusMap = [];
|
||||
$noteMap = [];
|
||||
if (!empty($studentIds)) {
|
||||
$flagRows = $this->db->table('current_flag')
|
||||
->select('student_id, flag_state')
|
||||
->select('student_id, flag_state, open_description, close_description')
|
||||
->where('flag', 'grade')
|
||||
->where('school_year', $schoolYear)
|
||||
->where("LOWER(TRIM(semester))", $semesterKey)
|
||||
@@ -1742,6 +1834,12 @@ class GradingController extends Controller
|
||||
$sid = (int)($row['student_id'] ?? 0);
|
||||
if ($sid <= 0) continue;
|
||||
$statusMap[$sid] = (string)($row['flag_state'] ?? '');
|
||||
$openNote = trim((string)($row['open_description'] ?? ''));
|
||||
$closeNote = trim((string)($row['close_description'] ?? ''));
|
||||
$noteMap[$sid] = [
|
||||
'open' => $openNote,
|
||||
'closed' => $closeNote,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1750,6 +1848,15 @@ class GradingController extends Controller
|
||||
$row['comment'] = $commentMap[$sid] ?? '';
|
||||
$flagState = strtolower(trim((string)($statusMap[$sid] ?? '')));
|
||||
$row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open';
|
||||
$noteBag = $noteMap[$sid] ?? ['open' => '', 'closed' => ''];
|
||||
$rawNote = $row['status'] === 'Closed' ? (string)$noteBag['closed'] : (string)$noteBag['open'];
|
||||
if ($rawNote !== '') {
|
||||
$lines = preg_split('/\R/', $rawNote);
|
||||
$lines = array_values(array_filter(array_map('trim', $lines), static fn($val) => $val !== ''));
|
||||
$row['note'] = $lines ? end($lines) : '';
|
||||
} else {
|
||||
$row['note'] = '';
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
|
||||
@@ -1799,6 +1906,91 @@ class GradingController extends Controller
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function userHasMenuUrl(string $needle): bool
|
||||
{
|
||||
$needle = strtolower(trim($needle));
|
||||
if ($needle === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rawRole = session()->get('role');
|
||||
$roles = is_array($rawRole) ? $rawRole : [$rawRole ?? 'guest'];
|
||||
$roles = array_values(array_filter(array_map('strval', $roles)));
|
||||
if (empty($roles)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$service = new NavbarService();
|
||||
$menu = $service->getMenuForRoles($roles);
|
||||
if (empty($menu)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalize = static function (string $url) use ($needle): string {
|
||||
$url = strtolower(trim($url));
|
||||
if ($url === '') return '';
|
||||
$url = preg_replace('#^https?://[^/]+/#i', '', $url);
|
||||
$url = ltrim($url, '/');
|
||||
return $url;
|
||||
};
|
||||
|
||||
$target = $normalize($needle);
|
||||
$stack = $menu;
|
||||
while (!empty($stack)) {
|
||||
$node = array_shift($stack);
|
||||
if (!empty($node['url'])) {
|
||||
$url = $normalize((string)$node['url']);
|
||||
if ($url !== '' && $url === $target) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!empty($node['children']) && is_array($node['children'])) {
|
||||
foreach ($node['children'] as $child) {
|
||||
$stack[] = $child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function fetchBelowSixtyParentName(int $studentId): string
|
||||
{
|
||||
$parentName = 'Parent/Guardian';
|
||||
try {
|
||||
$rows = $this->db->query(
|
||||
"SELECT u.firstname, u.lastname
|
||||
FROM family_students fs
|
||||
JOIN family_guardians fg ON fg.family_id = fs.family_id
|
||||
JOIN users u ON u.id = fg.user_id
|
||||
WHERE fs.student_id = ?
|
||||
ORDER BY fg.is_primary DESC, u.lastname, u.firstname
|
||||
LIMIT 1",
|
||||
[$studentId]
|
||||
)->getResultArray();
|
||||
if (!empty($rows[0])) {
|
||||
$candidate = trim((string)($rows[0]['firstname'] ?? '') . ' ' . (string)($rows[0]['lastname'] ?? ''));
|
||||
if ($candidate !== '') {
|
||||
$parentName = $candidate;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
return $parentName;
|
||||
}
|
||||
|
||||
private function buildBelowSixtySubject(string $studentName, string $semester, string $schoolYear): string
|
||||
{
|
||||
$subject = 'Student Performance Alert';
|
||||
if ($studentName !== '') {
|
||||
$subject .= ' — ' . $studentName;
|
||||
}
|
||||
if ($semester !== '' || $schoolYear !== '') {
|
||||
$subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
|
||||
}
|
||||
return $subject;
|
||||
}
|
||||
|
||||
private function fetchBelowSixtyMeetingContext(int $studentId, string $schoolYear, string $semester): array
|
||||
{
|
||||
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
|
||||
|
||||
Regular → Executable
Regular → Executable
+2
-4
@@ -482,6 +482,7 @@ class HomeworkController extends Controller
|
||||
// Step 1: Get student IDs from student_class table
|
||||
$studentClassRows = $this->studentClassModel
|
||||
->select('student_id')
|
||||
->distinct()
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
@@ -534,10 +535,7 @@ class HomeworkController extends Controller
|
||||
private function getStudentsWithHomeworkScores($classSectionId, $homeworkHeaders, $semester, $schoolYear)
|
||||
{
|
||||
$semVariants = $this->getSemesterVariants($semester);
|
||||
$studentClasses = $this->studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
$studentClasses = $this->studentClassModel->getClassStudents($classSectionId, $schoolYear, null);
|
||||
$students = [];
|
||||
|
||||
foreach ($studentClasses as $sc) {
|
||||
|
||||
Regular → Executable
+3
@@ -86,6 +86,7 @@ class HomeworkTrackingController extends BaseController
|
||||
|
||||
$hasHomework = [];
|
||||
$hwEnteredAt = [];
|
||||
$homeworkSubmissionCounts = [];
|
||||
foreach ($rows as $r) {
|
||||
$csid = (int)($r['class_section_id'] ?? 0);
|
||||
$hi = (int)($r['homework_index'] ?? 0);
|
||||
@@ -94,6 +95,7 @@ class HomeworkTrackingController extends BaseController
|
||||
$hasHomework[$csid][$hi] = true;
|
||||
$dateStr = substr((string)($r['first_created'] ?? ''), 0, 10);
|
||||
$hwEnteredAt[$csid][$hi] = $dateStr ?: null;
|
||||
$homeworkSubmissionCounts[$csid] = ($homeworkSubmissionCounts[$csid] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +219,7 @@ class HomeworkTrackingController extends BaseController
|
||||
'teachers' => $teachersPage,
|
||||
'hasHomework' => $hasHomework,
|
||||
'hwEnteredAt' => $hwEnteredAt,
|
||||
'homeworkSubmissionCounts' => $homeworkSubmissionCounts,
|
||||
'hasHomeworkByDate' => $hasHomeworkByDate,
|
||||
'hwEnteredAtByDate' => $hwEnteredAtByDate,
|
||||
'page' => $page,
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
+125
-29
@@ -381,18 +381,24 @@ class InvoiceController extends ResourceController
|
||||
return false;
|
||||
}
|
||||
|
||||
public function generateInvoice(string $parentId = null)
|
||||
public function generateInvoice(
|
||||
string $parentId = null,
|
||||
?string $schoolYearOverride = null,
|
||||
?string $semesterOverride = null,
|
||||
bool $recalculateDiscounts = true
|
||||
)
|
||||
{
|
||||
$isAjax = $this->request->isAJAX() || str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json');
|
||||
if ($parentId == null) {
|
||||
$parentId = (int)$this->request->getPost('parent_id');
|
||||
}
|
||||
$schoolYear = (string) $this->schoolYear;
|
||||
$schoolYear = (string) ($schoolYearOverride ?: $this->schoolYear);
|
||||
$semester = (string) ($semesterOverride ?: $this->semester);
|
||||
|
||||
// Fetch enrolled + withdrawn students
|
||||
$enrollments = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
if (empty($enrollments)) {
|
||||
@@ -444,21 +450,23 @@ class InvoiceController extends ResourceController
|
||||
$tuitionFee = $fees['tuition_fee'];
|
||||
|
||||
// ✅ Fetch event charges
|
||||
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $this->schoolYear);
|
||||
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
|
||||
$eventchargeTotal = array_sum(array_column($eventsList, 'charged'));
|
||||
|
||||
$totalDiscount = $this->recalculateAndUpdateDiscount(
|
||||
$parentId,
|
||||
$this->schoolYear,
|
||||
$tuitionFee,
|
||||
$enrollments
|
||||
);
|
||||
$totalDiscount = 0.0;
|
||||
if ($recalculateDiscounts) {
|
||||
$totalDiscount = $this->recalculateAndUpdateDiscount(
|
||||
$parentId,
|
||||
$schoolYear,
|
||||
$tuitionFee,
|
||||
$enrollments
|
||||
);
|
||||
}
|
||||
|
||||
$semester = $this->semester;
|
||||
// ✅ Refunds PAID to the parent for this year (Partial/Paid)
|
||||
$refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $this->schoolYear);
|
||||
$refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $schoolYear);
|
||||
|
||||
$totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $this->schoolYear);
|
||||
$totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $schoolYear);
|
||||
|
||||
$discountedTuition = max(0, $tuitionFee);
|
||||
$totalAmount = $discountedTuition + $eventchargeTotal;
|
||||
@@ -469,17 +477,22 @@ class InvoiceController extends ResourceController
|
||||
- $refundPaid // approved refunds paid to parent
|
||||
- $totalPaid; // payments received
|
||||
|
||||
// Your invoice fetch
|
||||
$existingInvoices = $this->invoiceModel->getInvoicesByParentId($parentId, $this->schoolYear);
|
||||
// Business rule: single invoice per parent per school year.
|
||||
// If legacy duplicates exist, prefer the invoice that already has a discount applied,
|
||||
// otherwise use the latest invoice for the parent/year.
|
||||
$invoice = $this->selectActiveInvoiceForParentYear((int)$parentId, $schoolYear);
|
||||
|
||||
$updated = false;
|
||||
|
||||
$updatedIds = [];
|
||||
if (!empty($existingInvoices)) {
|
||||
foreach ($existingInvoices as $invoice) {
|
||||
if (!isset($invoice['id'])) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($invoice) && isset($invoice['id'])) {
|
||||
$paymentExclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
$paymentsHasStatus = false;
|
||||
$paymentsHasVoid = false;
|
||||
try {
|
||||
$paymentsHasStatus = $this->db->fieldExists('status', 'payments');
|
||||
$paymentsHasVoid = $this->db->fieldExists('is_void', 'payments');
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
// Preserve applied additional charges and recalc this invoice only
|
||||
$extrasSum = 0.0;
|
||||
@@ -487,7 +500,7 @@ class InvoiceController extends ResourceController
|
||||
$rows = $this->db->table('additional_charges')
|
||||
->select('charge_type, amount')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('status', 'applied')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
@@ -520,7 +533,7 @@ class InvoiceController extends ResourceController
|
||||
$r = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$invRefunds = (float)($r['tot'] ?? 0.0);
|
||||
@@ -528,8 +541,33 @@ class InvoiceController extends ResourceController
|
||||
log_message('error', 'refund sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Payments recorded on this invoice (denormalized field kept in sync by PaymentController)
|
||||
$paidOnInv = (float)($invoice['paid_amount'] ?? 0.0);
|
||||
// Payments recorded on this invoice (sum payments to avoid stale invoice.paid_amount)
|
||||
$paidOnInv = 0.0;
|
||||
try {
|
||||
$qb = $this->db->table('payments')
|
||||
->select('COALESCE(SUM(paid_amount),0) AS tot')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('paid_amount >', 0);
|
||||
|
||||
if ($paymentsHasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', $paymentExclude)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($paymentsHasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$row = $qb->get()->getRowArray();
|
||||
$paidOnInv = (float)($row['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'payment sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
$paidOnInv = (float)($invoice['paid_amount'] ?? 0.0);
|
||||
}
|
||||
|
||||
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
|
||||
$newStatus = ($newBalance <= 0.00001)
|
||||
@@ -547,7 +585,6 @@ class InvoiceController extends ResourceController
|
||||
$updatedIds[] = (int)$invoice['id'];
|
||||
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
|
||||
$updated = true;
|
||||
}
|
||||
} else {
|
||||
// Generate invoice number
|
||||
$schoolId = $this->userModel->getSchoolIdByUserId($parentId);
|
||||
@@ -578,7 +615,7 @@ class InvoiceController extends ResourceController
|
||||
// Initial balance equals the created total; discounts/refunds/payments will adjust later
|
||||
'balance' => $totalAmount,
|
||||
'status' => 'Unpaid',
|
||||
'school_year' => $this->schoolYear,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'issue_date' => $issueUtc,
|
||||
'due_date' => $dueUtc,
|
||||
@@ -615,6 +652,47 @@ class InvoiceController extends ResourceController
|
||||
->with('success', $updated ? 'Invoice updated.' : 'Invoice created.');
|
||||
}
|
||||
|
||||
private function selectActiveInvoiceForParentYear(int $parentId, string $schoolYear): ?array
|
||||
{
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer invoices flagged as having discounts.
|
||||
$invoice = $this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('has_discount', 1)
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
if (!empty($invoice)) {
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
// Fallback: prefer invoices with discount_usages rows.
|
||||
try {
|
||||
$row = $this->db->table('invoices i')
|
||||
->select('i.*')
|
||||
->join('discount_usages du', 'du.invoice_id = i.id', 'inner')
|
||||
->where('i.parent_id', $parentId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->orderBy('i.id', 'DESC')
|
||||
->get()
|
||||
->getRowArray();
|
||||
if (!empty($row)) {
|
||||
return $row;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
// Final fallback: latest invoice for parent/year.
|
||||
return $this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function recalculateAndUpdateDiscount(
|
||||
int $parentId,
|
||||
string $schoolYear,
|
||||
@@ -678,7 +756,7 @@ class InvoiceController extends ResourceController
|
||||
log_message('error', 'additional_charges sum failed for discount recalculation invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$baseTotal = round($tuitionFee + $eventchargeTotal + $extrasSum, 2);
|
||||
$baseTotal = round($tuitionFee + $extrasSum, 2);
|
||||
|
||||
// Recalculate discount
|
||||
if ($discountUsage['discount_type'] === 'percent') {
|
||||
@@ -1266,6 +1344,12 @@ class InvoiceController extends ResourceController
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($studentName === 'N/A') {
|
||||
$externalName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? ''));
|
||||
if ($externalName !== '') {
|
||||
$studentName = $externalName . ' (external)';
|
||||
}
|
||||
}
|
||||
|
||||
$dt = $toLocal($event['created_at'] ?? null, false);
|
||||
$amount = (float)($event['charged'] ?? 0.0);
|
||||
@@ -1612,12 +1696,24 @@ private function getGradeLevel($grade): array
|
||||
$invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null;
|
||||
}
|
||||
|
||||
$invoiceEventCharges = [];
|
||||
foreach ($invoices as $invoice) {
|
||||
$year = $invoice['school_year'] ?? $this->schoolYear;
|
||||
$sem = $invoice['semester'] ?? $this->semester;
|
||||
$invoiceEventCharges[(int)$invoice['id']] = $this->chargesModel->getChargesWithEventInfo(
|
||||
$invoice['parent_id'],
|
||||
$year,
|
||||
$sem
|
||||
);
|
||||
}
|
||||
|
||||
return view('/parent/invoice_payment', [
|
||||
'invoices' => $invoices,
|
||||
'schoolYears' => $schoolYears,
|
||||
'selectedYear' => $selectedYear,
|
||||
'currentSchoolYear' => $currentSchoolYear,
|
||||
'dueDate' => $this->dueDate
|
||||
'dueDate' => $this->dueDate,
|
||||
'invoiceEventCharges' => $invoiceEventCharges,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Regular → Executable
Regular → Executable
+12
-3
@@ -95,6 +95,11 @@ class LandingPageController extends BaseController
|
||||
return null;
|
||||
}
|
||||
|
||||
// Teacher class sections only apply to the teacher role; other roles have no teacher_class rows.
|
||||
if (strtolower(trim((string) $this->getUserRole())) !== 'teacher') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all class assignments for this teacher in the current term
|
||||
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
|
||||
(int)$user_id,
|
||||
@@ -106,7 +111,7 @@ class LandingPageController extends BaseController
|
||||
$ids = array_values(array_filter(array_unique($ids)));
|
||||
|
||||
if (empty($ids)) {
|
||||
log_message('error', "No class section found for user ID: $user_id");
|
||||
log_message('warning', "No class section found for user ID: $user_id");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -868,8 +873,12 @@ class LandingPageController extends BaseController
|
||||
|
||||
protected function getUserRole()
|
||||
{
|
||||
// Assuming you have a session variable storing the user role
|
||||
return session()->get('user_role', 'guest'); // Default to guest if not set
|
||||
$active = session()->get('active_role');
|
||||
if ($active !== null && $active !== '') {
|
||||
return (string) $active;
|
||||
}
|
||||
|
||||
return (string) (session()->get('role') ?? 'guest');
|
||||
}
|
||||
|
||||
protected function getUserRoleFromDatabase($user_id)
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+129
-3
@@ -71,12 +71,15 @@ class ParentAttendanceReportController extends BaseController
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
$cutoffThreshold = $this->normalizeCutoffTime((string) $this->configModel->getConfig('parent_report_cutoff'));
|
||||
|
||||
return view('parent/report_attendance', [
|
||||
'students' => $students,
|
||||
'today' => local_date(utc_now(), 'Y-m-d'),
|
||||
'sundays' => $sundays, // array of ['value'=>Y-m-d, 'label'=>...] items
|
||||
'defaultDate' => $defaultDate, // preselected date (upcoming Sunday)
|
||||
'myReports' => $previewRows,
|
||||
'cutoffThreshold' => $cutoffThreshold,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -141,6 +144,17 @@ class ParentAttendanceReportController extends BaseController
|
||||
|
||||
// Enforce Sunday-only and future-or-today selection
|
||||
$todayCheck = new \DateTime('today');
|
||||
$cutoffThreshold = $this->normalizeCutoffTime((string) $this->configModel->getConfig('parent_report_cutoff'));
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = null;
|
||||
$nowTz = null;
|
||||
try {
|
||||
$tz = new \DateTimeZone($tzName ?: 'UTC');
|
||||
$nowTz = new \DateTime('now', $tz);
|
||||
} catch (\Throwable $e) {
|
||||
$tz = null;
|
||||
$nowTz = null;
|
||||
}
|
||||
$validDates = [];
|
||||
|
||||
$cap = $this->firstSundayOfJune((string) $this->configModel->getConfig('school_year'));
|
||||
@@ -150,9 +164,11 @@ class ParentAttendanceReportController extends BaseController
|
||||
$lateTodayStr = $todayCheck->format('Y-m-d');
|
||||
if ($type === 'late') {
|
||||
try {
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = new \DateTimeZone($tzName ?: 'UTC');
|
||||
$lateNow = new \DateTime('now', $tz);
|
||||
if (!$tz) {
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = new \DateTimeZone($tzName ?: 'UTC');
|
||||
}
|
||||
$lateNow = $nowTz ?: new \DateTime('now', $tz);
|
||||
$cm = $this->configModel;
|
||||
$cutoffStr = (string) ($cm->getConfig('late_report_cutoff')
|
||||
?: $cm->getConfig('late_cutoff_time')
|
||||
@@ -183,6 +199,17 @@ class ParentAttendanceReportController extends BaseController
|
||||
if ($dt < $todayCheck) {
|
||||
return redirect()->back()->withInput()->with('error', 'Past dates are not allowed. Please select today or a future Sunday.');
|
||||
}
|
||||
if ($nowTz && $tz) {
|
||||
try {
|
||||
$cutoff = new \DateTime($entry . ' ' . $cutoffThreshold . ':00', $tz);
|
||||
if ($nowTz >= $cutoff) {
|
||||
$abbr = $cutoff->format('T');
|
||||
return redirect()->back()->withInput()->with('error', 'Reporting closes at ' . $cutoffThreshold . ' ' . $abbr . ' on the report date.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore and continue
|
||||
}
|
||||
}
|
||||
if ($dt > $cap) {
|
||||
return redirect()->back()->withInput()->with('error', 'The last available date is the first Sunday of June.');
|
||||
}
|
||||
@@ -800,6 +827,36 @@ class ParentAttendanceReportController extends BaseController
|
||||
$update['reported'] = 1;
|
||||
}
|
||||
$this->attendanceDataModel->update((int) $existing['id'], $update);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($existingStatus === '' || $existingStatus === 'present') {
|
||||
$update = [
|
||||
'status' => $status,
|
||||
'reason' => $existing['reason'] ?: $reasonText,
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
if ($hasIsReported) {
|
||||
$update['is_reported'] = 'yes';
|
||||
}
|
||||
if ($hasLegacyReported) {
|
||||
$update['reported'] = 1;
|
||||
}
|
||||
$this->attendanceDataModel->update((int) $existing['id'], $update);
|
||||
|
||||
$classSectionId = (int) ($existing['class_section_id'] ?? $classSectionId ?? 0);
|
||||
$schoolId = (string) ($existing['school_id'] ?? '');
|
||||
if ($classSectionId > 0 && $schoolId !== '') {
|
||||
$this->adjustAttendanceRecordForStatusChange(
|
||||
studentId: $studentId,
|
||||
classSectionId: $classSectionId,
|
||||
schoolId: $schoolId,
|
||||
semester: $semester,
|
||||
schoolYear: $schoolYear,
|
||||
oldStatus: $existingStatus,
|
||||
newStatus: $status
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,6 +913,61 @@ class ParentAttendanceReportController extends BaseController
|
||||
$this->attendanceRecordModel->insert($insert);
|
||||
}
|
||||
|
||||
private function adjustAttendanceRecordForStatusChange(
|
||||
int $studentId,
|
||||
int $classSectionId,
|
||||
string $schoolId,
|
||||
string $semester,
|
||||
string $schoolYear,
|
||||
string $oldStatus,
|
||||
string $newStatus
|
||||
): void {
|
||||
$oldStatus = strtolower($oldStatus);
|
||||
$newStatus = strtolower($newStatus);
|
||||
|
||||
if ($oldStatus === $newStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record = $this->attendanceRecordModel->where([
|
||||
'student_id' => $studentId,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
])->first();
|
||||
|
||||
if (!$record) {
|
||||
$this->bumpAttendanceRecordFromParent($studentId, $classSectionId, $schoolId, $newStatus, $semester, $schoolYear);
|
||||
return;
|
||||
}
|
||||
|
||||
$updates = [
|
||||
'total_presence' => (int) ($record['total_presence'] ?? 0),
|
||||
'total_absence' => (int) ($record['total_absence'] ?? 0),
|
||||
'total_late' => (int) ($record['total_late'] ?? 0),
|
||||
'total_attendance' => (int) ($record['total_attendance'] ?? 0),
|
||||
'updated_at' => utc_now(),
|
||||
'modified_by' => (int) (session()->get('user_id') ?? 0),
|
||||
];
|
||||
|
||||
if ($oldStatus === 'present') {
|
||||
$updates['total_presence'] = max(0, $updates['total_presence'] - 1);
|
||||
} elseif ($oldStatus === 'absent') {
|
||||
$updates['total_absence'] = max(0, $updates['total_absence'] - 1);
|
||||
} elseif ($oldStatus === 'late') {
|
||||
$updates['total_late'] = max(0, $updates['total_late'] - 1);
|
||||
}
|
||||
|
||||
if ($newStatus === 'present') {
|
||||
$updates['total_presence']++;
|
||||
} elseif ($newStatus === 'absent') {
|
||||
$updates['total_absence']++;
|
||||
} elseif ($newStatus === 'late') {
|
||||
$updates['total_late']++;
|
||||
}
|
||||
|
||||
$this->attendanceRecordModel->update((int) $record['id'], $updates);
|
||||
}
|
||||
|
||||
// GET: staff view to list reports (today & upcoming by default)
|
||||
public function list()
|
||||
{
|
||||
@@ -1503,6 +1615,20 @@ class ParentAttendanceReportController extends BaseController
|
||||
return $userId; // fallback
|
||||
}
|
||||
|
||||
private function normalizeCutoffTime(string $raw, string $default = '09:00'): string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return $default;
|
||||
}
|
||||
if (preg_match('/^(\d{1,2}):(\d{2})$/', $raw, $m)) {
|
||||
$hh = str_pad((string) ((int) $m[1]), 2, '0', STR_PAD_LEFT);
|
||||
$mm = $m[2];
|
||||
return $hh . ':' . $mm;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send confirmation/notification emails for parent attendance submissions.
|
||||
*/
|
||||
|
||||
Regular → Executable
+27
-7
@@ -717,6 +717,7 @@ class ParentController extends BaseController
|
||||
|
||||
// Step 1: Generate a secure token for email verification
|
||||
$token = bin2hex(random_bytes(48));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
|
||||
// Step 2: Determine user type based on relationship
|
||||
$userType = in_array(strtolower($relationToStudent), ['wife', 'husband']) ? 'Secondary' : 'Tertiary';
|
||||
@@ -776,7 +777,7 @@ class ParentController extends BaseController
|
||||
'state' => strtoupper($userData['state']),
|
||||
'zip' => $userData['zip'],
|
||||
'accept_school_policy' => $userData['accept_school_policy'] ?? 0,
|
||||
'token' => $token,
|
||||
'token' => $tokenHash,
|
||||
'is_verified' => 0,
|
||||
'status' => 'Inactive',
|
||||
'user_type' => $userType,
|
||||
@@ -1796,16 +1797,34 @@ $existing = $this->studentModel
|
||||
$activeEventCount = is_array($activeEvents) ? count($activeEvents) : 0;
|
||||
|
||||
// Get charges (participation info)
|
||||
$chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
|
||||
$chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $semester);
|
||||
|
||||
// Build a map: "studentId:eventId" => [ 'participation' => ..., 'date' => ... ]
|
||||
$charges = [];
|
||||
$externalParticipantsByEvent = [];
|
||||
foreach ($chargesList as $charge) {
|
||||
$key = $charge['student_id'] . ':' . $charge['event_id'];
|
||||
$charges[$key] = [
|
||||
'participation' => $charge['participation'],
|
||||
'date' => $charge['updated_at'] ?? $charge['created_at'] // Use updated_at if available
|
||||
];
|
||||
$studentId = $charge['student_id'] ?? null;
|
||||
$eventId = (int) ($charge['event_id'] ?? 0);
|
||||
|
||||
if (!empty($studentId)) {
|
||||
$key = $studentId . ':' . $eventId;
|
||||
$charges[$key] = [
|
||||
'participation' => $charge['participation'],
|
||||
'date' => $charge['updated_at'] ?? $charge['created_at'], // Use updated_at if available
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$externalName = trim((string) ($charge['external_firstname'] ?? '') . ' ' . (string) ($charge['external_lastname'] ?? ''));
|
||||
if ($eventId > 0 && $externalName !== '') {
|
||||
$externalParticipantsByEvent[$eventId][] = [
|
||||
'name' => $externalName,
|
||||
'note' => (string) ($charge['external_note'] ?? ''),
|
||||
'participation' => (string) ($charge['participation'] ?? ''),
|
||||
'event_paid' => !empty($charge['event_paid']),
|
||||
'charged' => (float) ($charge['charged'] ?? ($charge['event_amount'] ?? 0)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Get enrolled students
|
||||
@@ -1814,6 +1833,7 @@ $existing = $this->studentModel
|
||||
return view('parent/event_participation', [
|
||||
'activeEvents' => $activeEvents,
|
||||
'charges' => $charges,
|
||||
'externalParticipantsByEvent' => $externalParticipantsByEvent,
|
||||
'yourStudents' => $students,
|
||||
'activeEventCount' => $activeEventCount,
|
||||
]);
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Executable
+571
@@ -0,0 +1,571 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\PrintRequestModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ClassModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\AdminNotificationSubjectModel;
|
||||
use App\Models\NotificationModel;
|
||||
use App\Models\UserNotificationModel;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
|
||||
class PrintRequests extends BaseController
|
||||
{
|
||||
protected $printRequestModel;
|
||||
protected $userModel;
|
||||
protected $classModel;
|
||||
protected $teacherClassModel;
|
||||
protected $configModel;
|
||||
protected $classSectionModel;
|
||||
protected $adminNotificationSubjectModel;
|
||||
protected $notificationModel;
|
||||
protected $userNotificationModel;
|
||||
|
||||
// Define the upload path as a constant or property for easy maintenance
|
||||
protected $uploadPath = WRITEPATH . 'uploads/print_requests/';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->printRequestModel = new PrintRequestModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->classModel = new ClassModel();
|
||||
$this->teacherClassModel = new TeacherClassModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->adminNotificationSubjectModel = new AdminNotificationSubjectModel();
|
||||
$this->notificationModel = new NotificationModel();
|
||||
$this->userNotificationModel = new UserNotificationModel();
|
||||
helper(['form', 'url']);
|
||||
}
|
||||
|
||||
public function teacher_index()
|
||||
{
|
||||
$teacher_id = session()->get('user_id');
|
||||
|
||||
$data['print_requests'] = $this->printRequestModel
|
||||
->select('print_requests.*, admins.firstname as admin_firstname, admins.lastname as admin_lastname')
|
||||
->join('users as admins', 'admins.id = print_requests.admin_id', 'left')
|
||||
->where('print_requests.teacher_id', $teacher_id)
|
||||
->findAll();
|
||||
|
||||
$teacher_classes = $this->teacherClassModel->getClassByTeacherId($teacher_id);
|
||||
$data['class_id'] = !empty($teacher_classes) ? $teacher_classes[0]['class_section_id'] : null;
|
||||
$dateOptions = $this->buildRequiredByOptions();
|
||||
$data['sundays'] = $dateOptions['sundays'];
|
||||
$data['times'] = $dateOptions['times'];
|
||||
|
||||
return view('print_requests/teacher_index', $data);
|
||||
}
|
||||
|
||||
public function admin_index()
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$query = $db->query("
|
||||
SELECT
|
||||
pr.*,
|
||||
u.firstname,
|
||||
u.lastname,
|
||||
cs.class_section_name,
|
||||
admins.firstname AS admin_firstname,
|
||||
admins.lastname AS admin_lastname
|
||||
FROM print_requests pr
|
||||
LEFT JOIN users u ON u.id = pr.teacher_id
|
||||
LEFT JOIN classSection cs ON cs.class_section_id = pr.class_id
|
||||
LEFT JOIN users admins ON admins.id = pr.admin_id
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN pr.status = 'not_assigned' THEN 1
|
||||
WHEN pr.status = 'assigned' THEN 2
|
||||
WHEN pr.status = 'done' THEN 3
|
||||
WHEN pr.status = 'delivered' THEN 4
|
||||
ELSE 5
|
||||
END ASC,
|
||||
pr.required_by ASC
|
||||
");
|
||||
$data['print_requests'] = $query->getResultArray();
|
||||
|
||||
return view('print_requests/admin_index', $data);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$validationRules = [
|
||||
'file' => 'uploaded[file]|max_size[file,2048]|ext_in[file,pdf,jpg,png,jpeg,doc,docx,txt]',
|
||||
'page_selection' => 'permit_empty|regex_match[/^\\s*\\d+(?:\\s*-\\s*\\d+)?(?:\\s*,\\s*\\d+(?:\\s*-\\s*\\d+)?)*\\s*$/]',
|
||||
'num_copies' => 'required|integer|greater_than[0]',
|
||||
'required_by' => 'required|valid_date',
|
||||
'pickup_method' => 'required'
|
||||
];
|
||||
|
||||
if (!$this->validate($validationRules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$teacher_id = session()->get('user_id');
|
||||
|
||||
$file = $this->request->getFile('file');
|
||||
$newName = '';
|
||||
if ($file->isValid() && !$file->hasMoved()) {
|
||||
$newName = $file->getRandomName();
|
||||
// Moved to WRITEPATH
|
||||
$file->move($this->uploadPath, $newName);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'teacher_id' => $teacher_id,
|
||||
'class_id' => $this->request->getPost('class_id'),
|
||||
'file_path' => $newName,
|
||||
'page_selection' => trim((string) $this->request->getPost('page_selection')),
|
||||
'num_copies' => $this->request->getPost('num_copies'),
|
||||
'required_by' => $this->request->getPost('required_by'),
|
||||
'pickup_method' => $this->request->getPost('pickup_method'),
|
||||
'status' => 'not_assigned',
|
||||
];
|
||||
|
||||
$printRequestId = (int) $this->printRequestModel->insert($data, true);
|
||||
if ($printRequestId > 0) {
|
||||
$this->notifyAdminsForPrintRequest($printRequestId, $data, 'created');
|
||||
}
|
||||
|
||||
return redirect()->to('teacher/print-requests')->with('success', 'Print request created successfully.');
|
||||
}
|
||||
|
||||
public function update($id)
|
||||
{
|
||||
$request = $this->printRequestModel->find($id);
|
||||
if (!$request) {
|
||||
return redirect()->back()->with('error', 'Print request not found.');
|
||||
}
|
||||
|
||||
// Case 1: Admin status update
|
||||
if ($this->request->getPost('status')) {
|
||||
$user_id = session()->get('user_id');
|
||||
$current_status = $request['status'];
|
||||
$new_status = $this->request->getPost('status');
|
||||
|
||||
$allowed_transitions = [
|
||||
'not_assigned' => ['assigned'],
|
||||
'assigned' => ['not_assigned', 'done'],
|
||||
'done' => ['delivered'],
|
||||
'delivered' => []
|
||||
];
|
||||
|
||||
if (!isset($allowed_transitions[$current_status]) || !in_array($new_status, $allowed_transitions[$current_status])) {
|
||||
if ($current_status == $new_status) {
|
||||
return redirect()->to('admin/print-requests');
|
||||
}
|
||||
return redirect()->to('admin/print-requests')->with('error', 'Invalid status transition.');
|
||||
}
|
||||
|
||||
$data = ['status' => $new_status];
|
||||
if ($new_status == 'assigned') {
|
||||
$data['admin_id'] = $user_id;
|
||||
} elseif ($new_status == 'not_assigned') {
|
||||
$data['admin_id'] = null;
|
||||
}
|
||||
|
||||
$this->printRequestModel->update($id, $data);
|
||||
return redirect()->to('admin/print-requests')->with('success', 'Status updated successfully.');
|
||||
}
|
||||
|
||||
// Case 2: Teacher edit
|
||||
if ($this->request->getPost('num_copies')) {
|
||||
$user_id = session()->get('user_id');
|
||||
if ($request['teacher_id'] != $user_id) {
|
||||
return redirect()->to('teacher/print-requests')->with('error', 'You are not authorized to edit this request.');
|
||||
}
|
||||
if (!in_array($request['status'], ['not_assigned', 'assigned'])) {
|
||||
return redirect()->to('teacher/print-requests')->with('error', 'Cannot edit a request that is already being processed.');
|
||||
}
|
||||
|
||||
$validationRules = [
|
||||
'num_copies' => 'required|integer|greater_than[0]',
|
||||
'required_by' => 'required|valid_date',
|
||||
'pickup_method' => 'required|in_list[Self Pickup,Delivered to class]'
|
||||
];
|
||||
|
||||
$pageSelectionRule = 'regex_match[/^\\s*\\d+(?:\\s*-\\s*\\d+)?(?:\\s*,\\s*\\d+(?:\\s*-\\s*\\d+)?)*\\s*$/]';
|
||||
$validationRules['page_selection'] = 'permit_empty|' . $pageSelectionRule;
|
||||
|
||||
$hasNewFile = $this->request->getFile('file') && $this->request->getFile('file')->isValid();
|
||||
if ($hasNewFile) {
|
||||
$validationRules['file'] = 'uploaded[file]|max_size[file,2048]|ext_in[file,pdf,jpg,png,jpeg,doc,docx,txt]';
|
||||
}
|
||||
|
||||
if (!$this->validate($validationRules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$data = [
|
||||
'num_copies' => $this->request->getPost('num_copies'),
|
||||
'required_by' => $this->request->getPost('required_by'),
|
||||
'pickup_method' => $this->request->getPost('pickup_method'),
|
||||
'page_selection' => trim((string) $this->request->getPost('page_selection')),
|
||||
];
|
||||
|
||||
$file = $this->request->getFile('file');
|
||||
if ($file && $file->isValid() && !$file->hasMoved()) {
|
||||
// Remove old file from WRITEPATH
|
||||
if ($request['file_path'] && file_exists($this->uploadPath . $request['file_path'])) {
|
||||
@unlink($this->uploadPath . $request['file_path']);
|
||||
}
|
||||
$newName = $file->getRandomName();
|
||||
$file->move($this->uploadPath, $newName);
|
||||
$data['file_path'] = $newName;
|
||||
}
|
||||
|
||||
$this->printRequestModel->update($id, $data);
|
||||
$notifyPayload = array_merge($request, $data, ['id' => $id]);
|
||||
$this->notifyAdminsForPrintRequest($id, $notifyPayload, 'updated');
|
||||
return redirect()->to('teacher/print-requests')->with('success', 'Print request updated successfully.');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('error', 'Invalid request data.');
|
||||
}
|
||||
|
||||
public function delete($id)
|
||||
{
|
||||
$teacher_id = session()->get('user_id');
|
||||
$request = $this->printRequestModel->find($id);
|
||||
|
||||
if (!$request) {
|
||||
return redirect()->to('teacher/print-requests')->with('error', 'Print request not found.');
|
||||
}
|
||||
|
||||
if ($request['teacher_id'] != $teacher_id) {
|
||||
return redirect()->to('teacher/print-requests')->with('error', 'You are not authorized to delete this request.');
|
||||
}
|
||||
|
||||
if (!in_array($request['status'], ['not_assigned', 'assigned'])) {
|
||||
return redirect()->to('teacher/print-requests')->with('error', 'Cannot delete a request that is already being processed.');
|
||||
}
|
||||
|
||||
$this->notifyAdminsForPrintRequest($id, $request, 'deleted');
|
||||
|
||||
// Delete file from WRITEPATH
|
||||
if ($request['file_path'] && file_exists($this->uploadPath . $request['file_path'])) {
|
||||
@unlink($this->uploadPath . $request['file_path']);
|
||||
}
|
||||
|
||||
$this->printRequestModel->delete($id);
|
||||
|
||||
return redirect()->to('teacher/print-requests')->with('success', 'Print request deleted successfully.');
|
||||
}
|
||||
|
||||
public function copy($id)
|
||||
{
|
||||
$teacher_id = session()->get('user_id');
|
||||
$request = $this->printRequestModel->find($id);
|
||||
|
||||
if (!$request) {
|
||||
return redirect()->to('teacher/print-requests')->with('error', 'Print request not found.');
|
||||
}
|
||||
|
||||
if ($request['teacher_id'] != $teacher_id) {
|
||||
return redirect()->to('teacher/print-requests')->with('error', 'You are not authorized to copy this request.');
|
||||
}
|
||||
|
||||
$filePath = trim((string) ($request['file_path'] ?? ''));
|
||||
$candidates = [
|
||||
$this->uploadPath . $filePath,
|
||||
FCPATH . 'uploads/print_requests/' . $filePath,
|
||||
];
|
||||
$fileExists = false;
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($filePath !== '' && file_exists($candidate)) {
|
||||
$fileExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$fileExists) {
|
||||
return redirect()->to('teacher/print-requests')->with('error', 'The original file is unavailable for copying.');
|
||||
}
|
||||
|
||||
$data = [
|
||||
'teacher_id' => $teacher_id,
|
||||
'class_id' => $request['class_id'],
|
||||
'file_path' => $filePath,
|
||||
'page_selection' => $request['page_selection'] ?? null,
|
||||
'num_copies' => $request['num_copies'],
|
||||
'required_by' => $request['required_by'],
|
||||
'pickup_method' => $request['pickup_method'],
|
||||
'status' => 'not_assigned',
|
||||
];
|
||||
|
||||
$copiedId = (int) $this->printRequestModel->insert($data, true);
|
||||
if ($copiedId > 0) {
|
||||
$this->notifyAdminsForPrintRequest($copiedId, $data, 'created');
|
||||
}
|
||||
|
||||
return redirect()->to('teacher/print-requests')->with('success', 'Print request copied successfully.');
|
||||
}
|
||||
|
||||
public function createCopy()
|
||||
{
|
||||
$validationRules = [
|
||||
'num_copies' => 'required|integer|greater_than[0]',
|
||||
'required_by' => 'required|valid_date',
|
||||
'pickup_method' => 'required'
|
||||
];
|
||||
|
||||
if (!$this->validate($validationRules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$teacher_id = session()->get('user_id');
|
||||
|
||||
$data = [
|
||||
'teacher_id' => $teacher_id,
|
||||
'class_id' => $this->request->getPost('class_id'),
|
||||
'file_path' => '',
|
||||
'page_selection' => null,
|
||||
'num_copies' => $this->request->getPost('num_copies'),
|
||||
'required_by' => $this->request->getPost('required_by'),
|
||||
'pickup_method' => $this->request->getPost('pickup_method'),
|
||||
'status' => 'not_assigned',
|
||||
];
|
||||
|
||||
$printRequestId = (int) $this->printRequestModel->insert($data, true);
|
||||
if ($printRequestId > 0) {
|
||||
$this->notifyAdminsForPrintRequest($printRequestId, $data, 'created');
|
||||
}
|
||||
|
||||
return redirect()->to('teacher/print-requests')->with('success', 'Copy request submitted. Please hand the original document to the copy center.');
|
||||
}
|
||||
|
||||
private function buildRequiredByOptions(): array
|
||||
{
|
||||
$tz = new \DateTimeZone('America/Chicago');
|
||||
$currentDate = new \DateTime('now', $tz);
|
||||
$currentYear = (int)$currentDate->format('Y');
|
||||
$endYear = ($currentDate->format('n') > 6) ? $currentYear + 1 : $currentYear;
|
||||
$endDate = new \DateTime("first Sunday of June {$endYear}", $tz);
|
||||
$endDate->modify('+1 week');
|
||||
|
||||
$sundays = [];
|
||||
$date = new \DateTime('now', $tz);
|
||||
$date->setTime(0, 0, 0);
|
||||
if ($date->format('w') !== '0') {
|
||||
$date->modify('next Sunday');
|
||||
}
|
||||
while ($date < $endDate) {
|
||||
$sundays[] = $date->format('Y-m-d');
|
||||
$date->modify('+1 week');
|
||||
}
|
||||
|
||||
$times = [];
|
||||
$start = new \DateTime('10:00', $tz);
|
||||
$end = new \DateTime('13:00', $tz);
|
||||
$interval = new \DateInterval('PT15M');
|
||||
$period = new \DatePeriod($start, $interval, $end);
|
||||
foreach ($period as $time) {
|
||||
$times[] = $time->format('H:i');
|
||||
}
|
||||
|
||||
return [
|
||||
'sundays' => $sundays,
|
||||
'times' => $times,
|
||||
];
|
||||
}
|
||||
|
||||
public function serveFile(string $filename, string $mode = 'inline')
|
||||
{
|
||||
$safeName = basename(trim($filename));
|
||||
if ($safeName === '') {
|
||||
throw new PageNotFoundException('File not specified.');
|
||||
}
|
||||
|
||||
$candidates = [
|
||||
WRITEPATH . 'uploads/print_requests/' . $safeName,
|
||||
FCPATH . 'uploads/print_requests/' . $safeName,
|
||||
];
|
||||
|
||||
$path = null;
|
||||
foreach ($candidates as $candidate) {
|
||||
if (is_file($candidate)) {
|
||||
$path = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$path) {
|
||||
throw new PageNotFoundException('File not found.');
|
||||
}
|
||||
|
||||
$mime = mime_content_type($path) ?: 'application/octet-stream';
|
||||
$disposition = strtolower(trim($mode)) === 'download' ? 'attachment' : 'inline';
|
||||
|
||||
return $this->response
|
||||
->download($path, null)
|
||||
->setFileName($safeName)
|
||||
->setHeader('Content-Type', $mime)
|
||||
->setHeader('Content-Disposition', sprintf('%s; filename="%s"', $disposition, $safeName));
|
||||
}
|
||||
|
||||
private function notifyAdminsForPrintRequest(int $printRequestId, array $requestData, string $event = 'created'): void
|
||||
{
|
||||
try {
|
||||
$db = \Config\Database::connect();
|
||||
} catch (\Throwable $e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!$db->tableExists('admin_notification_subjects') ||
|
||||
!$db->tableExists('notifications') ||
|
||||
!$db->tableExists('user_notifications')
|
||||
) {
|
||||
log_message('error', 'Print request notifications skipped: required tables missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = $this->adminNotificationSubjectModel
|
||||
->select('admin_id')
|
||||
->where('subject', 'print_requests')
|
||||
->findAll();
|
||||
|
||||
$adminIds = array_values(array_unique(array_map('intval', array_column($rows, 'admin_id'))));
|
||||
if (empty($adminIds)) {
|
||||
log_message('info', 'Print request notifications skipped: no admins subscribed to print_requests.');
|
||||
return;
|
||||
}
|
||||
|
||||
$teacherId = (int) ($requestData['teacher_id'] ?? 0);
|
||||
$teacherName = '';
|
||||
if ($teacherId > 0) {
|
||||
$teacher = $this->userModel->find($teacherId);
|
||||
$teacherName = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? ''));
|
||||
}
|
||||
if ($teacherName === '') {
|
||||
$teacherName = $teacherId > 0 ? ('Teacher #' . $teacherId) : 'Teacher';
|
||||
}
|
||||
|
||||
$className = '';
|
||||
$classId = $requestData['class_id'] ?? null;
|
||||
if ($classId !== null && $classId !== '') {
|
||||
$className = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classId) ?? '');
|
||||
}
|
||||
|
||||
$event = strtolower(trim($event));
|
||||
$eventMap = [
|
||||
'created' => [
|
||||
'title' => 'New Print Request',
|
||||
'intro' => 'A new print request has been submitted.',
|
||||
'lead' => 'New print request from ',
|
||||
],
|
||||
'updated' => [
|
||||
'title' => 'Print Request Updated',
|
||||
'intro' => 'A print request has been updated.',
|
||||
'lead' => 'Updated print request from ',
|
||||
],
|
||||
'deleted' => [
|
||||
'title' => 'Print Request Removed',
|
||||
'intro' => 'A print request has been deleted.',
|
||||
'lead' => 'Print request removed for ',
|
||||
],
|
||||
];
|
||||
$eventConfig = $eventMap[$event] ?? $eventMap['created'];
|
||||
|
||||
$filePath = trim((string) ($requestData['file_path'] ?? ''));
|
||||
$isCopyRequest = $filePath === '';
|
||||
$messageParts = [$eventConfig['lead'] . $teacherName];
|
||||
if ($className !== '') {
|
||||
$messageParts[] = 'Class: ' . $className;
|
||||
}
|
||||
if (!empty($requestData['num_copies'])) {
|
||||
$messageParts[] = 'Copies: ' . (int) $requestData['num_copies'];
|
||||
}
|
||||
if (!empty($requestData['required_by'])) {
|
||||
$messageParts[] = 'Needed by: ' . $requestData['required_by'];
|
||||
}
|
||||
if (!empty($requestData['page_selection'])) {
|
||||
$messageParts[] = 'Pages: ' . $requestData['page_selection'];
|
||||
}
|
||||
if (!empty($requestData['pickup_method'])) {
|
||||
$messageParts[] = 'Pickup: ' . $requestData['pickup_method'];
|
||||
}
|
||||
$message = implode(' | ', $messageParts);
|
||||
$actionUrl = site_url('admin/print-requests');
|
||||
|
||||
$payload = [
|
||||
'title' => $eventConfig['title'],
|
||||
'message' => $message,
|
||||
'target_group' => 'admin',
|
||||
'delivery_channels' => 'in_app,email',
|
||||
'priority' => 'normal',
|
||||
'status' => 'pending',
|
||||
'action_url' => $isCopyRequest ? '' : $actionUrl,
|
||||
'scheduled_at' => utc_now(),
|
||||
'school_year' => $this->configModel->getConfig('school_year'),
|
||||
'semester' => $this->configModel->getConfig('semester'),
|
||||
];
|
||||
|
||||
$notificationFields = $db->getFieldNames('notifications');
|
||||
if (is_array($notificationFields) && !empty($notificationFields)) {
|
||||
$payload = array_intersect_key($payload, array_flip($notificationFields));
|
||||
}
|
||||
|
||||
$notificationId = (int) $this->notificationModel->insert($payload, true);
|
||||
if ($notificationId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userNotificationFields = $db->getFieldNames('user_notifications');
|
||||
$userFieldMap = is_array($userNotificationFields) ? array_flip($userNotificationFields) : [];
|
||||
|
||||
$batch = [];
|
||||
foreach ($adminIds as $adminId) {
|
||||
if ($adminId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$row = [
|
||||
'notification_id' => $notificationId,
|
||||
'user_id' => $adminId,
|
||||
'is_read' => 0,
|
||||
'delivered' => 0,
|
||||
];
|
||||
if (!empty($userFieldMap)) {
|
||||
$row = array_intersect_key($row, $userFieldMap);
|
||||
}
|
||||
$batch[] = $row;
|
||||
}
|
||||
|
||||
if (!empty($batch)) {
|
||||
$this->userNotificationModel->insertBatch($batch);
|
||||
}
|
||||
|
||||
$adminRows = $this->userModel
|
||||
->select('id, firstname, lastname, email')
|
||||
->whereIn('id', $adminIds)
|
||||
->findAll();
|
||||
|
||||
if (empty($adminRows)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mailer = new \App\Controllers\View\EmailController();
|
||||
$subject = $eventConfig['title'];
|
||||
$body = '<p>' . esc($eventConfig['intro']) . '</p>'
|
||||
. '<p><strong>From:</strong> ' . esc($teacherName) . '</p>'
|
||||
. ($className !== '' ? '<p><strong>Class:</strong> ' . esc($className) . '</p>' : '')
|
||||
. (!empty($requestData['num_copies']) ? '<p><strong>Copies:</strong> ' . (int) $requestData['num_copies'] . '</p>' : '')
|
||||
. (!empty($requestData['required_by']) ? '<p><strong>Needed by:</strong> ' . esc($requestData['required_by']) . '</p>' : '')
|
||||
. (!empty($requestData['page_selection']) ? '<p><strong>Pages:</strong> ' . esc($requestData['page_selection']) . '</p>' : '')
|
||||
. (!empty($requestData['pickup_method']) ? '<p><strong>Pickup Method:</strong> ' . esc($requestData['pickup_method']) . '</p>' : '')
|
||||
. ($isCopyRequest ? '' : '<p><a href="' . esc($actionUrl) . '">View print requests</a></p>');
|
||||
|
||||
foreach ($adminRows as $adminRow) {
|
||||
$email = trim((string) ($adminRow['email'] ?? ''));
|
||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
continue;
|
||||
}
|
||||
$mailer->sendEmail($email, $subject, $body, 'notifications');
|
||||
}
|
||||
}
|
||||
}
|
||||
Regular → Executable
Regular → Executable
+2
-4
@@ -438,6 +438,7 @@ class ProjectController extends Controller
|
||||
// Step 1: Get student IDs from student_class table
|
||||
$studentClassRows = $studentClassModel
|
||||
->select('student_id')
|
||||
->distinct()
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
@@ -494,10 +495,7 @@ class ProjectController extends Controller
|
||||
$studentModel = new StudentModel();
|
||||
$projectModel = new ProjectModel();
|
||||
|
||||
$studentClasses = $studentClassModel
|
||||
->active()
|
||||
->where('student_class.class_section_id', $classSectionId)
|
||||
->findAll();
|
||||
$studentClasses = $studentClassModel->getClassStudents($classSectionId, $this->schoolYear, null);
|
||||
$students = [];
|
||||
|
||||
foreach ($studentClasses as $sc) {
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+5
-12
@@ -173,16 +173,8 @@ class RegisterController extends Controller
|
||||
$existingUser = $this->userModel->where('email', $post['email'])->first();
|
||||
|
||||
if ($existingUser) {
|
||||
// Step 2: Check if the user has a token (i.e., not verified yet)
|
||||
if (!empty($existingUser['token']) && $existingUser['is_verified'] == 0) {
|
||||
// User exists and is unverified
|
||||
return redirect()->back()->withInput()->with('error',
|
||||
'This email address is already registered and is pending activation. Please check your email to activate your account.');
|
||||
} else {
|
||||
// User exists and is already active or has no token
|
||||
return redirect()->back()->withInput()->with('error',
|
||||
'The email address you entered is already in use. Please try a different one.');
|
||||
}
|
||||
return redirect()->back()->withInput()->with('error',
|
||||
'This email address is already registered. Please check your email or log in.');
|
||||
}
|
||||
|
||||
/* ───────────── 6. Determine role ───────────── */
|
||||
@@ -194,6 +186,7 @@ class RegisterController extends Controller
|
||||
|
||||
/* ───────────── 7. Build & insert user ───────────── */
|
||||
$token = bin2hex(random_bytes(48));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$userData = [
|
||||
'firstname' => $post['firstname'],
|
||||
'lastname' => $post['lastname'],
|
||||
@@ -205,7 +198,7 @@ class RegisterController extends Controller
|
||||
'city' => $post['city'],
|
||||
'state' => $post['state'],
|
||||
'zip' => $post['zip'],
|
||||
'token' => $token,
|
||||
'token' => $tokenHash,
|
||||
'is_verified'=> 0,
|
||||
'accept_school_policy' => (int) $post['accept_school_policy'],
|
||||
'status' => 'Inactive',
|
||||
@@ -357,4 +350,4 @@ class RegisterController extends Controller
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
+56
@@ -4,18 +4,21 @@ namespace App\Controllers\View;
|
||||
|
||||
use App\Models\CalendarModel;
|
||||
use App\Models\AttendanceDataModel;
|
||||
use App\Models\ReportCardAcknowledgementModel;
|
||||
use App\Services\SemesterRangeService;
|
||||
|
||||
class ReportCardsController extends PrintablesBaseController
|
||||
{
|
||||
protected $calendarModel;
|
||||
protected $semesterRangeService;
|
||||
protected $ackModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->calendarModel = new CalendarModel();
|
||||
$this->semesterRangeService = new SemesterRangeService($this->configModel);
|
||||
$this->ackModel = new ReportCardAcknowledgementModel();
|
||||
}
|
||||
|
||||
public function report_card()
|
||||
@@ -369,6 +372,24 @@ class ReportCardsController extends PrintablesBaseController
|
||||
|
||||
$isNumeric = static fn($v) => $v !== null && $v !== '' && is_numeric($v);
|
||||
|
||||
$ackMap = [];
|
||||
try {
|
||||
$ackRows = $this->ackModel
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('school_year', $year)
|
||||
->where('semester', $sem)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->findAll();
|
||||
foreach ($ackRows as $row) {
|
||||
$sid = (int) ($row['student_id'] ?? 0);
|
||||
if ($sid > 0 && ! isset($ackMap[$sid])) {
|
||||
$ackMap[$sid] = $row;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$ackMap = [];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$completeCount = 0;
|
||||
$warningCount = 0;
|
||||
@@ -471,12 +492,16 @@ class ReportCardsController extends PrintablesBaseController
|
||||
$warningCount++;
|
||||
}
|
||||
|
||||
$ack = $sid > 0 ? ($ackMap[$sid] ?? null) : null;
|
||||
$results[] = [
|
||||
'id' => $sid,
|
||||
'name' => $name !== '' ? $name : 'N/A',
|
||||
'missing' => $missing,
|
||||
'warnings' => $warnings,
|
||||
'complete' => $isComplete,
|
||||
'viewed_at' => $ack['viewed_at'] ?? null,
|
||||
'signed_at' => $ack['signed_at'] ?? null,
|
||||
'signed_name' => $ack['signed_name'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -501,6 +526,37 @@ class ReportCardsController extends PrintablesBaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API: report card parent acknowledgement status
|
||||
* GET params: student_id, school_year, semester
|
||||
*/
|
||||
public function reportCardAcknowledgement()
|
||||
{
|
||||
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => 'Missing student_id'])->setStatusCode(400);
|
||||
}
|
||||
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? ''));
|
||||
$semester = trim((string) ($this->request->getGet('semester') ?? $this->semester ?? ''));
|
||||
|
||||
$row = $this->ackModel
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->first();
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'student_id' => $studentId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'viewed_at' => $row['viewed_at'] ?? null,
|
||||
'signed_at' => $row['signed_at'] ?? null,
|
||||
'signed_name' => $row['signed_name'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function fetchStudentsByClass(int $sectionId, ?string $schoolYear)
|
||||
{
|
||||
$b = $this->studentClassModel
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+3
-2
@@ -1287,14 +1287,14 @@ class ScoreController extends Controller
|
||||
public function viewStudentScore()
|
||||
{
|
||||
$parentId = session()->get('user_id');
|
||||
$userType = $_SESSION['user_type'];
|
||||
$userType = session()->get('user_type') ?? '';
|
||||
$firstParentId = null;
|
||||
$releaseFall = $this->getParentScoresReleasedForSemester('Fall');
|
||||
$releaseSpring = $this->getParentScoresReleasedForSemester('Spring');
|
||||
$releaseAny = $releaseFall || $releaseSpring;
|
||||
|
||||
// Identify the firstparent based on user type
|
||||
if ($userType === 'primary') {
|
||||
if ($userType === 'primary' || $userType === '') {
|
||||
$firstParentId = $parentId;
|
||||
} elseif ($userType === 'secondary') {
|
||||
$parentData = $this->db->table('parents')
|
||||
@@ -1378,6 +1378,7 @@ $students = $this->db->table('students')
|
||||
$releaseScores = (strcasecmp($semester, 'Fall') === 0) ? $releaseFall
|
||||
: ((strcasecmp($semester, 'Spring') === 0) ? $releaseSpring : $releaseAny);
|
||||
$scores[$selectedYear][$semester][$studentId] = [
|
||||
'student_id' => $studentId,
|
||||
'school_id' => $student['school_id'],
|
||||
'student_firstname' => $student['firstname'],
|
||||
'student_lastname' => $student['lastname'],
|
||||
|
||||
Regular → Executable
+4
-4
@@ -81,10 +81,10 @@ class ScorePredictor extends Controller
|
||||
s.school_id,
|
||||
s.firstname,
|
||||
s.lastname,
|
||||
fall.semester_score as fall_score,
|
||||
spring.semester_score as spring_score');
|
||||
// Also select class section for per-class trophy decision
|
||||
$builder->select('sc.class_section_id as class_section_id');
|
||||
MAX(fall.semester_score) as fall_score,
|
||||
MAX(spring.semester_score) as spring_score');
|
||||
// Reduce duplication from restored students while keeping a stable class section.
|
||||
$builder->select('MAX(sc.class_section_id) as class_section_id');
|
||||
$yearEsc = $this->db->escape($selectedYear);
|
||||
$builder->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $yearEsc, 'left');
|
||||
$builder->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left');
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+1
@@ -99,6 +99,7 @@ class SubjectCurriculumController extends BaseController
|
||||
->orderBy('classes.class_name', 'ASC')
|
||||
->orderBy('subject', 'ASC')
|
||||
->orderBy('unit_number', 'ASC')
|
||||
->orderBy("CAST(SUBSTRING_INDEX(subject_curriculum_items.chapter_name, '.', 1) AS UNSIGNED)", 'ASC', false)
|
||||
->orderBy('chapter_name', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+10
-6
@@ -297,12 +297,16 @@ class TeacherController extends BaseController
|
||||
$schoolYear = $forYear ?: ((string)($this->schoolYear ?? 'Not Set'));
|
||||
|
||||
$teachers = $this->teacherModel->getTeachersAndTAs();
|
||||
// Prefer class sections for the selected year, fallback to all if none
|
||||
$classSections = $classSectionModel
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
if (empty($classSections)) {
|
||||
// Prefer class sections for the selected year if the column exists, otherwise fallback to all.
|
||||
if ($this->db->fieldExists('school_year', 'classSection')) {
|
||||
$classSections = $classSectionModel
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
if (empty($classSections)) {
|
||||
$classSections = $classSectionModel->orderBy('class_section_name', 'ASC')->findAll();
|
||||
}
|
||||
} else {
|
||||
$classSections = $classSectionModel->orderBy('class_section_name', 'ASC')->findAll();
|
||||
}
|
||||
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
+160
-34
@@ -21,6 +21,7 @@ require_once APPPATH . 'Helpers/pbkdf2_helper.php';
|
||||
|
||||
class UserController extends BaseController
|
||||
{
|
||||
private const ACTIVATION_TTL_HOURS = 48;
|
||||
protected $userModel;
|
||||
protected $roleModel;
|
||||
protected $userRoleModel;
|
||||
@@ -49,6 +50,37 @@ class UserController extends BaseController
|
||||
$this->resetRequestModel = new PasswordResetRequestModel();
|
||||
}
|
||||
|
||||
private function denyAccess(string $message)
|
||||
{
|
||||
if ($this->request->isAJAX() || $this->request->getHeaderLine('Accept') === 'application/json') {
|
||||
return service('response')
|
||||
->setStatusCode(403)
|
||||
->setJSON(['status' => 'error', 'message' => $message]);
|
||||
}
|
||||
|
||||
session()->setFlashdata('error', $message);
|
||||
return redirect()->to('/access_denied');
|
||||
}
|
||||
|
||||
private function requirePermission(string $permission)
|
||||
{
|
||||
if (!session()->get('is_logged_in')) {
|
||||
return redirect()->to('/login');
|
||||
}
|
||||
|
||||
$userId = (int) session()->get('user_id');
|
||||
if ($userId <= 0 || !has_permission($userId, $permission)) {
|
||||
return $this->denyAccess("You don't have permission to use this feature.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function hashToken(string $token): string
|
||||
{
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
|
||||
// Method to show the home page
|
||||
public function home()
|
||||
{
|
||||
@@ -75,6 +107,10 @@ class UserController extends BaseController
|
||||
|
||||
public function userList()
|
||||
{
|
||||
if ($resp = $this->requirePermission('read_user')) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
helper('url');
|
||||
|
||||
return view('user/user_list', [
|
||||
@@ -85,6 +121,10 @@ class UserController extends BaseController
|
||||
// Method to show the list of users
|
||||
public function index()
|
||||
{
|
||||
if ($resp = $this->requirePermission('read_user')) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
// Fetch users along with their assigned roles
|
||||
$builder = $this->db->table('users');
|
||||
$builder->select('users.id, users.firstname, users.lastname, users.email, user_roles.role_id, roles.name as role, users.status, users.updated_at');
|
||||
@@ -122,6 +162,10 @@ class UserController extends BaseController
|
||||
|
||||
public function userListData()
|
||||
{
|
||||
if ($resp = $this->requirePermission('read_user')) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'users' => $this->buildUsersWithRoles(),
|
||||
]);
|
||||
@@ -253,6 +297,10 @@ class UserController extends BaseController
|
||||
// Method to store a new user
|
||||
public function store()
|
||||
{
|
||||
if ($resp = $this->requirePermission('edit_user')) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
// Validate input data
|
||||
$validation = \Config\Services::validation();
|
||||
$validation->setRules([
|
||||
@@ -315,6 +363,10 @@ class UserController extends BaseController
|
||||
// Method to show the form for editing an existing user
|
||||
public function edit($id)
|
||||
{
|
||||
if ($resp = $this->requirePermission('edit_user')) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$data['user'] = $this->userModel->find($id);
|
||||
$data['roles'] = $this->roleModel->findAll();
|
||||
$userRoles = $this->userRoleModel->where('user_id', $id)->findAll();
|
||||
@@ -327,6 +379,10 @@ class UserController extends BaseController
|
||||
// Method to delete an existing user
|
||||
public function delete($id)
|
||||
{
|
||||
if ($resp = $this->requirePermission('edit_user')) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$this->userModel->delete($id);
|
||||
|
||||
// Delete the user's roles from the user_roles table
|
||||
@@ -369,27 +425,21 @@ class UserController extends BaseController
|
||||
$email = strtolower($this->request->getPost('email'));
|
||||
$user = $this->userModel->where('email', $email)->first();
|
||||
|
||||
// --- Handle unknown email ---
|
||||
if (!$user) {
|
||||
session()->setFlashdata('error', 'If this email is registered, you will receive a reset link.');
|
||||
log_message('info', "Password reset requested for non-existing user {$email}");
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// --- Handle unverified accounts ---
|
||||
if ((int) $user['is_verified'] === 0) {
|
||||
session()->setFlashdata('error', 'Please check your email and complete the account activation process before resetting your password.');
|
||||
log_message('info', "Password reset blocked for unverified user {$email}");
|
||||
// --- Handle unknown or unverified email ---
|
||||
if (!$user || (int) $user['is_verified'] === 0) {
|
||||
session()->setFlashdata('success', 'If this email is registered, you will receive a reset link.');
|
||||
log_message('info', "Password reset requested for {$email} (user missing or unverified).");
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
// --- Verified user: continue with reset ---
|
||||
$token = bin2hex(random_bytes(48));
|
||||
$tokenHash = $this->hashToken($token);
|
||||
$expires_at = Time::now()->addHours(1);
|
||||
|
||||
$this->passwordResetModel->insert([
|
||||
'email' => $email,
|
||||
'token' => $token,
|
||||
'token' => $tokenHash,
|
||||
'created_at' => Time::now(),
|
||||
'expires_at' => $expires_at,
|
||||
]);
|
||||
@@ -447,7 +497,12 @@ class UserController extends BaseController
|
||||
}
|
||||
|
||||
// You may want to validate the token here
|
||||
$resetEntry = $this->passwordResetModel->where('token', $token)
|
||||
$tokenHash = $this->hashToken($token);
|
||||
$resetEntry = $this->passwordResetModel
|
||||
->groupStart()
|
||||
->where('token', $tokenHash)
|
||||
->orWhere('token', $token)
|
||||
->groupEnd()
|
||||
->where('expires_at >=', Time::now())
|
||||
->first();
|
||||
|
||||
@@ -462,6 +517,10 @@ class UserController extends BaseController
|
||||
//This function processes the new password submission, validating the token, updating the user's password, and cleaning up the reset entry.
|
||||
public function processResetPassword()
|
||||
{
|
||||
if (strtolower($this->request->getMethod()) !== 'post') {
|
||||
return redirect()->to('/')->with('error', 'Invalid request.');
|
||||
}
|
||||
|
||||
$token = $this->request->getPost('token');
|
||||
$newPassword = $this->request->getPost('password');
|
||||
$passConfirm = $this->request->getPost('pass_confirm');
|
||||
@@ -490,7 +549,12 @@ class UserController extends BaseController
|
||||
}
|
||||
|
||||
// Find the password reset entry
|
||||
$resetEntry = $this->passwordResetModel->where('token', $token)
|
||||
$tokenHash = $this->hashToken($token);
|
||||
$resetEntry = $this->passwordResetModel
|
||||
->groupStart()
|
||||
->where('token', $tokenHash)
|
||||
->orWhere('token', $token)
|
||||
->groupEnd()
|
||||
->where('expires_at >=', Time::now())
|
||||
->first();
|
||||
|
||||
@@ -519,7 +583,12 @@ class UserController extends BaseController
|
||||
]);
|
||||
|
||||
// Delete the used token from the password reset table
|
||||
$this->passwordResetModel->where('token', $token)->delete();
|
||||
$this->passwordResetModel
|
||||
->groupStart()
|
||||
->where('token', $tokenHash)
|
||||
->orWhere('token', $token)
|
||||
->groupEnd()
|
||||
->delete();
|
||||
|
||||
// Retrieve the user's IP address from the request
|
||||
$ipAddress = $this->request->getIPAddress();
|
||||
@@ -546,10 +615,16 @@ class UserController extends BaseController
|
||||
|
||||
public function confirm($token)
|
||||
{
|
||||
log_message('info', 'Processing email confirmation with token: ' . $token);
|
||||
log_message('info', 'Processing email confirmation.');
|
||||
|
||||
|
||||
$user = $this->userModel->where('token', $token)->first();
|
||||
$tokenHash = $this->hashToken($token);
|
||||
$user = $this->userModel
|
||||
->groupStart()
|
||||
->where('token', $tokenHash)
|
||||
->orWhere('token', $token)
|
||||
->groupEnd()
|
||||
->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString())
|
||||
->first();
|
||||
|
||||
if (!$user || $user['is_verified'] == 1) {
|
||||
return redirect()->to('/invalid_token');
|
||||
@@ -570,7 +645,14 @@ class UserController extends BaseController
|
||||
{
|
||||
//echo "Reached setPassword with token: " . esc($token);
|
||||
//echo "Token received: " . $token;
|
||||
$user = $this->userModel->where('token', $token)->first();
|
||||
$tokenHash = $this->hashToken($token);
|
||||
$user = $this->userModel
|
||||
->groupStart()
|
||||
->where('token', $tokenHash)
|
||||
->orWhere('token', $token)
|
||||
->groupEnd()
|
||||
->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString())
|
||||
->first();
|
||||
|
||||
if (!$user || $user['is_verified'] == 1) {
|
||||
return redirect()->to('/invalid_token');
|
||||
@@ -584,6 +666,10 @@ class UserController extends BaseController
|
||||
|
||||
public function savePassword()
|
||||
{
|
||||
if (strtolower($this->request->getMethod()) !== 'post') {
|
||||
return redirect()->to('/')->with('error', 'Invalid request.');
|
||||
}
|
||||
|
||||
$validation = \Config\Services::validation();
|
||||
$validation->setRules([
|
||||
'password' => [
|
||||
@@ -615,9 +701,17 @@ class UserController extends BaseController
|
||||
$token = $this->request->getPost('token');
|
||||
$password = $this->request->getPost('password');
|
||||
|
||||
$user = $this->userModel->where('id', $userId)->where('token', $token)->first();
|
||||
$tokenHash = $this->hashToken($token);
|
||||
$user = $this->userModel
|
||||
->where('id', $userId)
|
||||
->groupStart()
|
||||
->where('token', $tokenHash)
|
||||
->orWhere('token', $token)
|
||||
->groupEnd()
|
||||
->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString())
|
||||
->first();
|
||||
|
||||
log_message('debug', "Attempting to set password for user $userId with token $token");
|
||||
log_message('debug', "Attempting to set password for user $userId");
|
||||
|
||||
if (!$user || $user['is_verified'] == 1) {
|
||||
return redirect()->to('/invalid_token');
|
||||
@@ -670,20 +764,39 @@ class UserController extends BaseController
|
||||
$roleKey = (string) $this->request->getPost('role');
|
||||
log_message('info', 'Role selected: ' . $roleKey);
|
||||
|
||||
$roleModel = new RoleModel();
|
||||
$route = $roleModel->getRouteByNameOrSlug($roleKey);
|
||||
|
||||
if ($route === null) {
|
||||
log_message('error', 'Invalid or inactive role selected: ' . $roleKey);
|
||||
return redirect()->back()->with('error', 'Invalid role selected.');
|
||||
}
|
||||
|
||||
$userId = (int) session()->get('user_id');
|
||||
log_message('info', 'User ID: ' . $userId);
|
||||
|
||||
if ($userId <= 0) {
|
||||
return $this->denyAccess("You don't have permission to use this feature.");
|
||||
}
|
||||
|
||||
$roleRow = $this->db->table('user_roles ur')
|
||||
->join('roles r', 'r.id = ur.role_id', 'inner')
|
||||
->select('r.name, r.slug, r.dashboard_route')
|
||||
->where('ur.user_id', $userId)
|
||||
->where('r.is_active', 1)
|
||||
->groupStart()
|
||||
->where('LOWER(r.name)', strtolower($roleKey))
|
||||
->orWhere('LOWER(r.slug)', strtolower($roleKey))
|
||||
->groupEnd()
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if (empty($roleRow)) {
|
||||
log_message('error', 'Invalid or unassigned role selected: ' . $roleKey);
|
||||
return redirect()->back()->with('error', 'Invalid role selected.');
|
||||
}
|
||||
|
||||
$route = $roleRow['dashboard_route'] ?? null;
|
||||
if ($route === null) {
|
||||
log_message('error', 'No dashboard route configured for role: ' . $roleKey);
|
||||
return redirect()->back()->with('error', 'Invalid role selected.');
|
||||
}
|
||||
|
||||
// Persist the *exact* role name or slug—choose your convention.
|
||||
// If you want to store the canonical name, fetch the row and use $row['name'].
|
||||
$this->userModel->update($userId, ['role' => $roleKey]);
|
||||
// Store the canonical name to avoid arbitrary role strings.
|
||||
$this->userModel->update($userId, ['role' => $roleRow['name']]);
|
||||
log_message('info', 'Role updated in database.');
|
||||
|
||||
log_message('info', 'Redirecting to role dashboard: ' . $route);
|
||||
@@ -702,6 +815,10 @@ class UserController extends BaseController
|
||||
|
||||
public function delete_role($roleId)
|
||||
{
|
||||
if ($resp = $this->requirePermission('edit_user')) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
// Fetch the role to be deleted
|
||||
$role = $this->roleModel->find($roleId);
|
||||
if (!$role) {
|
||||
@@ -731,6 +848,10 @@ class UserController extends BaseController
|
||||
|
||||
public function loginActivity()
|
||||
{
|
||||
if ($resp = $this->requirePermission('view_login_activity')) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
helper('url');
|
||||
|
||||
$perPage = (int) ($this->request->getGet('per_page') ?? 25);
|
||||
@@ -743,6 +864,10 @@ class UserController extends BaseController
|
||||
|
||||
public function loginActivityData()
|
||||
{
|
||||
if ($resp = $this->requirePermission('view_login_activity')) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$perPage = (int) ($this->request->getGet('per_page') ?? 25);
|
||||
$page = (int) ($this->request->getGet('page') ?? 1);
|
||||
|
||||
@@ -752,6 +877,10 @@ class UserController extends BaseController
|
||||
// Method to update an existing user
|
||||
public function updateUser()
|
||||
{
|
||||
if ($resp = $this->requirePermission('edit_user')) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
if (strtolower($this->request->getMethod()) !== 'post') {
|
||||
return redirect()->to(site_url('user/user_list'))->with('error', 'Invalid request.');
|
||||
}
|
||||
@@ -800,9 +929,6 @@ class UserController extends BaseController
|
||||
'status' => trim((string)$this->request->getPost('status')),
|
||||
'is_suspended' => $toBool('is_suspended'),
|
||||
'is_verified' => $toBool('is_verified'),
|
||||
'token' => trim((string)$this->request->getPost('token')),
|
||||
'updated_at' => $toDT('updated_at'),
|
||||
'created_at' => $toDT('created_at'),
|
||||
];
|
||||
|
||||
// Validation
|
||||
|
||||
Regular → Executable
Reference in New Issue
Block a user