diff --git a/env b/.env similarity index 50% rename from env rename to .env index da227d3..6443556 100644 --- a/env +++ b/.env @@ -14,62 +14,6 @@ # ENVIRONMENT #-------------------------------------------------------------------- -# CI_ENVIRONMENT = production - -#-------------------------------------------------------------------- -# APP -#-------------------------------------------------------------------- - -# app.baseURL = '' -# If you have trouble with `.`, you could also use `_`. -# app_baseURL = '' -# app.forceGlobalSecureRequests = false -# app.CSPEnabled = false - -#-------------------------------------------------------------------- -# DATABASE -#-------------------------------------------------------------------- - -# database.default.hostname = localhost -# database.default.database = ci4 -# database.default.username = root -# database.default.password = root -# database.default.DBDriver = MySQLi -# database.default.DBPrefix = -# database.default.port = 3306 - -# If you use MySQLi as tests, first update the values of Config\Database::$tests. -# database.tests.hostname = localhost -# database.tests.database = ci4_test -# database.tests.username = root -# database.tests.password = root -# database.tests.DBDriver = MySQLi -# database.tests.DBPrefix = -# database.tests.charset = utf8mb4 -# database.tests.DBCollat = utf8mb4_general_ci -# database.tests.port = 3306 - -#-------------------------------------------------------------------- -# ENCRYPTION -#-------------------------------------------------------------------- - -# encryption.key = - -#-------------------------------------------------------------------- -# SESSION -#-------------------------------------------------------------------- - -# session.driver = 'CodeIgniter\Session\Handlers\FileHandler' -# session.savePath = null - -#-------------------------------------------------------------------- -# LOGGER -#-------------------------------------------------------------------- - -# logger.threshold = 4 -#-------------------------------------------------------------------- -# ENVIRONMENT -#-------------------------------------------------------------------- CI_ENVIRONMENT = development # Which profile to use if none is supplied to sendEmail() @@ -77,53 +21,20 @@ CI_ENVIRONMENT = development MAIL_PROFILE_DEFAULT=default # ===== DEFAULT (system) ===== -MAIL_DEFAULT_HOST=smtp.hostinger.com -MAIL_DEFAULT_USER=no-replay@alrahmaisgl.org -MAIL_DEFAULT_PASS="W8;xZ/5g" -MAIL_DEFAULT_PORT=587 -MAIL_DEFAULT_ENCRYPTION=ssl -MAIL_DEFAULT_FROM_EMAIL=no-replay@alrahmaisgl.org -MAIL_DEFAULT_FROM_NAME="Al Rahma Sunday School" -# Optional Reply-To (uncomment if you want a replyable inbox) -MAIL_DEFAULT_REPLY_TO=alrahma.isgl@gmail.com -MAIL_DEFAULT_REPLY_TO_NAME="No-Reply" -# Optional bounce address (envelope sender) -# MAIL_DEFAULT_RETURN_PATH=bounces@alrahmaisgl.org -# Optional DKIM -# MAIL_DEFAULT_DKIM_DOMAIN=alrahmaisgl.org -# MAIL_DEFAULT_DKIM_SELECTOR=default -# MAIL_DEFAULT_DKIM_PRIVATE=/path/to/private.key +# ---- Legacy fallbacks (kept for compatibility) ---- +SMTP_HOST = smtp.gmail.com +SMTP_USER = alrahma.sunday.school@gmail.com +SMTP_PASS = "psnp emdq dykw ypul" +SMTP_PORT = 587 +SMTP_ENCRYPTION = tls # was ssl; use tls for port 587 -# ===== COMMUNICATION ===== -MAIL_COMMUNICATION_HOST=smtp.hostinger.com -MAIL_COMMUNICATION_USER=no-replay@alrahmaisgl.org -MAIL_COMMUNICATION_PASS="W8;xZ/5g" -MAIL_COMMUNICATION_PORT=587 -MAIL_COMMUNICATION_ENCRYPTION=ssl -MAIL_COMMUNICATION_FROM_EMAIL=no-replay@alrahmaisgl.org -MAIL_COMMUNICATION_FROM_NAME="School Communications" +# Reply-To for all mail profiles +MAIL_DEFAULT_REPLY_TO=alrahma.isgl@gmail.com +MAIL_DEFAULT_REPLY_TO_NAME="Al Rahma Sunday School" MAIL_COMMUNICATION_REPLY_TO=alrahma.isgl@gmail.com MAIL_COMMUNICATION_REPLY_TO_NAME="School Communications" -# MAIL_COMMUNICATION_RETURN_PATH=bounces@alrahmaisgl.org - -# ===== PAYMENT ===== -MAIL_PAYMENT_HOST=smtp.hostinger.com -MAIL_PAYMENT_USER=no-replay@alrahmaisgl.org -MAIL_PAYMENT_PASS="W8;xZ/5g" -MAIL_PAYMENT_PORT=587 -MAIL_PAYMENT_ENCRYPTION=ssl -MAIL_PAYMENT_FROM_EMAIL=no-replay@alrahmaisgl.org -MAIL_PAYMENT_FROM_NAME="School Payments" MAIL_PAYMENT_REPLY_TO=alrahma.isgl@gmail.com MAIL_PAYMENT_REPLY_TO_NAME="School Payments" -# MAIL_PAYMENT_RETURN_PATH=bounces@alrahmaisgl.org - -# ---- Legacy fallbacks (kept for compatibility) ---- -SMTP_HOST = smtp.hostinger.com -SMTP_USER = no-replay@alrahmaosgl.org -SMTP_PASS = "W8;xZ/5g" -SMTP_PORT = 587 -SMTP_ENCRYPTION = ssl # was SSL; set to ssl to match 587 # Optional sender directory you already use elsewhere MAIL_SENDERS="{\"general\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma No-Reply\"},\ @@ -131,11 +42,20 @@ MAIL_SENDERS="{\"general\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al R \"notifications\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma Notifications\"},\ \"finance\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma Finance Office\"}}" +# Principal notification recipient for TimeOff requests (comma-separated allowed) +PRINCIPAL_EMAIL="melabidi@alrahmaisgl.org" +MAIL_PARENT_REPORT_TO="alrahma.isgl@gmail.com" +MAIL_FROM_ADDRESS = "alrahma.sunday.school@gmail.com" + +session.expiration = 43200 #-------------------------------------------------------------------- # APP #-------------------------------------------------------------------- -app.baseURL = 'http://localhost:8080/' -app.forceGlobalSecureRequests = false + +# If you have trouble with `.`, you could also use `_`. + app_baseURL = 'http://localhost:8080/' +# app.forceGlobalSecureRequests = true +# app.CSPEnabled = false #-------------------------------------------------------------------- # DATABASE @@ -143,21 +63,122 @@ app.forceGlobalSecureRequests = false database.default.hostname = 127.0.0.1 database.default.database = school database.default.username = root -database.default.password = +database.default.password = database.default.DBDriver = MySQLi database.default.DBPrefix = database.default.port = 3306 + #-------------------------------------------------------------------- -# CODEIGNITER EMAIL (only used if you use CI Email class) -# These don't affect PHPMailer but keep them aligned anyway. -mail.protocol = smtp -mail.SMTPHost = ${SMTP_HOST} -mail.SMTPUser = ${SMTP_USER} -mail.SMTPPass = ${SMTP_PASS} -mail.SMTPPort = ${SMTP_PORT} -mail.SMTPCrypto = ssl -mail.fromEmail = ${SMTP_USER} -mail.fromName = "Al Rahma Sunday School" -mail.mailType = html -mail.charset = UTF-8 +# CONTENT SECURITY POLICY +#-------------------------------------------------------------------- + +# contentsecuritypolicy.reportOnly = false +# contentsecuritypolicy.defaultSrc = 'none' +# contentsecuritypolicy.scriptSrc = 'self' +# contentsecuritypolicy.styleSrc = 'self' +# contentsecuritypolicy.imageSrc = 'self' +# contentsecuritypolicy.baseURI = null +# contentsecuritypolicy.childSrc = null +# contentsecuritypolicy.connectSrc = 'self' +# contentsecuritypolicy.fontSrc = null +# contentsecuritypolicy.formAction = null +# contentsecuritypolicy.frameAncestors = null +# contentsecuritypolicy.frameSrc = null +# contentsecuritypolicy.mediaSrc = null +# contentsecuritypolicy.objectSrc = null +# contentsecuritypolicy.pluginTypes = null +# contentsecuritypolicy.reportURI = null +# contentsecuritypolicy.sandbox = false +# contentsecuritypolicy.upgradeInsecureRequests = false +# contentsecuritypolicy.styleNonceTag = '{csp-style-nonce}' +# contentsecuritypolicy.scriptNonceTag = '{csp-script-nonce}' +# contentsecuritypolicy.autoNonce = true + +#-------------------------------------------------------------------- +# COOKIE +#-------------------------------------------------------------------- + +# cookie.prefix = '' +# cookie.expires = 0 +# cookie.path = '/' +# cookie.domain = '' +# cookie.secure = false +# cookie.httponly = false +# cookie.samesite = 'Lax' +# cookie.raw = false + +#-------------------------------------------------------------------- +# ENCRYPTION +#-------------------------------------------------------------------- + +# encryption.key = +# encryption.driver = OpenSSL +# encryption.blockSize = 16 +# encryption.digest = SHA512 + +#-------------------------------------------------------------------- +# HONEYPOT +#-------------------------------------------------------------------- + +# honeypot.hidden = 'true' +# honeypot.label = 'Fill This Field' +# honeypot.name = 'honeypot' +# honeypot.template = '' +# honeypot.container = '
' + +#-------------------------------------------------------------------- +# SECURITY +#-------------------------------------------------------------------- + +# security.csrfProtection = 'cookie' +# security.tokenRandomize = false +# security.tokenName = 'csrf_token_name' +# security.headerName = 'X-CSRF-TOKEN' +# security.cookieName = 'csrf_cookie_name' +# security.expires = 7200 +# security.regenerate = true +# security.redirect = false +# security.samesite = 'Lax' + +#-------------------------------------------------------------------- +# SESSION +#-------------------------------------------------------------------- + +# session.driver = 'CodeIgniter\Session\Handlers\FileHandler' +# session.cookieName = 'ci_session' +# session.expiration = 7200 +# session.savePath = null +# session.matchIP = false +# session.timeToUpdate = 300 +# session.regenerateDestroy = false + +#-------------------------------------------------------------------- +# LOGGER +#-------------------------------------------------------------------- + +logger.threshold = 4 +MAIL_DEBUG = true +MAIL_TIMEOUT = 15 + +#-------------------------------------------------------------------- +# CURLRequest +#-------------------------------------------------------------------- + +# curlrequest.shareOptions = false + + + + + +mail.protocol = smtp +mail.SMTPHost = ${SMTP_HOST} +mail.SMTPUser = ${SMTP_USER} +mail.SMTPPass = ${SMTP_PASS} +mail.SMTPPort = ${SMTP_PORT} +mail.SMTPCrypto = tls + +mail.fromEmail = ${SMTP_USER} +mail.fromName = "Al Rahma Sunday School" +mail.mailType = text +mail.charset = UTF-8 diff --git a/app/Config/Routes.php b/app/Config/Routes.php index c722b7c..5f69af6 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -108,6 +108,7 @@ $routes->get('administrator/student-score-card', 'View\StudentController::scoreC // API for report card meta (students, class sections, school years) $routes->get('api/printables/report-card/meta', 'View\ReportCardsController::reportCardMeta', ['filter' => 'auth']); $routes->get('api/printables/report-card/completeness', 'View\ReportCardsController::reportCardCompleteness', ['filter' => 'auth']); +$routes->get('api/printables/report-card/ack', 'View\ReportCardsController::reportCardAcknowledgement', ['filter' => 'auth']); //Badges $routes->get('printables_reports/badge_form', 'View\BadgesController::badgeForm'); @@ -330,6 +331,9 @@ $routes->post('/administrator/scores/update/{id}', 'View\ScoreController::update // Route to delete a score record $routes->get('/administrator/scores/delete/{id}', 'View\ScoreController::destroy'); $routes->get('/parent/scores', 'View\ScoreController::viewStudentScore'); +$routes->get('parent/report-cards', 'ParentReportCardController::index', ['filter' => 'auth:parent']); +$routes->get('parent/report-cards/view/(:num)', 'ParentReportCardController::view/$1', ['filter' => 'auth:parent']); +$routes->post('parent/report-cards/sign/(:num)', 'ParentReportCardController::sign/$1', ['filter' => 'auth:parent']); diff --git a/app/Controllers/AdminProgressController.php b/app/Controllers/AdminProgressController.php index 5f323ad..965d9fe 100644 --- a/app/Controllers/AdminProgressController.php +++ b/app/Controllers/AdminProgressController.php @@ -5,7 +5,11 @@ namespace App\Controllers; use App\Models\ClassProgressReportModel; use App\Models\ClassProgressAttachmentModel; use App\Models\ClassSectionModel; +use App\Models\CalendarModel; +use App\Models\ConfigurationModel; use App\Models\StudentClassModel; +use App\Models\SubjectCurriculumModel; +use App\Services\SemesterRangeService; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\ResponseInterface; @@ -15,6 +19,10 @@ class AdminProgressController extends BaseController protected ClassProgressAttachmentModel $attachmentModel; protected ClassSectionModel $classSectionModel; protected StudentClassModel $studentClassModel; + protected CalendarModel $calendarModel; + protected ConfigurationModel $configModel; + protected SemesterRangeService $semesterRangeService; + protected SubjectCurriculumModel $curriculumModel; public function __construct() { @@ -23,6 +31,10 @@ class AdminProgressController extends BaseController $this->attachmentModel = new ClassProgressAttachmentModel(); $this->classSectionModel = new ClassSectionModel(); $this->studentClassModel = new StudentClassModel(); + $this->calendarModel = new CalendarModel(); + $this->configModel = new ConfigurationModel(); + $this->semesterRangeService = new SemesterRangeService($this->configModel); + $this->curriculumModel = new SubjectCurriculumModel(); } public function index() @@ -53,22 +65,30 @@ class AdminProgressController extends BaseController } $rows = $builder->orderBy('week_start', 'DESC')->get()->getResultArray(); - $reportGroups = []; + $reportGroupsBySection = []; foreach ($rows as $row) { $row['status_label'] = ClassProgressController::STATUS_OPTIONS[$row['status']] ?? 'Unknown'; - $key = ($row['week_start'] ?? '') . '_' . ($row['class_section_id'] ?? ''); - if ($key === '_') { + $sectionId = (int) ($row['class_section_id'] ?? 0); + $weekKey = (string) ($row['week_start'] ?? ''); + if ($sectionId === 0 || $weekKey === '') { continue; } - if (! isset($reportGroups[$key])) { - $reportGroups[$key] = [ + if (! isset($reportGroupsBySection[$sectionId])) { + $reportGroupsBySection[$sectionId] = []; + } + if (! isset($reportGroupsBySection[$sectionId][$weekKey])) { + $reportGroupsBySection[$sectionId][$weekKey] = [ 'week_start' => $row['week_start'], 'week_end' => $row['week_end'], 'class_section_name' => $row['class_section_name'] ?? '', 'reports' => [], ]; } - $reportGroups[$key]['reports'][$row['subject']] = $row; + $reportGroupsBySection[$sectionId][$weekKey]['reports'][$row['subject']] = $row; + } + foreach ($reportGroupsBySection as $sectionId => $groups) { + krsort($groups); + $reportGroupsBySection[$sectionId] = $groups; } $classSections = $this->classSectionModel->getClassSections(); @@ -78,12 +98,29 @@ class AdminProgressController extends BaseController return isset($studentCounts[$sectionId]) && $studentCounts[$sectionId] > 0; })); + $filterStart = $this->normalizeDate($filters['from'] ?? ''); + $filterEnd = $this->normalizeDate($filters['to'] ?? ''); + [$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet] = $this->buildSemesterDates(); + [$expectedDays, $activeDatesSet] = $this->resolveExpectedDays( + $dateList, + $noSchoolDays, + $totalPassedDays, + $passedDatesSet, + $filterStart, + $filterEnd + ); + $sectionStats = $this->buildSectionSubmissionStats($rows, $activeDatesSet, $expectedDays); + $sectionSubjectCounts = $this->buildSectionSubjectCounts($rows); + return view('admin/class_progress_list', [ - 'reportGroups' => $reportGroups, + 'reportGroupsBySection' => $reportGroupsBySection, 'filters' => $filters, 'classSections' => $filteredSections, 'statusOptions' => ClassProgressController::STATUS_OPTIONS, 'subjectSections' => ClassProgressController::SUBJECT_SECTIONS, + 'sectionStats' => $sectionStats, + 'sectionSubjectCounts' => $sectionSubjectCounts, + 'expectedDays' => $expectedDays, ]); } @@ -211,4 +248,330 @@ class AdminProgressController extends BaseController $flags = json_decode($json, true); return is_array($flags) ? $flags : []; } + + protected function normalizeDate(string $value): string + { + $value = trim($value); + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + return ''; + } + [$y, $m, $d] = array_map('intval', explode('-', $value)); + return checkdate($m, $d, $y) ? $value : ''; + } + + protected function buildSemesterDates(): array + { + $schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); + $semester = (string) ($this->configModel->getConfig('semester') ?? ''); + $schoolYearForRange = $schoolYear !== '' ? $schoolYear : (string) ($this->configModel->getConfig('school_year') ?? ''); + [$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYearForRange); + $semesterNorm = $this->semesterRangeService->normalizeSemester($semester); + if ($semesterNorm !== '' && $schoolYearForRange !== '') { + $semRange = $this->semesterRangeService->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 { + $events = $this->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'); + + $passedDatesSet = []; + if (! empty($dateList) && $anchorSundayYmd !== '') { + foreach ($dateList as $d) { + if ($d <= $anchorSundayYmd && empty($noSchoolDays[$d])) { + $passedDatesSet[$d] = true; + } + } + } + $totalPassedDays = count($passedDatesSet); + + return [$dateList, $noSchoolDays, $totalPassedDays, $passedDatesSet]; + } + + protected function resolveExpectedDays( + array $dateList, + array $noSchoolDays, + int $totalPassedDays, + array $passedDatesSet, + string $filterStart, + string $filterEnd + ): array { + $activeDatesSet = []; + if ($filterStart === '' && $filterEnd === '') { + $expectedDays = $totalPassedDays; + if ($expectedDays > 0) { + $activeDatesSet = $passedDatesSet; + } + return [$expectedDays, $activeDatesSet]; + } + + $expectedDays = 0; + foreach ($dateList as $d) { + if ($d === '') { + continue; + } + if ($filterStart !== '' && $d < $filterStart) { + continue; + } + if ($filterEnd !== '' && $d > $filterEnd) { + continue; + } + if (! empty($noSchoolDays[$d])) { + continue; + } + $activeDatesSet[$d] = true; + $expectedDays++; + } + + return [$expectedDays, $activeDatesSet]; + } + + protected function buildSectionSubmissionStats(array $rows, array $activeDatesSet, int $expectedDays): array + { + $submittedBySection = []; + foreach ($rows as $row) { + $sectionId = (int) ($row['class_section_id'] ?? 0); + $weekStart = (string) ($row['week_start'] ?? ''); + if ($sectionId === 0 || $weekStart === '') { + continue; + } + $submittedBySection[$sectionId][$weekStart] = true; + } + + $stats = []; + foreach ($submittedBySection as $sectionId => $weeks) { + $submitted = count($weeks); + $stats[$sectionId] = $this->buildSectionStat($submitted, $expectedDays); + } + + return $stats; + } + + protected function buildSectionSubjectCounts(array $rows): array + { + $allowedSubjects = []; + foreach (ClassProgressController::SUBJECT_SECTIONS as $section) { + $allowedSubjects[] = $section['db_subject'] ?? $section['label'] ?? ''; + } + $allowedSubjects = array_values(array_filter($allowedSubjects)); + + $counts = []; + $latestWeekBySection = []; + $sectionClassMap = []; + foreach ($rows as $row) { + $sectionId = (int) ($row['class_section_id'] ?? 0); + $subject = (string) ($row['subject'] ?? ''); + $weekStart = (string) ($row['week_start'] ?? ''); + if ($sectionId === 0 || $subject === '' || $weekStart === '') { + continue; + } + if (! empty($allowedSubjects) && ! in_array($subject, $allowedSubjects, true)) { + continue; + } + if (! isset($sectionClassMap[$sectionId])) { + $sectionClassMap[$sectionId] = $this->classSectionModel->getClassId($sectionId); + } + if ( + ! isset($latestWeekBySection[$sectionId]) + || $weekStart > $latestWeekBySection[$sectionId] + ) { + $latestWeekBySection[$sectionId] = $weekStart; + } + } + + $curriculumChapters = $this->buildCurriculumChapterMap(array_values(array_filter($sectionClassMap))); + + foreach ($rows as $row) { + $sectionId = (int) ($row['class_section_id'] ?? 0); + $subject = (string) ($row['subject'] ?? ''); + $weekStart = (string) ($row['week_start'] ?? ''); + if ($sectionId === 0 || $subject === '' || $weekStart === '') { + continue; + } + if (! empty($allowedSubjects) && ! in_array($subject, $allowedSubjects, true)) { + continue; + } + if (empty($latestWeekBySection[$sectionId]) || $weekStart !== $latestWeekBySection[$sectionId]) { + continue; + } + $subjectSlug = $this->resolveSubjectSlug($subject); + $classId = $sectionClassMap[$sectionId] ?? null; + $chapterSet = []; + if ($classId && $subjectSlug && ! empty($curriculumChapters[$classId][$subjectSlug])) { + $chapterSet = $curriculumChapters[$classId][$subjectSlug]; + } + $counts[$sectionId] = ($counts[$sectionId] ?? 0) + $this->countChapterSegments( + (string) ($row['unit_title'] ?? ''), + $chapterSet + ); + } + + return $counts; + } + + protected function buildCurriculumChapterMap(array $classIds): array + { + $classIds = array_values(array_filter(array_map('intval', $classIds))); + if (empty($classIds)) { + return []; + } + + $rows = $this->curriculumModel + ->whereIn('class_id', $classIds) + ->findAll(); + + $map = []; + foreach ($rows as $row) { + $classId = (int) ($row['class_id'] ?? 0); + $subject = (string) ($row['subject'] ?? ''); + $chapter = trim((string) ($row['chapter_name'] ?? '')); + if ($classId === 0 || $subject === '' || $chapter === '') { + continue; + } + $map[$classId][$subject][$chapter] = true; + } + + return $map; + } + + protected function resolveSubjectSlug(string $subject): ?string + { + foreach (ClassProgressController::SUBJECT_SECTIONS as $slug => $section) { + $dbSubject = (string) ($section['db_subject'] ?? ''); + $label = (string) ($section['label'] ?? ''); + if ($subject === $dbSubject || $subject === $label) { + return $slug; + } + } + return null; + } + + protected function countChapterSegments(string $unitTitle, array $chapterSet): int + { + $unitTitle = trim($unitTitle); + if ($unitTitle === '') { + return 1; + } + $parts = array_filter(array_map('trim', explode(';', $unitTitle)), static fn ($part) => $part !== ''); + if (! $parts) { + return 1; + } + + $count = 0; + $seen = []; + foreach ($parts as $part) { + $chapter = $this->extractChapterFromSegment($part); + $key = $chapter !== '' ? $chapter : $part; + if (! empty($chapterSet) && $chapter !== '' && empty($chapterSet[$chapter])) { + $key = $part; + } + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $count++; + } + + return $count > 0 ? $count : 1; + } + + protected function extractChapterFromSegment(string $segment): string + { + $segment = trim($segment); + if ($segment === '') { + return ''; + } + $pos = strrpos($segment, '/'); + if ($pos === false) { + return $segment; + } + return trim(substr($segment, $pos + 1)); + } + + protected function buildSectionStat(int $submitted, int $expectedDays): array + { + $percent = $expectedDays > 0 ? round(($submitted * 100) / $expectedDays, 1) : 0.0; + if ($expectedDays === 0) { + $badgeClass = 'bg-secondary'; + $labelClass = 'text-muted'; + } elseif ($percent >= 100) { + $badgeClass = 'bg-success'; + $labelClass = 'text-success'; + } elseif ($percent >= 60) { + $badgeClass = 'bg-warning text-dark'; + $labelClass = 'text-warning'; + } elseif ($percent >= 40) { + $badgeClass = 'bg-orange text-dark'; + $labelClass = 'text-warning'; + } else { + $badgeClass = 'bg-danger'; + $labelClass = 'text-danger'; + } + + return [ + 'submitted' => $submitted, + 'expected' => $expectedDays, + 'percent' => $percent, + 'badgeClass' => $badgeClass, + 'labelClass' => $labelClass, + ]; + } } diff --git a/app/Controllers/ClassProgressController.php b/app/Controllers/ClassProgressController.php index 134596b..b25f03c 100644 --- a/app/Controllers/ClassProgressController.php +++ b/app/Controllers/ClassProgressController.php @@ -7,6 +7,7 @@ use App\Models\ClassProgressAttachmentModel; use App\Models\ConfigurationModel; use App\Models\SubjectCurriculumModel; use App\Models\TeacherClassModel; +use App\Services\SemesterRangeService; use CodeIgniter\Exceptions\PageNotFoundException; class ClassProgressController extends BaseController @@ -56,6 +57,7 @@ class ClassProgressController extends BaseController $classSectionName = $first['class_section_name'] ?? null; $classId = $first['class_id'] ?? null; $sundayOptions = $this->buildSundayOptions(); + $defaultWeekStart = $this->pickDefaultWeekStart($sundayOptions); $subjectCurriculum = []; if ($classId) { foreach (self::SUBJECT_SECTIONS as $slug => $section) { @@ -69,7 +71,7 @@ class ClassProgressController extends BaseController 'classSectionName' => $classSectionName, 'classId' => $classId, 'sundayOptions' => $sundayOptions, - 'defaultWeekStart' => $sundayOptions[0] ?? '', + 'defaultWeekStart' => $defaultWeekStart, ]; return view('teacher/class_progress_submit', $data); } @@ -171,10 +173,16 @@ class ClassProgressController extends BaseController if ($selectedSectionId && ! in_array($selectedSectionId, $validSectionIds, true)) { $selectedSectionId = $validSectionIds[0] ?? null; } + [$semester, $schoolYear] = $this->resolveCurrentTerm(); + $allowedTeacherIds = $this->resolveAssignedTeacherIds($selectedSectionId, $semester, $schoolYear); + if (empty($allowedTeacherIds)) { + $allowedTeacherIds = [$teacherId]; + } $builder = $this->reportModel - ->select('class_progress_reports.*, cs.class_section_name') + ->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name') ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') - ->where('teacher_id', $teacherId); + ->join('users u', 'u.id = class_progress_reports.teacher_id', 'left') + ->whereIn('teacher_id', $allowedTeacherIds); if ($selectedSectionId) { $builder->where('class_progress_reports.class_section_id', $selectedSectionId); } @@ -216,20 +224,33 @@ class ClassProgressController extends BaseController { $teacherId = (int) session()->get('user_id'); $row = $this->reportModel - ->select('class_progress_reports.*, cs.class_section_name') + ->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name') ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') - ->where('teacher_id', $teacherId) - ->find((int) $id); + ->join('users u', 'u.id = class_progress_reports.teacher_id', 'left') + ->where('class_progress_reports.id', (int) $id) + ->first(); if (! $row) { throw new PageNotFoundException('Progress report not found.'); } + [$semester, $schoolYear] = $this->resolveCurrentTerm(); + $allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear); + if (empty($allowedTeacherIds)) { + if ($teacherId !== (int) $row['teacher_id']) { + throw new PageNotFoundException('Progress report not found.'); + } + $allowedTeacherIds = [(int) $row['teacher_id']]; + } elseif (! in_array($teacherId, $allowedTeacherIds, true)) { + throw new PageNotFoundException('Progress report not found.'); + } + $row['status_label'] = self::STATUS_OPTIONS[$row['status']] ?? 'Unknown'; $weeklyReports = $this->reportModel - ->select('class_progress_reports.*, cs.class_section_name') + ->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name') ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') - ->where('teacher_id', $teacherId) + ->join('users u', 'u.id = class_progress_reports.teacher_id', 'left') + ->whereIn('teacher_id', $allowedTeacherIds) ->where('class_progress_reports.class_section_id', $row['class_section_id']) ->where('week_start', $row['week_start']) ->orderBy('subject', 'ASC') @@ -427,6 +448,39 @@ class ClassProgressController extends BaseController } protected function buildSundayOptions(int $count = 12): array + { + $range = $this->resolveProgressDateRange(); + if ($range === null) { + return $this->buildUpcomingSundayOptions($count); + } + [$rangeStart, $rangeEnd] = $range; + + try { + $start = new \DateTime($rangeStart); + $end = new \DateTime($rangeEnd); + } catch (\Exception $e) { + return $this->buildUpcomingSundayOptions($count); + } + + if ($end < $start) { + [$start, $end] = [$end, $start]; + } + + $weekday = (int) $start->format('w'); + if ($weekday !== 0) { + $start->modify('next sunday'); + } + + $options = []; + while ($start <= $end) { + $options[] = $start->format('Y-m-d'); + $start->modify('+7 days'); + } + + return $options; + } + + protected function buildUpcomingSundayOptions(int $count): array { $start = new \DateTime('today'); $weekday = (int) $start->format('w'); @@ -443,6 +497,48 @@ class ClassProgressController extends BaseController return $options; } + protected function resolveProgressDateRange(): ?array + { + $schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); + if ($schoolYear === '') { + return null; + } + + $semesterResolver = new SemesterRangeService($this->configModel); + $semester = $semesterResolver->normalizeSemester((string) ($this->configModel->getConfig('semester') ?? '')); + if ($semester === '') { + $semester = $semesterResolver->getSemesterForDate(); + } + + if ($semester !== '') { + $range = $semesterResolver->getSemesterRange($schoolYear, $semester); + if ($range !== null) { + return $range; + } + } + + return $semesterResolver->getSchoolYearRange($schoolYear); + } + + protected function pickDefaultWeekStart(array $options): string + { + if (empty($options)) { + return ''; + } + + $today = date('Y-m-d'); + $default = ''; + foreach ($options as $option) { + if ($option <= $today) { + $default = $option; + continue; + } + break; + } + + return $default !== '' ? $default : $options[0]; + } + protected function buildWeekEndFromStart(string $weekStart): string { try { @@ -468,4 +564,27 @@ class ClassProgressController extends BaseController mkdir($this->attachmentStoragePath, 0755, true); } } + + protected function resolveCurrentTerm(): array + { + $schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); + $semester = (string) ($this->configModel->getConfig('semester') ?? ''); + return [$semester, $schoolYear]; + } + + protected function resolveAssignedTeacherIds(?int $classSectionId, string $semester, string $schoolYear): array + { + if (! $classSectionId || $schoolYear === '') { + return []; + } + $rows = $this->teacherClassModel->assignedForSectionTerm($classSectionId, $semester, $schoolYear); + $ids = []; + foreach ($rows as $row) { + $id = (int) ($row['teacher_id'] ?? 0); + if ($id > 0) { + $ids[$id] = true; + } + } + return array_keys($ids); + } } diff --git a/app/Controllers/ParentReportCardController.php b/app/Controllers/ParentReportCardController.php new file mode 100644 index 0000000..8206712 --- /dev/null +++ b/app/Controllers/ParentReportCardController.php @@ -0,0 +1,186 @@ +db = \Config\Database::connect(); + $this->configModel = new ConfigurationModel(); + $this->ackModel = new ReportCardAcknowledgementModel(); + $this->studentModel = new StudentModel(); + } + + public function index() + { + $parentId = $this->resolvePrimaryParentId(); + if (! $parentId) { + return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.'); + } + + $schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->configModel->getConfig('school_year') ?? '')); + $semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? '')); + + $builder = $this->db->table('students s') + ->select('s.id, s.firstname, s.lastname, cs.class_section_name') + ->join('student_class sc', 'sc.student_id = s.id', 'left') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->where('s.parent_id', $parentId) + ->orderBy('s.firstname', 'ASC') + ->orderBy('s.lastname', 'ASC'); + + if ($schoolYear !== '') { + $builder->where('sc.school_year', $schoolYear); + } + if ($semester !== '') { + $builder->where('sc.semester', $semester); + } + + $students = $builder->get()->getResultArray(); + $studentIds = array_values(array_filter(array_map(static fn ($s) => (int) ($s['id'] ?? 0), $students))); + + $ackMap = []; + if (! empty($studentIds)) { + $rows = $this->ackModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->whereIn('student_id', $studentIds) + ->findAll(); + foreach ($rows as $row) { + $ackMap[(int) $row['student_id']] = $row; + } + } + + return view('parent/report_cards', [ + 'students' => $students, + 'ackMap' => $ackMap, + 'schoolYear' => $schoolYear, + 'semester' => $semester, + ]); + } + + public function view($studentId) + { + $parentId = $this->resolvePrimaryParentId(); + if (! $parentId) { + return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.'); + } + + $student = $this->studentModel->find((int) $studentId); + if (! $student || (int) ($student['parent_id'] ?? 0) !== $parentId) { + throw new PageNotFoundException('Student not found.'); + } + + $schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->configModel->getConfig('school_year') ?? '')); + $semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? '')); + + $this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [ + 'viewed_at' => date('Y-m-d H:i:s'), + ]); + + $url = site_url('report-card/student/' . (int) $studentId); + $query = []; + if ($schoolYear !== '') { + $query['school_year'] = $schoolYear; + } + if ($semester !== '') { + $query['semester'] = $semester; + } + if ($query) { + $url .= '?' . http_build_query($query); + } + + return redirect()->to($url); + } + + public function sign($studentId) + { + $parentId = $this->resolvePrimaryParentId(); + if (! $parentId) { + return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.'); + } + + $student = $this->studentModel->find((int) $studentId); + if (! $student || (int) ($student['parent_id'] ?? 0) !== $parentId) { + throw new PageNotFoundException('Student not found.'); + } + + $name = trim((string) $this->request->getPost('signed_name')); + if ($name === '') { + return redirect()->back()->with('error', 'Please type your full name to sign.'); + } + + $schoolYear = trim((string) ($this->configModel->getConfig('school_year') ?? '')); + $semester = trim((string) ($this->configModel->getConfig('semester') ?? '')); + $now = date('Y-m-d H:i:s'); + $this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [ + 'viewed_at' => $now, + 'signed_at' => $now, + 'signed_name' => $name, + 'signer_ip' => $this->request->getIPAddress(), + ]); + + return redirect()->to(site_url('parent/report-cards'))->with('success', 'Report card acknowledged.'); + } + + protected function resolvePrimaryParentId(): ?int + { + $parentId = (int) (session()->get('user_id') ?? 0); + $userType = (string) ($_SESSION['user_type'] ?? ''); + if ($userType === 'primary') { + return $parentId ?: null; + } + if ($userType === 'secondary') { + $row = $this->db->table('parents') + ->select('parent_id') + ->where('secondparent_user_id', $parentId) + ->get() + ->getRowArray(); + return $row ? (int) ($row['parent_id'] ?? 0) : null; + } + if ($userType === 'tertiary') { + $row = $this->db->table('authorized_users') + ->select('user_id as parent_id') + ->where('authorized_user_id', $parentId) + ->get() + ->getRowArray(); + return $row ? (int) ($row['parent_id'] ?? 0) : null; + } + return $parentId ?: null; + } + + protected function touchAcknowledgement( + int $parentId, + int $studentId, + string $schoolYear, + string $semester, + array $values + ): void { + $criteria = [ + 'parent_id' => $parentId, + 'student_id' => $studentId, + 'school_year' => $schoolYear, + 'semester' => $semester, + ]; + $existing = $this->ackModel->where($criteria)->first(); + if ($existing) { + $this->ackModel->update((int) $existing['id'], $values); + return; + } + $this->ackModel->insert(array_merge($criteria, $values)); + } +} diff --git a/app/Controllers/View/AttendanceController.php b/app/Controllers/View/AttendanceController.php index eb0782f..f3b0962 100644 --- a/app/Controllers/View/AttendanceController.php +++ b/app/Controllers/View/AttendanceController.php @@ -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. diff --git a/app/Controllers/View/ClassPreparationController.php b/app/Controllers/View/ClassPreparationController.php index 1da0b69..db9456e 100644 --- a/app/Controllers/View/ClassPreparationController.php +++ b/app/Controllers/View/ClassPreparationController.php @@ -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; diff --git a/app/Controllers/View/ParentAttendanceReportController.php b/app/Controllers/View/ParentAttendanceReportController.php index 268aa85..ec1119f 100644 --- a/app/Controllers/View/ParentAttendanceReportController.php +++ b/app/Controllers/View/ParentAttendanceReportController.php @@ -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. */ diff --git a/app/Controllers/View/ReportCardsController.php b/app/Controllers/View/ReportCardsController.php index 74b6e1b..7b52691 100644 --- a/app/Controllers/View/ReportCardsController.php +++ b/app/Controllers/View/ReportCardsController.php @@ -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 diff --git a/app/Controllers/View/ScoreController.php b/app/Controllers/View/ScoreController.php index 4e05fba..ff24070 100644 --- a/app/Controllers/View/ScoreController.php +++ b/app/Controllers/View/ScoreController.php @@ -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'], diff --git a/app/Database/Migrations/2026-01-10-000007_AllowCompetitionWinnerRankTies.php b/app/Database/Migrations/2026-01-10-000007_AllowCompetitionWinnerRankTies.php index e7c6498..3729203 100644 --- a/app/Database/Migrations/2026-01-10-000007_AllowCompetitionWinnerRankTies.php +++ b/app/Database/Migrations/2026-01-10-000007_AllowCompetitionWinnerRankTies.php @@ -13,11 +13,11 @@ class AllowCompetitionWinnerRankTies extends Migration return; } - if ($db->indexExists('competition_winners', 'competition_id_class_section_id_rank')) { + if ($this->indexExists($db, 'competition_winners', 'competition_id_class_section_id_rank')) { $db->query('DROP INDEX competition_id_class_section_id_rank ON competition_winners'); } - if (!$db->indexExists('competition_winners', 'competition_id_class_section_id_rank')) { + if (! $this->indexExists($db, 'competition_winners', 'competition_id_class_section_id_rank')) { $db->query( 'CREATE INDEX competition_id_class_section_id_rank ON competition_winners (competition_id, class_section_id, rank)' ); @@ -31,7 +31,7 @@ class AllowCompetitionWinnerRankTies extends Migration return; } - if ($db->indexExists('competition_winners', 'competition_id_class_section_id_rank')) { + if ($this->indexExists($db, 'competition_winners', 'competition_id_class_section_id_rank')) { $db->query('DROP INDEX competition_id_class_section_id_rank ON competition_winners'); } @@ -39,4 +39,23 @@ class AllowCompetitionWinnerRankTies extends Migration 'ALTER TABLE competition_winners ADD UNIQUE KEY competition_id_class_section_id_rank (competition_id, class_section_id, rank)' ); } + + private function indexExists($db, string $table, string $index): bool + { + if (method_exists($db, 'indexExists')) { + return $db->indexExists($table, $index); + } + + $dbName = $db->getDatabase(); + $builder = $db->table('information_schema.STATISTICS'); + $row = $builder + ->select('INDEX_NAME') + ->where('TABLE_SCHEMA', $dbName) + ->where('TABLE_NAME', $table) + ->where('INDEX_NAME', $index) + ->get() + ->getRowArray(); + + return ! empty($row); + } } diff --git a/app/Database/Migrations/2026-02-05-000300_AddPageSelectionToPrintRequests.php b/app/Database/Migrations/2026-02-05-000300_AddPageSelectionToPrintRequests.php index 8af9a6b..70a47e5 100644 --- a/app/Database/Migrations/2026-02-05-000300_AddPageSelectionToPrintRequests.php +++ b/app/Database/Migrations/2026-02-05-000300_AddPageSelectionToPrintRequests.php @@ -1 +1,37 @@ -Print all \ No newline at end of file +db->tableExists('print_requests')) { + return; + } + + if (! $this->db->fieldExists('page_selection', 'print_requests')) { + $this->forge->addColumn('print_requests', [ + 'page_selection' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + 'after' => 'num_copies', + ], + ]); + } + } + + public function down() + { + if (! $this->db->tableExists('print_requests')) { + return; + } + + if ($this->db->fieldExists('page_selection', 'print_requests')) { + $this->forge->dropColumn('print_requests', 'page_selection'); + } + } +} diff --git a/app/Database/Migrations/2026-02-27-120000_CreateReportCardAcknowledgements.php b/app/Database/Migrations/2026-02-27-120000_CreateReportCardAcknowledgements.php new file mode 100644 index 0000000..54e49f3 --- /dev/null +++ b/app/Database/Migrations/2026-02-27-120000_CreateReportCardAcknowledgements.php @@ -0,0 +1,77 @@ +db->tableExists('report_card_acknowledgements')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'parent_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'student_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'school_year' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + ], + 'semester' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + ], + 'viewed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'signed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'signed_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'signer_ip' => [ + 'type' => 'VARCHAR', + 'constraint' => 45, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey(['parent_id', 'student_id', 'school_year', 'semester'], false, true); + $this->forge->createTable('report_card_acknowledgements'); + } + + public function down() + { + $this->forge->dropTable('report_card_acknowledgements', true); + } +} diff --git a/app/Models/ReportCardAcknowledgementModel.php b/app/Models/ReportCardAcknowledgementModel.php new file mode 100644 index 0000000..0778bc1 --- /dev/null +++ b/app/Models/ReportCardAcknowledgementModel.php @@ -0,0 +1,27 @@ +extend('layout/management_layout') ?> = $this->section('content') ?>| Week | -Subjects | -Teacher | -Action | -||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| No reports found. | |||||||||||
|
- = esc($weekLabel) ?>
-
- Class: = esc($group['class_section_name']) ?>
-
- |
-
-
- $section): ?>
-
-
-
-
+
- = esc($section['label'] ?? $subjectName) ?>
- = esc($statusTag) ?>
-
- = $report ? esc($report['unit_title'] ?: '-') : 'No submission' ?>
-
+ + ++
+
+
+ No reports found for this section.
+
+
+
-
|
- = esc($teacherName ?: '-') ?> | -- - View - - | -||||||||