reportModel = new ClassProgressReportModel(); $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() { $filters = [ 'from' => (string) $this->request->getGet('from'), 'to' => (string) $this->request->getGet('to'), 'class_section_id' => (string) $this->request->getGet('class_section_id'), 'status' => (string) $this->request->getGet('status'), ]; $builder = $this->reportModel ->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') ->join('users u', 'u.id = class_progress_reports.teacher_id', 'left'); if ($filters['from']) { $builder->where('week_start >=', $filters['from']); } if ($filters['to']) { $builder->where('week_end <=', $filters['to']); } if ($filters['class_section_id']) { $builder->where('class_progress_reports.class_section_id', (int) $filters['class_section_id']); } if ($filters['status']) { $builder->where('class_progress_reports.status', $filters['status']); } $rows = $builder->orderBy('week_start', 'DESC')->get()->getResultArray(); $reportGroupsBySection = []; foreach ($rows as $row) { $row['status_label'] = ClassProgressController::STATUS_OPTIONS[$row['status']] ?? 'Unknown'; $sectionId = (int) ($row['class_section_id'] ?? 0); $weekKey = (string) ($row['week_start'] ?? ''); if ($sectionId === 0 || $weekKey === '') { continue; } 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' => [], ]; } $reportGroupsBySection[$sectionId][$weekKey]['reports'][$row['subject']] = $row; } foreach ($reportGroupsBySection as $sectionId => $groups) { krsort($groups); $reportGroupsBySection[$sectionId] = $groups; } $classSections = $this->classSectionModel->getClassSections(); $studentCounts = $this->studentClassModel->getStudentCountsBySection(); $filteredSections = array_values(array_filter($classSections, function ($section) use ($studentCounts) { $sectionId = (int) ($section['class_section_id'] ?? 0); 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); $lowProgressSectionIds = []; if ($expectedDays > 0) { foreach ($filteredSections as $section) { $sectionId = (int) ($section['class_section_id'] ?? 0); if ($sectionId === 0) { continue; } $stat = $sectionStats[$sectionId] ?? null; $percent = $stat['percent'] ?? 0; if ($stat === null) { $percent = 0; } if ($percent < 50) { $lowProgressSectionIds[] = $sectionId; } } } return view('admin/class_progress_list', [ 'reportGroupsBySection' => $reportGroupsBySection, 'filters' => $filters, 'classSections' => $filteredSections, 'statusOptions' => ClassProgressController::STATUS_OPTIONS, 'subjectSections' => ClassProgressController::SUBJECT_SECTIONS, 'sectionStats' => $sectionStats, 'sectionSubjectCounts' => $sectionSubjectCounts, 'expectedDays' => $expectedDays, 'lowProgressSectionIds' => $lowProgressSectionIds, ]); } public function view($id) { $row = $this->reportModel ->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') ->join('users u', 'u.id = class_progress_reports.teacher_id', 'left') ->find((int) $id); if (! $row) { throw new PageNotFoundException('Progress report not found.'); } $row['status_label'] = ClassProgressController::STATUS_OPTIONS[$row['status']] ?? 'Unknown'; $row['flags'] = $this->decodeFlags($row['flags_json']); $weeklyReports = $this->reportModel ->select('class_progress_reports.*') ->where('class_section_id', $row['class_section_id']) ->where('week_start', $row['week_start']) ->orderBy('subject', 'ASC') ->findAll(); $attachmentMap = $this->loadAttachmentsForReports(array_column($weeklyReports, 'id')); foreach ($weeklyReports as &$report) { $report['attachments'] = $attachmentMap[$report['id']] ?? []; if (empty($report['attachments']) && ! empty($report['attachment_path'])) { $report['attachments'][] = [ 'id' => $report['id'], 'name' => basename((string) $report['attachment_path']), 'legacy' => true, ]; } } return view('admin/class_progress_view', [ 'row' => $row, 'weeklyReports' => $weeklyReports, 'subjectSections' => ClassProgressController::SUBJECT_SECTIONS, ]); } public function attachment($id) { $row = $this->reportModel->find((int)$id); if (! $row || empty($row['attachment_path'])) { throw new PageNotFoundException('Attachment not found.'); } $file = $this->resolveAttachmentFile($row); if (! $file) { throw new PageNotFoundException('Attachment missing.'); } return $this->response->download($file, null)->setFileName(basename($file)); } public function attachmentFile($id) { $attachment = $this->attachmentModel->find((int) $id); if (! $attachment || empty($attachment['file_path'])) { throw new PageNotFoundException('Attachment not found.'); } $file = $this->resolveAttachmentPath($attachment['file_path']); if (! $file) { throw new PageNotFoundException('Attachment missing.'); } $downloadName = $attachment['original_name'] ?: basename($file); return $this->response->download($file, null)->setFileName($downloadName); } protected function resolveAttachmentFile(array $row): ?string { $path = trim((string) ($row['attachment_path'] ?? '')); if ($path === '') { return null; } return $this->resolveAttachmentPath($path); } protected function resolveAttachmentPath(string $path): ?string { $relative = preg_replace('#^writable/uploads/#', '', $path); $absolute = WRITEPATH . 'uploads/' . ltrim($relative, '/'); return is_file($absolute) ? $absolute : null; } protected function loadAttachmentsForReports(array $reportIds): array { $reportIds = array_values(array_filter(array_map('intval', $reportIds))); if (empty($reportIds)) { return []; } $rows = $this->attachmentModel ->whereIn('report_id', $reportIds) ->orderBy('id', 'ASC') ->findAll(); $map = []; foreach ($rows as $row) { $reportId = (int) ($row['report_id'] ?? 0); if ($reportId === 0) { continue; } $map[$reportId][] = [ 'id' => (int) $row['id'], 'name' => $row['original_name'] ?: basename((string) $row['file_path']), ]; } return $map; } protected function decodeFlags(?string $json): array { if (! $json) { return []; } $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 = []; $sectionClassMap = []; foreach ($rows as $row) { $sectionId = (int) ($row['class_section_id'] ?? 0); $subject = (string) ($row['subject'] ?? ''); if ($sectionId === 0 || $subject === '') { continue; } if (! empty($allowedSubjects) && ! in_array($subject, $allowedSubjects, true)) { continue; } if (! isset($sectionClassMap[$sectionId])) { $sectionClassMap[$sectionId] = $this->classSectionModel->getClassId($sectionId); } } $curriculumUnits = $this->buildCurriculumUnitMap(array_values(array_filter($sectionClassMap))); foreach ($rows as $row) { $sectionId = (int) ($row['class_section_id'] ?? 0); $subject = (string) ($row['subject'] ?? ''); if ($sectionId === 0 || $subject === '') { continue; } if (! empty($allowedSubjects) && ! in_array($subject, $allowedSubjects, true)) { continue; } $subjectSlug = $this->resolveSubjectSlug($subject); $classId = $sectionClassMap[$sectionId] ?? null; $chapterToUnit = []; if ($classId && $subjectSlug && ! empty($curriculumUnits[$classId][$subjectSlug]['chapter_to_unit'])) { $chapterToUnit = $curriculumUnits[$classId][$subjectSlug]['chapter_to_unit']; } $unitKeys = $this->extractUnitKeys((string) ($row['unit_title'] ?? ''), $chapterToUnit); foreach ($unitKeys as $unitKey) { $key = $subjectSlug . '|' . $unitKey; $counts[$sectionId][$key] = true; } } $totals = []; foreach ($counts as $sectionId => $unitSet) { $totals[$sectionId] = count($unitSet); } return $totals; } protected function buildCurriculumUnitMap(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'] ?? '')); $unitNumber = $row['unit_number'] ?? null; if ($classId === 0 || $subject === '' || $chapter === '') { continue; } if ($unitNumber === null || $unitNumber === '') { continue; } $unitKey = (string) $unitNumber; $map[$classId][$subject]['chapter_to_unit'][$chapter] = $unitKey; } 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 countUnitSegments(string $unitTitle, array $chapterToUnit): int { $unitTitle = trim($unitTitle); if ($unitTitle === '') { return 0; } $parts = array_filter(array_map('trim', explode(';', $unitTitle)), static fn ($part) => $part !== ''); if (! $parts) { return 0; } $seen = []; foreach ($parts as $part) { [$unitPart, $chapterPart] = $this->splitUnitChapterSegment($part); $key = $this->resolveUnitKey($unitPart, $chapterPart, $chapterToUnit); if ($key === '') { $key = $part; } if (isset($seen[$key])) { continue; } $seen[$key] = true; } return count($seen); } protected function splitUnitChapterSegment(string $segment): array { $segment = trim($segment); if ($segment === '') { return ['', '']; } $pos = strrpos($segment, '/'); if ($pos === false) { return [$segment, '']; } $unitPart = trim(substr($segment, 0, $pos)); $chapterPart = trim(substr($segment, $pos + 1)); return [$unitPart, $chapterPart]; } protected function extractUnitKeys(string $unitTitle, array $chapterToUnit): array { $unitTitle = trim($unitTitle); if ($unitTitle === '') { return []; } $parts = array_filter(array_map('trim', explode(';', $unitTitle)), static fn ($part) => $part !== ''); if (! $parts) { return []; } $keys = []; foreach ($parts as $part) { [$unitPart, $chapterPart] = $this->splitUnitChapterSegment($part); $key = $this->resolveUnitKey($unitPart, $chapterPart, $chapterToUnit); if ($key === '') { continue; } $keys[$key] = true; } return array_keys($keys); } protected function resolveUnitKey(string $unitPart, string $chapterPart, array $chapterToUnit): string { if ($chapterPart !== '' && ! empty($chapterToUnit[$chapterPart])) { return (string) $chapterToUnit[$chapterPart]; } if (empty($chapterToUnit)) { if (preg_match('/\bunit\s*(\d+)\b/i', $unitPart, $matches)) { return (string) $matches[1]; } return ''; } if (preg_match('/\bunit\s*(\d+)\b/i', $unitPart, $matches)) { return (string) $matches[1]; } if ($unitPart !== '') { return $unitPart; } if ($chapterPart !== '') { return $chapterPart; } return ''; } 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, ]; } }