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); } 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); return view('admin/class_progress_list', [ 'reportGroupsBySection' => $reportGroupsBySection, 'filters' => $filters, 'classSections' => $filteredSections, 'statusOptions' => ClassProgressController::STATUS_OPTIONS, 'subjectSections' => ClassProgressController::SUBJECT_SECTIONS, 'sectionStats' => $sectionStats, 'expectedDays' => $expectedDays, ]); } 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 { $requiredSubjects = []; foreach (ClassProgressController::SUBJECT_SECTIONS as $section) { $requiredSubjects[] = $section['db_subject'] ?? $section['label'] ?? ''; } $requiredSubjects = array_values(array_filter($requiredSubjects)); $submittedBySection = []; foreach ($rows as $row) { $sectionId = (int) ($row['class_section_id'] ?? 0); $weekStart = (string) ($row['week_start'] ?? ''); $subject = (string) ($row['subject'] ?? ''); if ($sectionId === 0 || $weekStart === '' || $subject === '') { continue; } if (! empty($activeDatesSet) && empty($activeDatesSet[$weekStart])) { continue; } if (! in_array($subject, $requiredSubjects, true)) { continue; } $submittedBySection[$sectionId][$weekStart][$subject] = true; } $stats = []; foreach ($submittedBySection as $sectionId => $weeks) { $submitted = 0; foreach ($weeks as $subjects) { $all = true; foreach ($requiredSubjects as $subject) { if (empty($subjects[$subject])) { $all = false; break; } } if ($all) { $submitted++; } } $stats[$sectionId] = $this->buildSectionStat($submitted, $expectedDays); } return $stats; } 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, ]; } }