From a83ac160edb046036e350ea053a0c120f2528a7d Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Fri, 17 Apr 2026 02:12:13 +0000 Subject: [PATCH] add students assessments --- app_settings.php | 12 +- approved_school.php | 2 + assessment_scores.php | 439 ++++++++++++++++++ assessments.php | 14 +- center_application.php | 22 +- .../20260416_alter_app_settings_slogan.sql | 1 + .../20260417_school_assessment_scores.sql | 27 ++ includes/app.php | 33 +- includes/center_sidebar.php | 6 + includes/cycles.php | 331 +++++++++++++ 10 files changed, 850 insertions(+), 37 deletions(-) create mode 100644 assessment_scores.php create mode 100644 db/migrations/20260416_alter_app_settings_slogan.sql create mode 100644 db/migrations/20260417_school_assessment_scores.sql diff --git a/app_settings.php b/app_settings.php index 4cebb60..20ebb9e 100644 --- a/app_settings.php +++ b/app_settings.php @@ -8,12 +8,14 @@ $settings = get_app_settings(); $errors = []; $values = [ 'app_name' => $settings['app_name'] ?? '', + 'app_slogan' => $settings['app_slogan'] ?? '', 'app_email' => $settings['app_email'] ?? '', 'app_telephone' => $settings['app_telephone'] ?? '', ]; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $values['app_name'] = clean_text($_POST['app_name'] ?? '', 190); + $values['app_slogan'] = clean_text($_POST['app_slogan'] ?? '', 190); $values['app_email'] = clean_text($_POST['app_email'] ?? '', 190); $values['app_telephone'] = clean_text($_POST['app_telephone'] ?? '', 60); @@ -54,9 +56,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (empty($errors)) { try { - $stmt = db()->prepare('UPDATE app_settings SET app_name = ?, app_email = ?, app_telephone = ?, app_logo = ?, app_favicon = ?, updated_at = NOW() WHERE id = 1'); + $stmt = db()->prepare('UPDATE app_settings SET app_name = ?, app_slogan = ?, app_email = ?, app_telephone = ?, app_logo = ?, app_favicon = ?, updated_at = NOW() WHERE id = 1'); $stmt->execute([ $values['app_name'], + $values['app_slogan'], $values['app_email'], $values['app_telephone'], $logoPath, @@ -93,11 +96,16 @@ render_flash($flash);
-
+
+
+ + +
+
diff --git a/approved_school.php b/approved_school.php index 863d830..3be3fe4 100644 --- a/approved_school.php +++ b/approved_school.php @@ -133,6 +133,7 @@ $studentsUrl = school_page_url('students.php', (int) $application['id'], $select $teachersUrl = school_page_url('teachers.php', (int) $application['id'], $selectedCycleId); $assessmentsUrl = school_page_url('assessments.php', (int) $application['id'], $selectedCycleId); $attendanceUrl = school_page_url('attendance.php', (int) $application['id'], $selectedCycleId); +$assessmentScoresUrl = school_page_url('assessment_scores.php', (int) $application['id'], $selectedCycleId); $approvedSchoolUrl = school_page_url('approved_school.php', (int) $application['id'], $selectedCycleId); $centerSubjectsUrl = school_page_url('center_subjects.php', (int) $application['id'], $selectedCycleId); $studentCycleMetrics = $isApproved && $selectedCycleId > 0 ? school_student_metrics_by_cycle((int) $application['id'], $selectedCycleId) : ['total' => 0, 'active' => 0]; @@ -185,6 +186,7 @@ render_flash($flash);
+ إدخال الدرجات إعدادات المركز ملف الاعتماد لوحة الإدارة diff --git a/assessment_scores.php b/assessment_scores.php new file mode 100644 index 0000000..62f64b7 --- /dev/null +++ b/assessment_scores.php @@ -0,0 +1,439 @@ + 0 ? get_application($applicationId) : null; +$isApprovedSchool = $application && (string) $application['status'] === 'approved'; +$errors = []; +$search = clean_text($_GET['search'] ?? '', 255); +$cycleContext = ['cycles' => [], 'selected' => null, 'active' => null, 'read_only' => false]; +$selectedCycle = null; +$selectedCycleId = 0; +$isCycleReadOnly = false; +$cycleLabel = 'لا توجد دورة بعد'; +$values = [ + 'assessment_type_id' => '', + 'teacher_id' => '', + 'assessed_on' => date('Y-m-d'), + 'assessment_max_score' => 0.0, + 'entries' => [], +]; + +if ($application && $isApprovedSchool) { + $cycleContext = resolve_school_cycle_context((int) $application['id'], $application, $requestedCycleId); + $selectedCycle = $cycleContext['selected']; + $selectedCycleId = $selectedCycle ? (int) ($selectedCycle['id'] ?? 0) : 0; + $isCycleReadOnly = (bool) $cycleContext['read_only']; + $cycleLabel = $selectedCycle ? (string) $selectedCycle['cycle_name'] : $cycleLabel; +} + +$assessmentOptions = $isApprovedSchool && $selectedCycleId > 0 ? school_assessment_type_options_by_cycle((int) $application['id'], $selectedCycleId, true) : []; +$teacherOptions = $isApprovedSchool && $selectedCycleId > 0 ? school_teacher_options_by_cycle((int) $application['id'], $selectedCycleId, true) : []; +$studentFilters = ['enrollment_status' => 'active']; +$students = $isApprovedSchool && $selectedCycleId > 0 ? list_school_students_by_cycle((int) $application['id'], $selectedCycleId, $search, 0, 0, $studentFilters) : []; + +$selectedAssessmentId = $requestedAssessmentId; +if ($selectedAssessmentId <= 0 && $assessmentOptions !== []) { + $keys = array_keys($assessmentOptions); + $selectedAssessmentId = (int) ($keys[0] ?? 0); +} +if ($selectedAssessmentId > 0) { + $values['assessment_type_id'] = (string) $selectedAssessmentId; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $application) { + if (!$isApprovedSchool) { + $errors['form'] = 'لا يمكن إدخال الدرجات قبل اعتماد المركز.'; + } elseif ($selectedCycleId <= 0) { + $errors['form'] = 'يرجى إنشاء دورة موسمية أولاً من صفحة المركز.'; + } elseif ($isCycleReadOnly) { + $errors['form'] = 'هذه الدورة مؤرشفة للقراءة فقط. افتح دورة جديدة أو اختر دورة نشطة لإدخال درجات جديدة.'; + } else { + [$values, $errors, $selectedAssessmentMeta] = validate_assessment_scores_batch_input((int) $application['id'], $selectedCycleId, $_POST); + $selectedAssessmentId = (int) ($values['assessment_type_id'] ?? 0); + if ($errors === []) { + try { + $savedRows = save_assessment_scores_in_cycle((int) $application['id'], $selectedCycleId, $values); + set_flash('success', 'تم حفظ درجات ' . $savedRows . ' طالب/طالبة في هذا التقييم.'); + header('Location: ' . school_page_url('assessment_scores.php', (int) $application['id'], $selectedCycleId) . '&assessment_id=' . urlencode((string) $selectedAssessmentId) . ($search !== '' ? '&search=' . urlencode($search) : '')); + exit; + } catch (Throwable $exception) { + $errors['form'] = 'تعذر حفظ الدرجات حالياً. يرجى المحاولة مرة أخرى.'; + } + } + } +} + +$selectedAssessment = $selectedAssessmentId > 0 ? ($assessmentOptions[$selectedAssessmentId] ?? null) : null; +$scoreMap = $isApprovedSchool && $selectedCycleId > 0 && $selectedAssessmentId > 0 + ? school_assessment_score_map_by_assessment((int) $application['id'], $selectedCycleId, $selectedAssessmentId) + : []; + +if ($_SERVER['REQUEST_METHOD'] !== 'POST' && $scoreMap !== []) { + $firstRecord = reset($scoreMap); + if (is_array($firstRecord)) { + if (!empty($firstRecord['teacher_id'])) { + $values['teacher_id'] = (string) ((int) $firstRecord['teacher_id']); + } + if (!empty($firstRecord['assessed_on'])) { + $values['assessed_on'] = (string) $firstRecord['assessed_on']; + } + } +} + +$assessmentMetrics = $isApprovedSchool && $selectedCycleId > 0 ? school_assessment_metrics_by_cycle((int) $application['id'], $selectedCycleId) : [ + 'total' => 0, 'active' => 0, 'inactive' => 0, 'total_weight' => 0.0, 'active_weight' => 0.0, + 'average_max_score' => 0.0, 'percentage' => 0, 'points' => 0, 'rubric' => 0, +]; +$studentMetrics = $isApprovedSchool && $selectedCycleId > 0 ? school_student_metrics_by_cycle((int) $application['id'], $selectedCycleId) : [ + 'total' => 0, 'boys' => 0, 'girls' => 0, 'active' => 0, 'waiting' => 0, 'withdrawn' => 0, +]; +$teacherMetrics = $isApprovedSchool && $selectedCycleId > 0 ? school_teacher_metrics_by_cycle((int) $application['id'], $selectedCycleId) : [ + 'total' => 0, 'active' => 0, 'pending' => 0, 'inactive' => 0, 'teachers' => 0, 'supervisors' => 0, +]; +$scoreMetrics = $isApprovedSchool && $selectedCycleId > 0 ? school_assessment_score_metrics_by_cycle((int) $application['id'], $selectedCycleId, $selectedAssessmentId) : [ + 'total' => 0, 'present' => 0, 'absent' => 0, 'excused' => 0, 'average_score' => 0.0, 'latest_date' => '', +]; + +$pageTitle = $application ? 'إدخال درجات الطلاب: ' . (string) $application['center_name'] . ($selectedCycle ? ' — ' . $cycleLabel : '') : 'إدخال درجات الطلاب'; +$pageDescription = 'صفحة مستقلة تسمح للمعلم أو الإدارة الأكاديمية بإدخال درجات الطلاب لكل تقييم داخل دورة موسمية محددة.'; +$approvedSchoolUrl = $application ? school_page_url('approved_school.php', (int) $application['id'], $selectedCycleId) : 'approved_school.php'; +$studentsUrl = $application ? school_page_url('students.php', (int) $application['id'], $selectedCycleId) : 'students.php'; +$teachersUrl = $application ? school_page_url('teachers.php', (int) $application['id'], $selectedCycleId) : 'teachers.php'; +$assessmentsUrl = $application ? school_page_url('assessments.php', (int) $application['id'], $selectedCycleId) : 'assessments.php'; +$attendanceUrl = $application ? school_page_url('attendance.php', (int) $application['id'], $selectedCycleId) : 'attendance.php'; +$assessmentSwitchBaseUrl = $application ? school_page_url('assessment_scores.php', (int) $application['id'], $selectedCycleId) : 'assessment_scores.php'; +$latestScoreDate = $scoreMetrics['latest_date'] !== '' ? $scoreMetrics['latest_date'] : 'لا يوجد'; +$averageScoreDisplay = $selectedAssessment && $scoreMetrics['present'] > 0 + ? number_format((float) $scoreMetrics['average_score'], 2, '.', '') . ' / ' . rtrim(rtrim(number_format((float) $selectedAssessment['max_score'], 2, '.', ''), '0'), '.') + : 'لا يوجد'; + +if (!$application) { + http_response_code(404); +} + +render_page_start($pageTitle, 'approved', $pageDescription, (string) ($application['favicon'] ?? '')); +render_flash($flash); +?> +
+
+
+
+ +
+
+ + +
+
المدرسة غير موجودة
+

تحقق من رابط المدرسة أو ارجع إلى قائمة المراكز المعتمدة.

+ المراكز المعتمدة +
+ +
+
+
+ الدرجات تُفتح بعد الاعتماد +

+

تم تجهيز صفحة إدخال الدرجات، لكنها لا تعمل إلا بعد اعتماد المركز وفتح الدورة الأكاديمية الخاصة به.

+ +
+
+
+ +
+
+
+ خطوة التنفيذ بعد تصميم ورقة التقييم +

إدخال درجات الطلاب —

+

اختر التقييم، حدّد المعلّم إن رغبت، ثم أدخل درجات الطلاب مباشرة داخل نفس الدورة . كل درجة تُحفَظ على مستوى الطالب والتقييم والموسم الحالي.

+
+ طلاب نشطون + تقييمات مفعلة + معلمين نشطين +
+ +
+
+
+
ملخص الدرجة الحالية
+
+
السجلات المحفوظة سجل
+
درجات فعلية طالب
+
آخر تحديث
+
+

المتوسط الحالي: ضمن التقييم .

+
+
+
+
+ + + +
+
+
+
+
+
الدورة الموسمية الحالية
+
كل الدرجات في هذه الصفحة مرتبطة بالدورة . أرشفة الدورة تجعل الصفحة للقراءة فقط بدون خلط النتائج مع الموسم التالي.
+
+ +
+
+
اسم الدورة
+
الفترة
+
عدد الدورات دورة للمركز
+
+ +
هذه الدورة مؤرشفة، لذلك تبقى صفحة إدخال الدرجات للقراءة فقط حالياً.
+ +
+
+ +
+ +
+ +
+ + +
+
+ + + +
+ +
+
+
+
+
كشف إدخال الدرجات
+
كل صف يمثل طالباً واحداً ضمن التقييم المختار. عند حفظ النموذج يتم تحديث الصفوف التي تحتوي على درجة أو حالة خاصة أو ملاحظة.
+
+ طلاب ظاهرون +
+ + +
+
لا توجد تقييمات مفعلة بعد
+

أنشئ أول تقييم من صفحة التقييمات، ثم عد هنا لبدء رصد النتائج.

+
+ +
+
لا يوجد طلاب مطابقون لهذا العرض
+

جرّب إزالة البحث الحالي أو أضف طلاباً نشطين من صفحة الطلاب.

+
+ +
+ +
+
المحفوظ حالياً سجل
+
درجات مرصودة طالب
+
غياب / عذر حالة
+
المتوسط
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
الطالبالحالةالدرجةملاحظاتآخر رصد
+ + + + + + +
+
+ + + + + + + + +
+
+ + +
+
سيتم حفظ الصفوف التي تحتوي على بيانات فقط، وتحديث السجل السابق لنفس الطالب داخل هذا التقييم.
+ +
+ +
+ +
+ +
+
سياق المدرسة
+
+
الطلاب النشطون طالب/طالبة
+
المعلمون النشطون عضو
+
التقييمات المفعلة نوع
+
آخر رصد محفوظ
+
+ +
+
+
+ + +
+
+
+
+إدارة أنواع التقييم وتوزيع الدرجات ضمن الخطة الأكاديمية للدورة .

- - - +
+ إدخال الدرجات + + + +
diff --git a/center_application.php b/center_application.php index ac6d99e..cb6b5ed 100644 --- a/center_application.php +++ b/center_application.php @@ -51,7 +51,7 @@ render_flash($flash);
بيانات الطلب الأساسية
قسّمنا النموذج إلى مجموعات واضحة حتى لا تختلط بيانات المركز مع بيانات التشغيل والتواصل.
- نموذج منظم + @@ -221,23 +221,23 @@ render_flash($flash);

روابط سريعة

-
+ diff --git a/db/migrations/20260416_alter_app_settings_slogan.sql b/db/migrations/20260416_alter_app_settings_slogan.sql new file mode 100644 index 0000000..ce70635 --- /dev/null +++ b/db/migrations/20260416_alter_app_settings_slogan.sql @@ -0,0 +1 @@ +ALTER TABLE app_settings ADD COLUMN app_slogan VARCHAR(255); diff --git a/db/migrations/20260417_school_assessment_scores.sql b/db/migrations/20260417_school_assessment_scores.sql new file mode 100644 index 0000000..4886b1e --- /dev/null +++ b/db/migrations/20260417_school_assessment_scores.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS school_assessment_scores ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + center_application_id INT UNSIGNED NOT NULL, + cycle_id INT UNSIGNED NOT NULL, + assessment_type_id INT UNSIGNED NOT NULL, + student_id INT UNSIGNED NOT NULL, + teacher_id INT UNSIGNED NULL, + score DECIMAL(8,2) NULL, + max_score DECIMAL(8,2) NOT NULL DEFAULT 100.00, + status VARCHAR(20) NOT NULL DEFAULT 'present', + notes TEXT NULL, + assessed_on DATE NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_school_assessment_score (cycle_id, assessment_type_id, student_id), + INDEX idx_school_assessment_scores_center (center_application_id), + INDEX idx_school_assessment_scores_cycle (cycle_id), + INDEX idx_school_assessment_scores_assessment (assessment_type_id), + INDEX idx_school_assessment_scores_student (student_id), + INDEX idx_school_assessment_scores_teacher (teacher_id), + INDEX idx_school_assessment_scores_status (status), + CONSTRAINT fk_school_assessment_scores_center_application FOREIGN KEY (center_application_id) REFERENCES center_applications(id) ON DELETE CASCADE, + CONSTRAINT fk_school_assessment_scores_cycle FOREIGN KEY (cycle_id) REFERENCES school_cycles(id) ON DELETE CASCADE, + CONSTRAINT fk_school_assessment_scores_assessment FOREIGN KEY (assessment_type_id) REFERENCES school_assessment_types(id) ON DELETE CASCADE, + CONSTRAINT fk_school_assessment_scores_student FOREIGN KEY (student_id) REFERENCES school_students(id) ON DELETE CASCADE, + CONSTRAINT fk_school_assessment_scores_teacher FOREIGN KEY (teacher_id) REFERENCES school_teachers(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/includes/app.php b/includes/app.php index 8eea7d3..30c0761 100644 --- a/includes/app.php +++ b/includes/app.php @@ -109,6 +109,7 @@ function db_connection(): PDO seed_center_application_demo_data($pdo); ensure_school_module_schema($pdo); ensure_school_cycle_schema($pdo); + ensure_school_assessment_score_schema($pdo); seed_school_module_demo_data($pdo); $bootstrapped = true; } @@ -143,7 +144,8 @@ function get_app_settings(): array 'app_email' => '', 'app_telephone' => '', 'app_logo' => '', - 'app_favicon' => '' + 'app_favicon' => '', + 'app_slogan' => '' ]; } return $res; @@ -1272,7 +1274,10 @@ function school_attendance_metrics(int $centerApplicationId): array function render_page_start(string $pageTitle, string $active = 'home', string $pageDescription = ''): void { - $projectName = project_name(); + $settings = get_app_settings(); + $projectName = !empty($settings['app_name']) ? $settings['app_name'] : project_name(); + $projectLogo = !empty($settings['app_logo']) ? $settings['app_logo'] : ''; + $projectSlogan = !empty($settings['app_slogan']) ? $settings['app_slogan'] : 'منصة الولاية لإدارة المراكز الصيفية'; $description = $pageDescription !== '' ? $pageDescription : project_description(); $projectImageUrl = env_value('PROJECT_IMAGE_URL'); ?> @@ -1305,10 +1310,14 @@ function render_page_start(string $pageTitle, string $active = 'home', string $p
diff --git a/includes/center_sidebar.php b/includes/center_sidebar.php index dc29627..46a10b8 100644 --- a/includes/center_sidebar.php +++ b/includes/center_sidebar.php @@ -7,6 +7,7 @@ if ($script === 'students.php') $activePage = 'students'; if ($script === 'teachers.php') $activePage = 'teachers'; if ($script === 'assessments.php') $activePage = 'assessments'; if ($script === 'attendance.php') $activePage = 'attendance'; +if ($script === 'assessment_scores.php') $activePage = 'scores'; if ($script === 'center_subjects.php') $activePage = 'subjects'; // We assume $application is available in scope. @@ -53,6 +54,11 @@ $baseQuery = '?id=' . e((string) $application['id']) . $selectedCycleIdStr; التقييمات + + + إدخال الدرجات + + سجلات الغياب diff --git a/includes/cycles.php b/includes/cycles.php index dbfe051..5947e4c 100644 --- a/includes/cycles.php +++ b/includes/cycles.php @@ -104,6 +104,24 @@ function ensure_school_cycle_schema(PDO $pdo): void $done = true; } +function ensure_school_assessment_score_schema(PDO $pdo): void +{ + static $done = false; + if ($done) { + return; + } + + $migrationPath = __DIR__ . '/../db/migrations/20260417_school_assessment_scores.sql'; + if (is_file($migrationPath)) { + $sql = file_get_contents($migrationPath); + if (is_string($sql) && trim($sql) !== '') { + $pdo->exec($sql); + } + } + + $done = true; +} + function ensure_school_cycle_backfill(PDO $pdo): void { $applicationRows = $pdo->query( @@ -965,6 +983,319 @@ function school_assessment_metrics_by_cycle(int $centerApplicationId, int $cycle ]; } +function assessment_score_status_map(): array +{ + return [ + 'present' => ['label' => 'حاضر', 'class' => 'status-approved'], + 'absent' => ['label' => 'غائب', 'class' => 'status-review'], + 'excused' => ['label' => 'بعذر', 'class' => 'status-muted'], + ]; +} + +function assessment_score_status_badge(string $status): string +{ + $map = assessment_score_status_map(); + $meta = $map[$status] ?? ['label' => 'غير محدد', 'class' => 'status-muted']; + return '' . e($meta['label']) . ''; +} + +function school_teacher_options_by_cycle(int $centerApplicationId, int $cycleId, bool $onlyActive = false): array +{ + $filters = []; + if ($onlyActive) { + $filters['employment_status'] = 'active'; + } + + $teachers = list_school_teachers_by_cycle($centerApplicationId, $cycleId, $filters); + $options = []; + foreach ($teachers as $teacher) { + $teacherId = (int) ($teacher['id'] ?? 0); + if ($teacherId <= 0) { + continue; + } + + $roleTitle = trim((string) ($teacher['role_title'] ?? '')); + $specialization = trim((string) ($teacher['specialization'] ?? '')); + $label = trim((string) ($teacher['full_name'] ?? '')); + if ($roleTitle !== '') { + $label .= ' — ' . $roleTitle; + } + if ($specialization !== '') { + $label .= ' (' . $specialization . ')'; + } + + $options[$teacherId] = [ + 'label' => $label, + 'full_name' => trim((string) ($teacher['full_name'] ?? '')), + 'role_title' => $roleTitle, + 'specialization' => $specialization, + 'employment_status' => (string) ($teacher['employment_status'] ?? ''), + ]; + } + + return $options; +} + +function school_assessment_type_options_by_cycle(int $centerApplicationId, int $cycleId, bool $onlyActive = false): array +{ + $rows = list_school_assessments_by_cycle($centerApplicationId, $cycleId); + $subjects = []; + foreach (get_enabled_subjects() as $subject) { + $subjects[(int) ($subject['id'] ?? 0)] = (string) ($subject['name'] ?? ''); + } + + $options = []; + foreach ($rows as $assessment) { + $assessmentId = (int) ($assessment['id'] ?? 0); + if ($assessmentId <= 0) { + continue; + } + + $isActive = (int) ($assessment['is_active'] ?? 0) === 1; + if ($onlyActive && !$isActive) { + continue; + } + + $subjectId = (int) ($assessment['subject_id'] ?? 0); + $subjectLabel = $subjectId > 0 ? ($subjects[$subjectId] ?? '') : ''; + $title = trim((string) ($assessment['title'] ?? '')); + $label = $title !== '' ? $title : 'تقييم غير مسمى'; + if ($subjectLabel !== '') { + $label .= ' — ' . $subjectLabel; + } + + $options[$assessmentId] = [ + 'id' => $assessmentId, + 'label' => $label, + 'title' => $title, + 'subject_label' => $subjectLabel, + 'category' => (string) ($assessment['category'] ?? ''), + 'max_score' => (float) ($assessment['max_score'] ?? 0), + 'weight_percentage' => (float) ($assessment['weight_percentage'] ?? 0), + 'is_active' => $isActive, + ]; + } + + return $options; +} + +function validate_assessment_scores_batch_input(int $centerApplicationId, int $cycleId, array $input): array +{ + $data = [ + 'assessment_type_id' => (string) ((int) ($input['assessment_type_id'] ?? 0)), + 'teacher_id' => (string) ((int) ($input['teacher_id'] ?? 0)), + 'assessed_on' => clean_text((string) ($input['assessed_on'] ?? date('Y-m-d')), 20), + 'assessment_max_score' => 0.0, + 'entries' => [], + ]; + + $errors = []; + $assessmentOptions = school_assessment_type_options_by_cycle($centerApplicationId, $cycleId, false); + $teacherOptions = school_teacher_options_by_cycle($centerApplicationId, $cycleId, false); + $studentOptions = school_student_options_by_cycle($centerApplicationId, $cycleId); + $statusMap = assessment_score_status_map(); + + $assessmentId = (int) $data['assessment_type_id']; + $selectedAssessment = $assessmentOptions[$assessmentId] ?? null; + if ($selectedAssessment === null) { + $errors['assessment_type_id'] = 'يرجى اختيار تقييم صحيح من نفس الدورة.'; + } else { + $data['assessment_max_score'] = (float) ($selectedAssessment['max_score'] ?? 0); + } + + $teacherId = (int) $data['teacher_id']; + if ($teacherId > 0 && !array_key_exists($teacherId, $teacherOptions)) { + $errors['teacher_id'] = 'يرجى اختيار معلم صحيح من نفس الدورة.'; + } + + if ($data['assessed_on'] === '' || strtotime($data['assessed_on']) === false) { + $errors['assessed_on'] = 'يرجى إدخال تاريخ تقييم صحيح.'; + } + + $postedEntries = $input['entries'] ?? []; + if (!is_array($postedEntries)) { + $postedEntries = []; + } + + $hasSaveableRow = false; + foreach ($postedEntries as $studentKey => $row) { + if (!is_array($row)) { + continue; + } + + $studentId = (int) $studentKey; + if ($studentId <= 0 || !array_key_exists($studentId, $studentOptions)) { + continue; + } + + $status = clean_text((string) ($row['status'] ?? 'present'), 20); + if (!array_key_exists($status, $statusMap)) { + $status = 'present'; + } + + $scoreRaw = clean_text((string) ($row['score'] ?? ''), 30); + $notes = clean_text((string) ($row['notes'] ?? ''), 1000); + $score = null; + $shouldSave = $status !== 'present' || $scoreRaw !== '' || $notes !== ''; + + if ($scoreRaw !== '') { + if (!is_numeric($scoreRaw)) { + $errors['entries_' . $studentId] = 'الدرجة يجب أن تكون رقماً صحيحاً أو عشرياً.'; + } else { + $score = round((float) $scoreRaw, 2); + if ($selectedAssessment !== null && ($score < 0 || $score > (float) $selectedAssessment['max_score'])) { + $errors['entries_' . $studentId] = 'الدرجة يجب أن تكون بين 0 و ' . rtrim(rtrim(number_format((float) $selectedAssessment['max_score'], 2, '.', ''), '0'), '.'); + } + } + } + + if ($status === 'present' && $shouldSave && $scoreRaw === '') { + $errors['entries_' . $studentId] = 'أدخل الدرجة أو غيّر الحالة إلى غائب أو بعذر.'; + } + + if ($status !== 'present') { + $score = null; + $scoreRaw = ''; + } + + if ($shouldSave) { + $hasSaveableRow = true; + } + + $data['entries'][$studentId] = [ + 'student_id' => $studentId, + 'status' => $status, + 'score' => $score, + 'score_raw' => $scoreRaw, + 'notes' => $notes, + 'should_save' => $shouldSave, + ]; + } + + if (!$hasSaveableRow && $errors === []) { + $errors['form'] = 'أدخل درجات أو حدّد حالة طالب واحد على الأقل قبل الحفظ.'; + } + + return [$data, $errors, $selectedAssessment]; +} + +function save_assessment_scores_in_cycle(int $centerApplicationId, int $cycleId, array $data): int +{ + $pdo = db_connection(); + $stmt = $pdo->prepare( + 'INSERT INTO school_assessment_scores ( + center_application_id, cycle_id, assessment_type_id, student_id, teacher_id, + score, max_score, status, notes, assessed_on, created_at, updated_at + ) VALUES ( + :center_application_id, :cycle_id, :assessment_type_id, :student_id, :teacher_id, + :score, :max_score, :status, :notes, :assessed_on, NOW(), NOW() + ) + ON DUPLICATE KEY UPDATE + teacher_id = VALUES(teacher_id), + score = VALUES(score), + max_score = VALUES(max_score), + status = VALUES(status), + notes = VALUES(notes), + assessed_on = VALUES(assessed_on), + updated_at = NOW()' + ); + + $saved = 0; + foreach ($data['entries'] as $entry) { + if (empty($entry['should_save'])) { + continue; + } + + $stmt->execute([ + ':center_application_id' => $centerApplicationId, + ':cycle_id' => $cycleId, + ':assessment_type_id' => (int) $data['assessment_type_id'], + ':student_id' => (int) $entry['student_id'], + ':teacher_id' => !empty($data['teacher_id']) ? (int) $data['teacher_id'] : null, + ':score' => $entry['score'], + ':max_score' => (float) ($data['assessment_max_score'] ?? 0), + ':status' => $entry['status'], + ':notes' => $entry['notes'] !== '' ? $entry['notes'] : null, + ':assessed_on' => $data['assessed_on'], + ]); + + $saved++; + } + + return $saved; +} + +function list_assessment_scores_by_assessment(int $centerApplicationId, int $cycleId, int $assessmentTypeId): array +{ + $pdo = db_connection(); + $stmt = $pdo->prepare( + 'SELECT scores.*, teachers.full_name AS teacher_name + FROM school_assessment_scores scores + LEFT JOIN school_teachers teachers ON teachers.id = scores.teacher_id + WHERE scores.center_application_id = :center_application_id + AND scores.cycle_id = :cycle_id + AND scores.assessment_type_id = :assessment_type_id + ORDER BY scores.assessed_on DESC, scores.updated_at DESC, scores.id DESC' + ); + $stmt->execute([ + ':center_application_id' => $centerApplicationId, + ':cycle_id' => $cycleId, + ':assessment_type_id' => $assessmentTypeId, + ]); + + return $stmt->fetchAll(); +} + +function school_assessment_score_map_by_assessment(int $centerApplicationId, int $cycleId, int $assessmentTypeId): array +{ + $map = []; + foreach (list_assessment_scores_by_assessment($centerApplicationId, $cycleId, $assessmentTypeId) as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId <= 0 || array_key_exists($studentId, $map)) { + continue; + } + $map[$studentId] = $row; + } + + return $map; +} + +function school_assessment_score_metrics_by_cycle(int $centerApplicationId, int $cycleId, int $assessmentTypeId = 0): array +{ + $pdo = db_connection(); + $query = "SELECT + COUNT(*) AS total, + COALESCE(SUM(status = 'present'), 0) AS present_count, + COALESCE(SUM(status = 'absent'), 0) AS absent_count, + COALESCE(SUM(status = 'excused'), 0) AS excused_count, + COALESCE(AVG(CASE WHEN status = 'present' THEN score END), 0) AS average_score, + MAX(assessed_on) AS latest_date + FROM school_assessment_scores + WHERE center_application_id = :center_application_id AND cycle_id = :cycle_id"; + $params = [ + ':center_application_id' => $centerApplicationId, + ':cycle_id' => $cycleId, + ]; + + if ($assessmentTypeId > 0) { + $query .= ' AND assessment_type_id = :assessment_type_id'; + $params[':assessment_type_id'] = $assessmentTypeId; + } + + $stmt = $pdo->prepare($query); + $stmt->execute($params); + $row = $stmt->fetch() ?: []; + + return [ + 'total' => (int) ($row['total'] ?? 0), + 'present' => (int) ($row['present_count'] ?? 0), + 'absent' => (int) ($row['absent_count'] ?? 0), + 'excused' => (int) ($row['excused_count'] ?? 0), + 'average_score' => (float) ($row['average_score'] ?? 0), + 'latest_date' => (string) ($row['latest_date'] ?? ''), + ]; +} + function school_student_options_by_cycle(int $centerApplicationId, int $cycleId): array { $students = list_school_students_by_cycle($centerApplicationId, $cycleId);