'On track', 'slightly_behind' => 'Slightly behind', 'behind' => 'Behind', ]; private const DEFAULT_STATUS = 'on_track'; public const SUBJECT_SECTIONS = [ 'islamic' => [ 'label' => 'Islamic Studies', 'db_subject' => 'Islamic Studies', ], 'quran' => [ 'label' => 'Quran/Arabic', 'db_subject' => 'Quran/Arabic', ], ]; protected ClassProgressReportModel $reportModel; protected ClassProgressAttachmentModel $attachmentModel; protected TeacherClassModel $teacherClassModel; protected ConfigurationModel $configModel; protected string $attachmentStoragePath; protected string $attachmentPublicBase; protected SubjectCurriculumModel $curriculumModel; public function __construct() { helper(['form', 'url']); $this->reportModel = new ClassProgressReportModel(); $this->attachmentModel = new ClassProgressAttachmentModel(); $this->teacherClassModel = new TeacherClassModel(); $this->configModel = new ConfigurationModel(); $this->curriculumModel = new SubjectCurriculumModel(); $this->attachmentStoragePath = WRITEPATH . 'uploads/class_material/'; $this->attachmentPublicBase = 'writable/uploads/class_material/'; } public function create() { $teacherId = (int) session()->get('user_id'); $assignments = $this->loadTeacherSections($teacherId); $first = $assignments[0] ?? null; $classSectionId = $first['class_section_id'] ?? null; $classSectionName = $first['class_section_name'] ?? null; $classId = $first['class_id'] ?? null; $sundayOptions = $this->buildSundayOptions(); $subjectCurriculum = []; if ($classId) { foreach (self::SUBJECT_SECTIONS as $slug => $section) { $subjectCurriculum[$slug] = $this->curriculumModel->getOptionsForClass((int) $classId, $slug); } } $data = [ 'subjectSections' => self::SUBJECT_SECTIONS, 'subjectCurriculum' => $subjectCurriculum, 'classSectionId' => $classSectionId, 'classSectionName' => $classSectionName, 'classId' => $classId, 'sundayOptions' => $sundayOptions, 'defaultWeekStart' => $sundayOptions[0] ?? '', ]; return view('teacher/class_progress_submit', $data); } public function store() { $subjectSections = self::SUBJECT_SECTIONS; $rules = [ 'class_section_id' => 'required|integer', 'week_start' => 'required|valid_date[Y-m-d]', 'week_end' => 'required|valid_date[Y-m-d]', 'support_needed' => 'permit_empty|string', 'flags' => 'permit_empty', ]; foreach ($subjectSections as $slug => $section) { $rules["covered_$slug"] = 'required|string'; $rules["homework_$slug"] = 'permit_empty|string'; $rules["unit_{$slug}.*"] = 'permit_empty|string|max_length[120]'; $rules["chapter_{$slug}.*"] = 'permit_empty|string|max_length[120]'; } if (! $this->validate($rules)) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } $attachmentErrors = $this->validateAttachmentFiles($subjectSections); if (! empty($attachmentErrors)) { return redirect()->back()->withInput()->with('errors', $attachmentErrors); } $weekStart = (string) $this->request->getPost('week_start'); $weekEnd = (string) $this->request->getPost('week_end'); if ($weekStart && ! $weekEnd) { $weekEnd = $this->buildWeekEndFromStart($weekStart); } if ($weekStart && $weekEnd && strtotime($weekEnd) < strtotime($weekStart)) { return redirect()->back()->withInput()->with('error', 'Week end must be the same as or after the week start.'); } $teacherId = (int) session()->get('user_id'); $classSectionId = $this->request->getPost('class_section_id'); $classSectionId = $classSectionId ? (int) $classSectionId : null; if (! $classSectionId) { return redirect()->back()->withInput()->with('error', 'No class assignment found for this report.'); } $status = self::DEFAULT_STATUS; $reportsCreated = 0; foreach ($subjectSections as $slug => $section) { $covered = trim((string) $this->request->getPost("covered_$slug")); if ($covered === '') { continue; } $homework = trim((string) $this->request->getPost("homework_$slug")); $unitTitle = $this->buildUnitChapterSummary($slug); $data = [ 'teacher_id' => $teacherId, 'class_section_id' => $classSectionId, 'week_start' => $weekStart, 'week_end' => $weekEnd, 'subject' => $section['db_subject'] ?? $section['label'] ?? $slug, 'unit_title' => $unitTitle, 'covered' => $covered, 'homework' => $homework ?: null, 'status' => $status, 'flags_json' => $this->normalizeFlags($this->request->getPost('flags')), ]; $reportId = $this->reportModel->insert($data, true); $attachmentField = "attachment_$slug"; $attachments = $this->request->getFileMultiple($attachmentField) ?? []; $storedAttachments = $this->storeAttachments($reportId, $attachments); if (! empty($storedAttachments)) { $this->attachmentModel->insertBatch($storedAttachments); $this->reportModel->update($reportId, ['attachment_path' => $storedAttachments[0]['file_path']]); } $reportsCreated++; } if ($reportsCreated === 0) { return redirect()->back()->withInput()->with('error', 'Please provide progress for at least one subject.'); } return redirect()->to('teacher/progress/submit')->with('success', 'Progress reports saved.'); } public function history() { $teacherId = (int) session()->get('user_id'); $assignments = $this->loadTeacherSections($teacherId); $selectedSectionId = (int) $this->request->getGet('class_section_id'); $validSectionIds = array_column($assignments, 'class_section_id'); if ($selectedSectionId === 0 && ! empty($validSectionIds)) { $selectedSectionId = $validSectionIds[0]; } if ($selectedSectionId && ! in_array($selectedSectionId, $validSectionIds, true)) { $selectedSectionId = $validSectionIds[0] ?? null; } $builder = $this->reportModel ->select('class_progress_reports.*, cs.class_section_name') ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') ->where('teacher_id', $teacherId); if ($selectedSectionId) { $builder->where('class_progress_reports.class_section_id', $selectedSectionId); } $rows = $builder ->orderBy('week_start', 'DESC') ->findAll(); $reportGroups = []; foreach ($rows as $row) { $row['status_label'] = self::STATUS_OPTIONS[$row['status']] ?? 'Unknown'; $key = $row['week_start'] ?? ''; if ($key === '') { continue; } if (! isset($reportGroups[$key])) { $reportGroups[$key] = [ 'week_start' => $row['week_start'], 'week_end' => $row['week_end'], 'class_section_name' => $row['class_section_name'] ?? '', 'reports' => [], ]; } $reportGroups[$key]['reports'][$row['subject']] = $row; } $sectionOptions = []; foreach ($assignments as $assignment) { $sectionOptions[$assignment['class_section_id']] = $assignment['class_section_name'] ?? ''; } return view('teacher/class_progress_history', [ 'reportGroups' => $reportGroups, 'subjectSections' => self::SUBJECT_SECTIONS, 'classSectionOptions' => $sectionOptions, 'selectedSectionId' => $selectedSectionId, ]); } public function view($id) { $teacherId = (int) session()->get('user_id'); $row = $this->reportModel ->select('class_progress_reports.*, cs.class_section_name') ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') ->where('teacher_id', $teacherId) ->find((int) $id); if (! $row) { 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') ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left') ->where('teacher_id', $teacherId) ->where('class_progress_reports.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['status_label'] = self::STATUS_OPTIONS[$report['status']] ?? 'Unknown'; $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('teacher/class_progress_view', [ 'row' => $row, 'weeklyReports' => $weeklyReports, 'subjectSections' => self::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 validateAttachmentFiles(array $subjectSections): array { $errors = []; foreach ($subjectSections as $slug => $section) { $label = $section['label'] ?? $slug; $field = "attachment_$slug"; $files = $this->request->getFileMultiple($field) ?? []; foreach ($files as $file) { if (! $file || $file->getError() === UPLOAD_ERR_NO_FILE) { continue; } if (! $file->isValid()) { $errors[] = "Invalid attachment uploaded for {$label}."; continue; } if ($file->getSize() > 5 * 1024 * 1024) { $errors[] = "Each attachment for {$label} must be 5MB or smaller."; } $ext = strtolower((string) $file->getClientExtension()); if ($ext === '' || ! in_array($ext, ['pdf', 'jpg', 'jpeg', 'png'], true)) { $errors[] = "Only PDF, JPG, JPEG, or PNG files are allowed for {$label}."; } } } return $errors; } protected function storeAttachments(int $reportId, array $attachments): array { if (empty($attachments)) { return []; } $stored = []; $now = date('Y-m-d H:i:s'); foreach ($attachments as $file) { if (! $file || $file->getError() === UPLOAD_ERR_NO_FILE) { continue; } if (! $file->isValid() || $file->hasMoved()) { continue; } $stored[] = [ 'report_id' => $reportId, 'file_path' => $this->storeAttachment($file), 'original_name' => $file->getClientName(), 'mime_type' => $file->getClientMimeType(), 'file_size' => $file->getSize(), 'created_at' => $now, ]; } return $stored; } 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 loadTeacherSections(int $teacherId): array { $schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); $semester = (string) ($this->configModel->getConfig('semester') ?? ''); return $this->teacherClassModel->getClassAssignmentsByUserId($teacherId, $schoolYear, $semester); } protected function normalizeFlags($flags): ?string { $flags = array_values(array_filter((array) $flags, static fn ($item) => $item !== '' && $item !== null)); return $flags ? json_encode($flags) : null; } protected function buildUnitChapterSummary(string $slug): ?string { $unitValues = array_map('trim', (array) $this->request->getPost("unit_$slug")); $chapterValues = array_map('trim', (array) $this->request->getPost("chapter_$slug")); $parts = []; $count = max(count($unitValues), count($chapterValues)); for ($i = 0; $i < $count; $i++) { $unit = $unitValues[$i] ?? ''; $chapter = $chapterValues[$i] ?? ''; if ($unit === '' && $chapter === '') { continue; } $segment = $unit; if ($chapter !== '') { $segment = $segment !== '' ? $segment . ' / ' . $chapter : $chapter; } if ($segment === '') { continue; } $parts[] = $segment; } if (! $parts) { return null; } $summary = implode(' ; ', $parts); return mb_strlen($summary) > 120 ? mb_substr($summary, 0, 120) : $summary; } protected function buildSundayOptions(int $count = 12): array { $start = new \DateTime('today'); $weekday = (int) $start->format('w'); if ($weekday !== 0) { $start->modify('next sunday'); } $options = []; for ($i = 0; $i < $count; $i++) { $options[] = $start->format('Y-m-d'); $start->modify('+7 days'); } return $options; } protected function buildWeekEndFromStart(string $weekStart): string { try { $dt = new \DateTime($weekStart); $dt->modify('+6 days'); return $dt->format('Y-m-d'); } catch (\Exception $e) { return $weekStart; } } protected function storeAttachment($file): string { $this->ensureAttachmentPath(); $name = $file->getRandomName(); $file->move($this->attachmentStoragePath, $name); return $this->attachmentPublicBase . $name; } protected function ensureAttachmentPath(): void { if (! is_dir($this->attachmentStoragePath)) { mkdir($this->attachmentStoragePath, 0755, true); } } }