596 lines
22 KiB
PHP
596 lines
22 KiB
PHP
<?php
|
|
|
|
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;
|
|
|
|
class AdminProgressController extends BaseController
|
|
{
|
|
protected ClassProgressReportModel $reportModel;
|
|
protected ClassProgressAttachmentModel $attachmentModel;
|
|
protected ClassSectionModel $classSectionModel;
|
|
protected StudentClassModel $studentClassModel;
|
|
protected CalendarModel $calendarModel;
|
|
protected ConfigurationModel $configModel;
|
|
protected SemesterRangeService $semesterRangeService;
|
|
protected SubjectCurriculumModel $curriculumModel;
|
|
|
|
public function __construct()
|
|
{
|
|
helper(['url', 'form']);
|
|
$this->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 = [];
|
|
$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,
|
|
];
|
|
}
|
|
}
|