From 5849af849c7df2be6293f1b3ba67bb9579030474 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Fri, 17 Apr 2026 02:53:02 +0000 Subject: [PATCH] Autosave: 20260417-025303 --- assessment_criteria.php | 299 +++++++++ assessment_score_sheet.php | 438 +++++++++++++ assessment_scores.php | 462 +++----------- assessments.php | 23 +- .../20260417_school_assessment_criteria.sql | 46 ++ includes/app.php | 5 +- includes/center_sidebar.php | 4 +- includes/cycles.php | 596 +++++++++++++++++- students.php | 20 +- 9 files changed, 1474 insertions(+), 419 deletions(-) create mode 100644 assessment_criteria.php create mode 100644 assessment_score_sheet.php create mode 100644 db/migrations/20260417_school_assessment_criteria.sql diff --git a/assessment_criteria.php b/assessment_criteria.php new file mode 100644 index 0000000..8ad7fc6 --- /dev/null +++ b/assessment_criteria.php @@ -0,0 +1,299 @@ + 0 ? get_application($applicationId) : null; +$isApprovedSchool = $application && (string) $application['status'] === 'approved'; +$errors = []; +$cycleContext = ['cycles' => [], 'selected' => null, 'active' => null, 'read_only' => false]; +$selectedCycle = null; +$selectedCycleId = 0; +$isCycleReadOnly = false; +$cycleLabel = 'لا توجد دورة بعد'; +$values = ['criteria' => []]; + +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, false) + : []; +$selectedAssessmentId = $requestedAssessmentId; +if ($selectedAssessmentId <= 0 && $assessmentOptions !== []) { + $keys = array_keys($assessmentOptions); + $selectedAssessmentId = (int) ($keys[0] ?? 0); +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $application) { + $selectedAssessmentId = filter_input(INPUT_POST, 'assessment_id', FILTER_VALIDATE_INT) ?: $selectedAssessmentId; + if (!$isApprovedSchool) { + $errors['form'] = 'لا يمكن إعداد بنود التقييم قبل اعتماد المركز.'; + } elseif ($selectedCycleId <= 0) { + $errors['form'] = 'يرجى إنشاء دورة موسمية أولاً من صفحة المركز.'; + } elseif ($isCycleReadOnly) { + $errors['form'] = 'هذه الدورة مؤرشفة للقراءة فقط. افتح دورة جديدة أو اختر دورة نشطة لتعديل البنود.'; + } elseif ($selectedAssessmentId <= 0 || !isset($assessmentOptions[$selectedAssessmentId])) { + $errors['form'] = 'يرجى اختيار تقييم صحيح أولاً.'; + } else { + [$values, $errors] = validate_assessment_criteria_input((int) $application['id'], $selectedCycleId, $selectedAssessmentId, $_POST); + if ($errors === []) { + try { + $savedRows = save_assessment_criteria_in_cycle((int) $application['id'], $selectedCycleId, $selectedAssessmentId, $values); + set_flash('success', 'تم حفظ ' . $savedRows . ' بند/بنود لهذا التقييم.'); + header('Location: ' . school_page_url('assessment_criteria.php', (int) $application['id'], $selectedCycleId) . '&assessment_id=' . urlencode((string) $selectedAssessmentId)); + exit; + } catch (Throwable $exception) { + $errors['form'] = 'تعذر حفظ البنود حالياً. يرجى المحاولة مرة أخرى.'; + } + } + } +} + +$selectedAssessment = $selectedAssessmentId > 0 ? ($assessmentOptions[$selectedAssessmentId] ?? null) : null; +$criteriaRows = $isApprovedSchool && $selectedCycleId > 0 && $selectedAssessmentId > 0 + ? list_assessment_criteria_by_assessment((int) $application['id'], $selectedCycleId, $selectedAssessmentId, false) + : []; +$criteriaMetrics = $isApprovedSchool && $selectedCycleId > 0 && $selectedAssessmentId > 0 + ? school_assessment_criteria_metrics((int) $application['id'], $selectedCycleId, $selectedAssessmentId) + : ['total' => 0, 'active' => 0, 'active_max_score' => 0.0]; + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $values['criteria'] = []; + foreach ($criteriaRows as $criterion) { + $values['criteria'][] = [ + 'id' => (int) ($criterion['id'] ?? 0), + 'title' => (string) ($criterion['title'] ?? ''), + 'max_score' => rtrim(rtrim(number_format((float) ($criterion['max_score'] ?? 0), 2, '.', ''), '0'), '.'), + 'notes' => (string) ($criterion['notes'] ?? ''), + 'is_active' => ((int) ($criterion['is_active'] ?? 0) === 1) ? '1' : '0', + ]; + } + if ($values['criteria'] === []) { + $values['criteria'][] = ['id' => 0, 'title' => 'الحفظ', 'max_score' => '10', 'notes' => '', 'is_active' => '1']; + $values['criteria'][] = ['id' => 0, 'title' => 'الطلاقة', 'max_score' => '10', 'notes' => '', 'is_active' => '1']; + $values['criteria'][] = ['id' => 0, 'title' => 'التجويد / النطق', 'max_score' => '10', 'notes' => '', 'is_active' => '1']; + } +} + +$pageTitle = $application && $selectedAssessment + ? 'بنود التقييم: ' . (string) $selectedAssessment['title'] . ' — ' . (string) $application['center_name'] + : 'إعداد بنود التقييم'; +$pageDescription = 'صفحة مستقلة لبناء ورقة تقييم متعددة البنود مثل الحفظ والطلاقة والنطق لكل تقييم.'; +$assessmentsUrl = $application ? school_page_url('assessments.php', (int) $application['id'], $selectedCycleId) : 'assessments.php'; +$scoreSheetUrl = $application ? school_page_url('assessment_score_sheet.php', (int) $application['id'], $selectedCycleId) . '&assessment_id=' . urlencode((string) $selectedAssessmentId) : 'assessment_score_sheet.php'; +$scoreListUrl = $application ? school_page_url('assessment_scores.php', (int) $application['id'], $selectedCycleId) : 'assessment_scores.php'; + +if (!$application) { + http_response_code(404); +} + +render_page_start($pageTitle, 'approved', $pageDescription, (string) ($application['favicon'] ?? '')); +render_flash($flash); +?> +
+
+
+
+ +
+
+ +
+
المدرسة غير موجودة
+

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

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

اعتمد المركز أولاً حتى تتمكن من بناء أوراق تقييم تفصيلية.

+
+ +
+
اختر تقييماً أولاً
+

ابدأ من صفحة التقييمات ثم افتح إعداد البنود للتقييم المطلوب.

+ الرجوع إلى التقييمات +
+ +
+
+
+ ورقة تقييم متعددة البنود +

+

أضف البنود التي تريد للمعلم أن يرصدها بشكل مستقل، مثل الحفظ والطلاقة والنطق. سيظهر كل بند كعمود مستقل في صفحة الرصد، ويُحسب المجموع تلقائياً.

+
+ + + الوزن % +
+
+ +
+
+ +
+
إجمالي البنود
كل البنود المحفوظة
+
البنود النشطة
هي التي تظهر في ورقة الرصد
+
المجموع النهائي
يُحدَّث تلقائياً داخل التقييم
+
+ + +
+ + +
هذه الدورة مؤرشفة، لذلك الصفحة معروضة للقراءة فقط.
+ + +
+
+
+
بنود التقييم
+
يمكنك إضافة بنود جديدة، أو إيقاف بند قديم عن الظهور في ورقة الرصد.
+
+ + + +
+ +
+ +
+ + + + + + + + + + + + $criterion): ?> + + + + + + + + + + +
اسم البندالدرجةملاحظة داخليةالحالةإجراء
+ + > +
+
+ > + + > + + + + + قراءة فقط + + أوقف التفعيل لإخفائه + + + +
+
+ + +
+
سيتم تحديث الدرجة النهائية للتقييم تلقائياً إلى مجموع البنود النشطة.
+ +
+ +
+
+ +
+
+
+
+ + + + + 0 ? get_application($applicationId) : null; +$isApprovedSchool = $application && (string) $application['status'] === 'approved'; +$errors = []; +$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, + 'has_criteria' => false, + 'criteria' => [], + '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, false) + : []; +$teacherOptions = $isApprovedSchool && $selectedCycleId > 0 + ? school_teacher_options_by_cycle((int) $application['id'], $selectedCycleId, true) + : []; +$students = $isApprovedSchool && $selectedCycleId > 0 + ? list_school_students_by_cycle((int) $application['id'], $selectedCycleId, $search, 0, 0, ['enrollment_status' => 'active']) + : []; + +$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_score_sheet.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; +$criteria = $isApprovedSchool && $selectedCycleId > 0 && $selectedAssessmentId > 0 + ? list_assessment_criteria_by_assessment((int) $application['id'], $selectedCycleId, $selectedAssessmentId, true) + : []; +$hasCriteria = $criteria !== []; +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $values['has_criteria'] = $hasCriteria; + $values['criteria'] = $criteria; + if ($hasCriteria) { + $values['assessment_max_score'] = round(array_reduce($criteria, static function (float $carry, array $criterion): float { + return $carry + (float) ($criterion['max_score'] ?? 0); + }, 0.0), 2); + } elseif ($selectedAssessment) { + $values['assessment_max_score'] = (float) ($selectedAssessment['max_score'] ?? 0); + } +} + +$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']; + } + } +} + +$criteriaMetrics = $isApprovedSchool && $selectedCycleId > 0 && $selectedAssessmentId > 0 + ? school_assessment_criteria_metrics((int) $application['id'], $selectedCycleId, $selectedAssessmentId) + : ['total' => 0, 'active' => 0, 'active_max_score' => 0.0]; +$scoreMetrics = $isApprovedSchool && $selectedCycleId > 0 && $selectedAssessmentId > 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 && $selectedAssessment + ? 'ورقة رصد: ' . (string) $selectedAssessment['title'] . ' — ' . (string) $application['center_name'] + : 'ورقة رصد الدرجات'; +$pageDescription = $hasCriteria + ? 'صفحة مستقلة لرصد درجات الطلاب حسب البنود التفصيلية داخل تقييم واحد.' + : 'صفحة مستقلة ومبسطة لإدخال درجات الطلاب داخل تقييم واحد فقط.'; +$scoreListUrl = $application ? school_page_url('assessment_scores.php', (int) $application['id'], $selectedCycleId) : 'assessment_scores.php'; +$assessmentsUrl = $application ? school_page_url('assessments.php', (int) $application['id'], $selectedCycleId) : 'assessments.php'; +$criteriaUrl = $application ? school_page_url('assessment_criteria.php', (int) $application['id'], $selectedCycleId) . '&assessment_id=' . urlencode((string) $selectedAssessmentId) : 'assessment_criteria.php'; +$approvedSchoolUrl = $application ? school_page_url('approved_school.php', (int) $application['id'], $selectedCycleId) : 'approved_school.php'; +$maxScoreLabel = score_display((float) ($values['assessment_max_score'] ?? 0.0)); +$averageScoreLabel = ($selectedAssessment && (int) $scoreMetrics['present'] > 0) + ? score_display((float) $scoreMetrics['average_score']) . ' / ' . ($maxScoreLabel !== '' ? $maxScoreLabel : '0') + : 'لا يوجد'; +$latestScoreDate = $scoreMetrics['latest_date'] !== '' ? (string) $scoreMetrics['latest_date'] : 'لا يوجد'; + +if (!$application) { + http_response_code(404); +} + +render_page_start($pageTitle, 'approved', $pageDescription, (string) ($application['favicon'] ?? '')); +render_flash($flash); +?> +
+
+
+
+ +
+
+ +
+
المدرسة غير موجودة
+

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

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

اعتمد المركز أولاً حتى تتمكن من فتح ورقة الرصد.

+
+ +
+
اختر تقييماً أولاً
+

هذه الصفحة تعمل لتقييم واحد فقط حتى تكون عملية الرصد أوضح وأسهل.

+ الرجوع إلى قائمة التقييمات +
+ +
+
+
+ صفحة رصد مستقلة +

+

+ ' . e($cycleLabel) . '' . ' حتى يتمكن المعلم من إدخال الدرجات بسرعة وبدون عناصر مشتتة.' ?> +

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

جرّب البحث بكلمة أخرى أو أضف طلاباً من سجل الطلاب.

+
+ +
+ +
+
+ + +
+
+ + +
+
+
+ نمط الورقة + +
+
+
+ + + +
+
+ + +
+
يجب إدخال جميع البنود النشطة للطالب الحاضر، ثم سيُحسب المجموع تلقائياً.
+ تعديل البنود +
+ +
هذا التقييم ما يزال بدرجة واحدة. إذا كنت تريد بنوداً مثل الحفظ والطلاقة والتجويد، افتح إعداد بنود التقييم.
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
الطالبالحالة + + من + المجموعالدرجةملاحظةآخر حفظ
+ + + + + + + +
+ / +
+
+ +
+
+ + + + + + + + +
+
+ + +
+
يتم حفظ الصفوف التي تحتوي على بيانات فقط، ويمكنك الرجوع لاحقاً لتعديل نفس الورقة.
+ +
+ +
+ +
+ +
+
+
+
+ + + + 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) +$assessmentOptions = $isApprovedSchool && $selectedCycleId > 0 + ? school_assessment_type_options_by_cycle((int) $application['id'], $selectedCycleId, false) : []; -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 ($search !== '') { + $assessmentOptions = array_filter( + $assessmentOptions, + static function (array $assessment) use ($search): bool { + $haystack = implode(' ', [ + (string) ($assessment['label'] ?? ''), + (string) ($assessment['title'] ?? ''), + (string) ($assessment['subject_label'] ?? ''), + (string) ($assessment['category'] ?? ''), + ]); + return stripos($haystack, $search) !== false; } - 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 = 'صفحة مستقلة تسمح للمعلم أو الإدارة الأكاديمية بإدخال درجات الطلاب لكل تقييم داخل دورة موسمية محددة.'; +$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'), '.') - : 'لا يوجد'; +$scoreSheetBaseUrl = $application ? school_page_url('assessment_score_sheet.php', (int) $application['id'], $selectedCycleId) : 'assessment_score_sheet.php'; +$criteriaBaseUrl = $application ? school_page_url('assessment_criteria.php', (int) $application['id'], $selectedCycleId) : 'assessment_criteria.php'; if (!$application) { http_response_code(404); @@ -126,312 +60,92 @@ render_flash($flash);
- - -
-
المدرسة غير موجودة
-

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

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

-

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

- + +
+
المدرسة غير موجودة
+

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

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

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

-

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

-
- طلاب نشطون - تقييمات مفعلة - معلمين نشطين -
- + +
+
الدرجات تُفتح بعد الاعتماد
+

اعتمد المركز أولاً حتى تظهر أوراق الرصد الخاصة به.

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

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

-
-
-
-
- - - -
-
-
-
-
-
الدورة الموسمية الحالية
-
كل الدرجات في هذه الصفحة مرتبطة بالدورة . أرشفة الدورة تجعل الصفحة للقراءة فقط بدون خلط النتائج مع الموسم التالي.
+ +
+
+
+ الخطوة 1 +

اختر التقييم ثم افتح صفحة الرصد

+

بدلاً من شاشة مزدحمة، أصبحت عملية إدخال الدرجات على خطوتين: اختيار التقييم ثم فتح صفحة مستقلة لكل ورقة رصد داخل دورة .

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

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

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

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

+
+
+
لا توجد تقييمات جاهزة للرصد
+

أضف نوع تقييم أولاً من صفحة التقييمات، ثم ارجع هنا لفتح ورقة الرصد.

+ إضافة تقييم +
-
- -
-
المحفوظ حالياً سجل
-
درجات مرصودة طالب
-
غياب / عذر حالة
-
المتوسط
+ $assessment): ?> +
+
- -
- - - - - - - - - - - - - - - - - - - - - - -
الطالبالحالةالدرجةملاحظاتآخر رصد
- - - - - - -
-
- - - - - - - - -
-
- - -
-
سيتم حفظ الصفوف التي تحتوي على بيانات فقط، وتحديث السجل السابق لنفس الطالب داخل هذا التقييم.
- -
- - +
- -
-
سياق المدرسة
-
-
الطلاب النشطون طالب/طالبة
-
المعلمون النشطون عضو
-
التقييمات المفعلة نوع
-
آخر رصد محفوظ
-
- -
-
-
- - +
diff --git a/assessments.php b/assessments.php index 65854d7..98d22c7 100644 --- a/assessments.php +++ b/assessments.php @@ -87,6 +87,8 @@ $pageTitle = $application ? 'التقييمات والأوزان: ' . (string) $ $pageDescription = 'صفحة مستقلة لتعريف أنواع التقييم، المقاييس، والأوزان لكل مدرسة معتمدة داخل دورة موسمية محددة.'; $approvedSchoolUrl = $application ? school_page_url('approved_school.php', (int) $application['id'], $selectedCycleId) : 'approved_school.php'; $assessmentScoresUrl = $application ? school_page_url('assessment_scores.php', (int) $application['id'], $selectedCycleId) : 'assessment_scores.php'; +$assessmentScoreSheetBaseUrl = $application ? school_page_url('assessment_score_sheet.php', (int) $application['id'], $selectedCycleId) : 'assessment_score_sheet.php'; +$criteriaBaseUrl = $application ? school_page_url('assessment_criteria.php', (int) $application['id'], $selectedCycleId) : 'assessment_criteria.php'; if (!$application) { http_response_code(404); @@ -200,6 +202,7 @@ render_flash($flash); المقياس الدرجة الوزن + البنود الحالة الإجراء @@ -223,12 +226,25 @@ render_flash($flash); % + + + 0): ?> +
بنود
+ المجموع + + بدون بنود + + - + @@ -287,6 +303,7 @@ render_flash($flash);
+
إذا أضفت بنوداً تفصيلية لاحقاً فسيتم تحديث هذا الرقم تلقائياً من مجموع البنود النشطة.
diff --git a/db/migrations/20260417_school_assessment_criteria.sql b/db/migrations/20260417_school_assessment_criteria.sql new file mode 100644 index 0000000..dd3bb65 --- /dev/null +++ b/db/migrations/20260417_school_assessment_criteria.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS school_assessment_criteria ( + 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, + title VARCHAR(190) NOT NULL, + max_score DECIMAL(8,2) NOT NULL DEFAULT 0.00, + sort_order INT UNSIGNED NOT NULL DEFAULT 1, + is_active TINYINT(1) NOT NULL DEFAULT 1, + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_school_assessment_criteria_center (center_application_id), + INDEX idx_school_assessment_criteria_cycle (cycle_id), + INDEX idx_school_assessment_criteria_assessment (assessment_type_id), + INDEX idx_school_assessment_criteria_active (assessment_type_id, is_active), + CONSTRAINT fk_school_assessment_criteria_center_application FOREIGN KEY (center_application_id) REFERENCES center_applications(id) ON DELETE CASCADE, + CONSTRAINT fk_school_assessment_criteria_cycle FOREIGN KEY (cycle_id) REFERENCES school_cycles(id) ON DELETE CASCADE, + CONSTRAINT fk_school_assessment_criteria_assessment FOREIGN KEY (assessment_type_id) REFERENCES school_assessment_types(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS school_assessment_score_items ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + center_application_id INT UNSIGNED NOT NULL, + cycle_id INT UNSIGNED NOT NULL, + assessment_score_id INT UNSIGNED NOT NULL, + assessment_type_id INT UNSIGNED NOT NULL, + criterion_id INT UNSIGNED NOT NULL, + student_id INT UNSIGNED NOT NULL, + score DECIMAL(8,2) NULL, + max_score DECIMAL(8,2) NOT NULL DEFAULT 0.00, + 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_item (assessment_score_id, criterion_id), + INDEX idx_school_assessment_score_items_center (center_application_id), + INDEX idx_school_assessment_score_items_cycle (cycle_id), + INDEX idx_school_assessment_score_items_assessment (assessment_type_id), + INDEX idx_school_assessment_score_items_criterion (criterion_id), + INDEX idx_school_assessment_score_items_student (student_id), + CONSTRAINT fk_school_assessment_score_items_center_application FOREIGN KEY (center_application_id) REFERENCES center_applications(id) ON DELETE CASCADE, + CONSTRAINT fk_school_assessment_score_items_cycle FOREIGN KEY (cycle_id) REFERENCES school_cycles(id) ON DELETE CASCADE, + CONSTRAINT fk_school_assessment_score_items_score FOREIGN KEY (assessment_score_id) REFERENCES school_assessment_scores(id) ON DELETE CASCADE, + CONSTRAINT fk_school_assessment_score_items_assessment FOREIGN KEY (assessment_type_id) REFERENCES school_assessment_types(id) ON DELETE CASCADE, + CONSTRAINT fk_school_assessment_score_items_criterion FOREIGN KEY (criterion_id) REFERENCES school_assessment_criteria(id) ON DELETE CASCADE, + CONSTRAINT fk_school_assessment_score_items_student FOREIGN KEY (student_id) REFERENCES school_students(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/includes/app.php b/includes/app.php index 30c0761..caeb087 100644 --- a/includes/app.php +++ b/includes/app.php @@ -110,6 +110,7 @@ function db_connection(): PDO ensure_school_module_schema($pdo); ensure_school_cycle_schema($pdo); ensure_school_assessment_score_schema($pdo); + ensure_school_assessment_criteria_schema($pdo); seed_school_module_demo_data($pdo); $bootstrapped = true; } @@ -686,7 +687,7 @@ function student_enrollment_status_badge(string $status): string return '' . e($meta['label']) . ''; } -function validate_student_input(array $input): array +function validate_student_input(array $input, bool $requireStudentCode = true): array { $data = student_defaults(); foreach ($data as $key => $_value) { @@ -696,7 +697,7 @@ function validate_student_input(array $input): array $errors = []; - if ($data['student_code'] === '') { + if ($requireStudentCode && $data['student_code'] === '') { $errors['student_code'] = 'يرجى إدخال الرقم أو الكود المرجعي للطالب.'; } if ($data['full_name'] === '') { diff --git a/includes/center_sidebar.php b/includes/center_sidebar.php index 46a10b8..0b94919 100644 --- a/includes/center_sidebar.php +++ b/includes/center_sidebar.php @@ -5,9 +5,9 @@ $activePage = 'dashboard'; if ($script === 'center_profile.php') $activePage = 'profile'; if ($script === 'students.php') $activePage = 'students'; if ($script === 'teachers.php') $activePage = 'teachers'; -if ($script === 'assessments.php') $activePage = 'assessments'; +if ($script === 'assessments.php' || $script === 'assessment_criteria.php') $activePage = 'assessments'; if ($script === 'attendance.php') $activePage = 'attendance'; -if ($script === 'assessment_scores.php') $activePage = 'scores'; +if ($script === 'assessment_scores.php' || $script === 'assessment_score_sheet.php') $activePage = 'scores'; if ($script === 'center_subjects.php') $activePage = 'subjects'; // We assume $application is available in scope. diff --git a/includes/cycles.php b/includes/cycles.php index 5947e4c..5d6dc69 100644 --- a/includes/cycles.php +++ b/includes/cycles.php @@ -122,6 +122,24 @@ function ensure_school_assessment_score_schema(PDO $pdo): void $done = true; } +function ensure_school_assessment_criteria_schema(PDO $pdo): void +{ + static $done = false; + if ($done) { + return; + } + + $migrationPath = __DIR__ . '/../db/migrations/20260417_school_assessment_criteria.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( @@ -422,25 +440,68 @@ function copy_school_cycle_rollover(PDO $pdo, int $centerApplicationId, int $sou } if (!empty($rollover['copy_assessments'])) { - $stmt = $pdo->prepare( + $sourceStmt = $pdo->prepare( + 'SELECT * FROM school_assessment_types + WHERE center_application_id = :center_application_id AND cycle_id = :source_cycle_id + ORDER BY id ASC' + ); + $sourceStmt->execute([ + ':center_application_id' => $centerApplicationId, + ':source_cycle_id' => $sourceCycleId, + ]); + $sourceAssessments = $sourceStmt->fetchAll(); + + $insertAssessmentStmt = $pdo->prepare( 'INSERT INTO school_assessment_types ( center_application_id, cycle_id, subject_id, title, category, scale_type, max_score, weight_percentage, is_active, notes, created_at, updated_at + ) VALUES ( + :center_application_id, :cycle_id, :subject_id, :title, :category, :scale_type, + :max_score, :weight_percentage, :is_active, :notes, + NOW(), NOW() + )' + ); + $copyCriteriaStmt = $pdo->prepare( + 'INSERT INTO school_assessment_criteria ( + center_application_id, cycle_id, assessment_type_id, title, max_score, + sort_order, is_active, notes, created_at, updated_at ) SELECT - center_application_id, :target_cycle_id, subject_id, title, category, scale_type, - max_score, weight_percentage, is_active, notes, - NOW(), NOW() - FROM school_assessment_types - WHERE center_application_id = :center_application_id AND cycle_id = :source_cycle_id' + center_application_id, :target_cycle_id, :target_assessment_id, title, max_score, + sort_order, is_active, notes, NOW(), NOW() + FROM school_assessment_criteria + WHERE center_application_id = :center_application_id + AND cycle_id = :source_cycle_id + AND assessment_type_id = :source_assessment_id' ); - $stmt->execute([ - ':target_cycle_id' => $targetCycleId, - ':center_application_id' => $centerApplicationId, - ':source_cycle_id' => $sourceCycleId, - ]); - $summary['assessments'] = $stmt->rowCount(); + + foreach ($sourceAssessments as $sourceAssessment) { + $insertAssessmentStmt->execute([ + ':center_application_id' => $centerApplicationId, + ':cycle_id' => $targetCycleId, + ':subject_id' => !empty($sourceAssessment['subject_id']) ? (int) $sourceAssessment['subject_id'] : null, + ':title' => (string) ($sourceAssessment['title'] ?? ''), + ':category' => (string) ($sourceAssessment['category'] ?? ''), + ':scale_type' => (string) ($sourceAssessment['scale_type'] ?? 'percentage'), + ':max_score' => (float) ($sourceAssessment['max_score'] ?? 0), + ':weight_percentage' => (float) ($sourceAssessment['weight_percentage'] ?? 0), + ':is_active' => (int) ($sourceAssessment['is_active'] ?? 1), + ':notes' => !empty($sourceAssessment['notes']) ? (string) $sourceAssessment['notes'] : null, + ]); + $newAssessmentId = (int) $pdo->lastInsertId(); + if ($newAssessmentId > 0) { + $copyCriteriaStmt->execute([ + ':target_cycle_id' => $targetCycleId, + ':target_assessment_id' => $newAssessmentId, + ':center_application_id' => $centerApplicationId, + ':source_cycle_id' => $sourceCycleId, + ':source_assessment_id' => (int) ($sourceAssessment['id'] ?? 0), + ]); + sync_assessment_total_score_from_criteria($centerApplicationId, $targetCycleId, $newAssessmentId); + } + } + $summary['assessments'] = count($sourceAssessments); } if (!empty($rollover['copy_students'])) { @@ -605,6 +666,40 @@ function school_page_url(string $page, int $applicationId, ?int $cycleId = null) return $url; } +function next_student_code_for_cycle(int $centerApplicationId, int $cycleId): string +{ + $pdo = db_connection(); + $stmt = $pdo->prepare( + 'SELECT student_code FROM school_students WHERE center_application_id = :center_application_id AND cycle_id = :cycle_id ORDER BY id DESC' + ); + $stmt->execute([ + ':center_application_id' => $centerApplicationId, + ':cycle_id' => $cycleId, + ]); + + $codes = $stmt->fetchAll(PDO::FETCH_COLUMN) ?: []; + $prefix = 'ST-'; + $padding = 3; + $maxNumber = 0; + + foreach ($codes as $codeValue) { + $code = trim((string) $codeValue); + if ($code === '') { + continue; + } + + if (preg_match('/^(.*?)(\d+)$/', $code, $matches)) { + if ($prefix === 'ST-' && trim((string) $matches[1]) !== '') { + $prefix = (string) $matches[1]; + } + $padding = max($padding, strlen((string) $matches[2])); + $maxNumber = max($maxNumber, (int) $matches[2]); + } + } + + return $prefix . str_pad((string) ($maxNumber + 1), $padding, '0', STR_PAD_LEFT); +} + function create_student_in_cycle(int $centerApplicationId, int $cycleId, array $data): int { $pdo = db_connection(); @@ -878,7 +973,25 @@ function create_assessment_type_in_cycle(int $centerApplicationId, int $cycleId, function list_school_assessments_by_cycle(int $centerApplicationId, int $cycleId, array $filters = [], int $limit = 0, int $offset = 0): array { $pdo = db_connection(); - $query = 'SELECT * FROM school_assessment_types WHERE center_application_id = :center_application_id AND cycle_id = :cycle_id'; + $query = 'SELECT sat.*, + ( + SELECT COUNT(*) + FROM school_assessment_criteria criteria + WHERE criteria.assessment_type_id = sat.id + AND criteria.center_application_id = sat.center_application_id + AND criteria.cycle_id = sat.cycle_id + AND criteria.is_active = 1 + ) AS criteria_count, + ( + SELECT COALESCE(SUM(criteria.max_score), 0) + FROM school_assessment_criteria criteria + WHERE criteria.assessment_type_id = sat.id + AND criteria.center_application_id = sat.center_application_id + AND criteria.cycle_id = sat.cycle_id + AND criteria.is_active = 1 + ) AS criteria_total_max_score + FROM school_assessment_types sat + WHERE sat.center_application_id = :center_application_id AND sat.cycle_id = :cycle_id'; $params = [ ':center_application_id' => $centerApplicationId, ':cycle_id' => $cycleId, @@ -886,24 +999,24 @@ function list_school_assessments_by_cycle(int $centerApplicationId, int $cycleId $search = $filters['search'] ?? ''; if ($search !== '') { - $query .= ' AND (title LIKE :search1 OR category LIKE :search2)'; + $query .= ' AND (sat.title LIKE :search1 OR sat.category LIKE :search2)'; $params[':search1'] = "%$search%"; $params[':search2'] = "%$search%"; } $subject_id = $filters['subject_id'] ?? ''; if ($subject_id !== '') { - $query .= ' AND subject_id = :subject_id'; + $query .= ' AND sat.subject_id = :subject_id'; $params[':subject_id'] = (int) $subject_id; } $category = $filters['category'] ?? ''; if ($category !== '') { - $query .= ' AND category = :category'; + $query .= ' AND sat.category = :category'; $params[':category'] = $category; } - $query .= ' ORDER BY is_active DESC, created_at DESC, id DESC'; + $query .= ' ORDER BY sat.is_active DESC, sat.created_at DESC, sat.id DESC'; if ($limit > 0) { $query .= ' LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset; @@ -1064,6 +1177,9 @@ function school_assessment_type_options_by_cycle(int $centerApplicationId, int $ $label .= ' — ' . $subjectLabel; } + $criteriaCount = (int) ($assessment['criteria_count'] ?? 0); + $criteriaTotal = (float) ($assessment['criteria_total_max_score'] ?? 0); + $options[$assessmentId] = [ 'id' => $assessmentId, 'label' => $label, @@ -1072,6 +1188,9 @@ function school_assessment_type_options_by_cycle(int $centerApplicationId, int $ 'category' => (string) ($assessment['category'] ?? ''), 'max_score' => (float) ($assessment['max_score'] ?? 0), 'weight_percentage' => (float) ($assessment['weight_percentage'] ?? 0), + 'criteria_count' => $criteriaCount, + 'criteria_total_max_score' => $criteriaTotal, + 'has_criteria' => $criteriaCount > 0, 'is_active' => $isActive, ]; } @@ -1079,6 +1198,254 @@ function school_assessment_type_options_by_cycle(int $centerApplicationId, int $ return $options; } +function list_assessment_criteria_by_assessment(int $centerApplicationId, int $cycleId, int $assessmentTypeId, bool $onlyActive = false): array +{ + $pdo = db_connection(); + $query = 'SELECT * + FROM school_assessment_criteria + WHERE center_application_id = :center_application_id + AND cycle_id = :cycle_id + AND assessment_type_id = :assessment_type_id'; + $params = [ + ':center_application_id' => $centerApplicationId, + ':cycle_id' => $cycleId, + ':assessment_type_id' => $assessmentTypeId, + ]; + + if ($onlyActive) { + $query .= ' AND is_active = 1'; + } + + $query .= ' ORDER BY sort_order ASC, id ASC'; + $stmt = $pdo->prepare($query); + $stmt->execute($params); + return $stmt->fetchAll(); +} + +function school_assessment_criteria_metrics(int $centerApplicationId, int $cycleId, int $assessmentTypeId): array +{ + $pdo = db_connection(); + $stmt = $pdo->prepare( + "SELECT + COUNT(*) AS total_count, + COALESCE(SUM(is_active = 1), 0) AS active_count, + COALESCE(SUM(CASE WHEN is_active = 1 THEN max_score ELSE 0 END), 0) AS active_max_score + FROM school_assessment_criteria + WHERE center_application_id = :center_application_id + AND cycle_id = :cycle_id + AND assessment_type_id = :assessment_type_id" + ); + $stmt->execute([ + ':center_application_id' => $centerApplicationId, + ':cycle_id' => $cycleId, + ':assessment_type_id' => $assessmentTypeId, + ]); + $row = $stmt->fetch() ?: []; + + return [ + 'total' => (int) ($row['total_count'] ?? 0), + 'active' => (int) ($row['active_count'] ?? 0), + 'active_max_score' => (float) ($row['active_max_score'] ?? 0), + ]; +} + +function validate_assessment_criteria_input(int $centerApplicationId, int $cycleId, int $assessmentTypeId, array $input): array +{ + $data = ['criteria' => []]; + $errors = []; + $assessmentOptions = school_assessment_type_options_by_cycle($centerApplicationId, $cycleId, false); + if (!array_key_exists($assessmentTypeId, $assessmentOptions)) { + return [$data, ['form' => 'يرجى اختيار تقييم صحيح من نفس الدورة.']]; + } + + $postedRows = $input['criteria'] ?? []; + if (!is_array($postedRows)) { + $postedRows = []; + } + + $position = 1; + $activeCount = 0; + foreach ($postedRows as $rowKey => $row) { + if (!is_array($row)) { + continue; + } + + $criterionId = (int) ($row['id'] ?? 0); + $title = clean_text((string) ($row['title'] ?? ''), 150); + $maxScoreRaw = str_replace(',', '.', clean_text((string) ($row['max_score'] ?? ''), 30)); + $notes = clean_text((string) ($row['notes'] ?? ''), 500); + $isActive = ((string) ($row['is_active'] ?? '1')) === '1' ? 1 : 0; + + if ($criterionId <= 0 && $title === '' && $maxScoreRaw === '' && $notes === '') { + continue; + } + + $rowErrors = []; + if ($title === '') { + $rowErrors[] = 'اسم البند مطلوب.'; + } + + $maxScore = null; + if ($maxScoreRaw === '' || !is_numeric($maxScoreRaw)) { + $rowErrors[] = 'أدخل درجة رقمية للبند.'; + } else { + $maxScore = round((float) $maxScoreRaw, 2); + if ($maxScore <= 0 || $maxScore > 1000) { + $rowErrors[] = 'درجة البند يجب أن تكون بين 0.01 و1000.'; + } + } + + if ($rowErrors !== []) { + $errors['criteria_' . $rowKey] = implode(' ', $rowErrors); + } + + $data['criteria'][] = [ + 'id' => $criterionId, + 'title' => $title, + 'max_score' => $maxScore !== null ? number_format($maxScore, 2, '.', '') : '', + 'notes' => $notes, + 'is_active' => (string) $isActive, + 'sort_order' => $position, + ]; + + if ($isActive === 1) { + $activeCount++; + } + $position++; + } + + if ($data['criteria'] === []) { + $errors['form'] = 'أضف بند تقييم واحد على الأقل قبل الحفظ.'; + } elseif ($activeCount === 0) { + $errors['form'] = 'فعّل بنداً واحداً على الأقل ليظهر في ورقة الرصد.'; + } + + return [$data, $errors]; +} + +function sync_assessment_total_score_from_criteria(int $centerApplicationId, int $cycleId, int $assessmentTypeId): void +{ + $pdo = db_connection(); + $criteria = list_assessment_criteria_by_assessment($centerApplicationId, $cycleId, $assessmentTypeId, true); + if ($criteria === []) { + return; + } + + $totalMaxScore = 0.0; + foreach ($criteria as $criterion) { + $totalMaxScore += (float) ($criterion['max_score'] ?? 0); + } + $totalMaxScore = round($totalMaxScore, 2); + + $assessmentStmt = $pdo->prepare( + 'UPDATE school_assessment_types + SET max_score = :max_score, updated_at = NOW() + WHERE id = :id AND center_application_id = :center_application_id AND cycle_id = :cycle_id' + ); + $assessmentStmt->execute([ + ':max_score' => $totalMaxScore, + ':id' => $assessmentTypeId, + ':center_application_id' => $centerApplicationId, + ':cycle_id' => $cycleId, + ]); + + $itemStmt = $pdo->prepare( + 'UPDATE school_assessment_score_items items + INNER JOIN school_assessment_criteria criteria ON criteria.id = items.criterion_id + SET items.max_score = criteria.max_score, + items.updated_at = NOW() + WHERE items.center_application_id = :center_application_id + AND items.cycle_id = :cycle_id + AND items.assessment_type_id = :assessment_type_id' + ); + $itemStmt->execute([ + ':center_application_id' => $centerApplicationId, + ':cycle_id' => $cycleId, + ':assessment_type_id' => $assessmentTypeId, + ]); + + $scoreStmt = $pdo->prepare( + 'UPDATE school_assessment_scores + SET max_score = :max_score, + updated_at = NOW() + WHERE center_application_id = :center_application_id + AND cycle_id = :cycle_id + AND assessment_type_id = :assessment_type_id' + ); + $scoreStmt->execute([ + ':max_score' => $totalMaxScore, + ':center_application_id' => $centerApplicationId, + ':cycle_id' => $cycleId, + ':assessment_type_id' => $assessmentTypeId, + ]); +} + +function save_assessment_criteria_in_cycle(int $centerApplicationId, int $cycleId, int $assessmentTypeId, array $data): int +{ + $pdo = db_connection(); + $existingStmt = $pdo->prepare( + 'SELECT id FROM school_assessment_criteria + WHERE center_application_id = :center_application_id + AND cycle_id = :cycle_id + AND assessment_type_id = :assessment_type_id' + ); + $existingStmt->execute([ + ':center_application_id' => $centerApplicationId, + ':cycle_id' => $cycleId, + ':assessment_type_id' => $assessmentTypeId, + ]); + $existingIds = array_map('intval', $existingStmt->fetchAll(PDO::FETCH_COLUMN)); + $existingMap = array_fill_keys($existingIds, true); + + $insertStmt = $pdo->prepare( + 'INSERT INTO school_assessment_criteria ( + center_application_id, cycle_id, assessment_type_id, title, max_score, + sort_order, is_active, notes, created_at, updated_at + ) VALUES ( + :center_application_id, :cycle_id, :assessment_type_id, :title, :max_score, + :sort_order, :is_active, :notes, NOW(), NOW() + )' + ); + $updateStmt = $pdo->prepare( + 'UPDATE school_assessment_criteria + SET title = :title, + max_score = :max_score, + sort_order = :sort_order, + is_active = :is_active, + notes = :notes, + updated_at = NOW() + WHERE id = :id + AND center_application_id = :center_application_id + AND cycle_id = :cycle_id + AND assessment_type_id = :assessment_type_id' + ); + + $saved = 0; + foreach ($data['criteria'] as $criterion) { + $params = [ + ':center_application_id' => $centerApplicationId, + ':cycle_id' => $cycleId, + ':assessment_type_id' => $assessmentTypeId, + ':title' => (string) ($criterion['title'] ?? ''), + ':max_score' => (float) ($criterion['max_score'] ?? 0), + ':sort_order' => (int) ($criterion['sort_order'] ?? 0), + ':is_active' => ((string) ($criterion['is_active'] ?? '1')) === '1' ? 1 : 0, + ':notes' => !empty($criterion['notes']) ? (string) $criterion['notes'] : null, + ]; + + $criterionId = (int) ($criterion['id'] ?? 0); + if ($criterionId > 0 && isset($existingMap[$criterionId])) { + $updateStmt->execute($params + [':id' => $criterionId]); + } else { + $insertStmt->execute($params); + } + $saved++; + } + + sync_assessment_total_score_from_criteria($centerApplicationId, $cycleId, $assessmentTypeId); + return $saved; +} + function validate_assessment_scores_batch_input(int $centerApplicationId, int $cycleId, array $input): array { $data = [ @@ -1086,6 +1453,8 @@ function validate_assessment_scores_batch_input(int $centerApplicationId, int $c '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, + 'has_criteria' => false, + 'criteria' => [], 'entries' => [], ]; @@ -1099,7 +1468,25 @@ function validate_assessment_scores_batch_input(int $centerApplicationId, int $c $selectedAssessment = $assessmentOptions[$assessmentId] ?? null; if ($selectedAssessment === null) { $errors['assessment_type_id'] = 'يرجى اختيار تقييم صحيح من نفس الدورة.'; - } else { + } + + $criteriaRows = $assessmentId > 0 + ? list_assessment_criteria_by_assessment($centerApplicationId, $cycleId, $assessmentId, true) + : []; + $criteriaMap = []; + foreach ($criteriaRows as $criterion) { + $criteriaId = (int) ($criterion['id'] ?? 0); + if ($criteriaId <= 0) { + continue; + } + $criteriaMap[$criteriaId] = $criterion; + $data['assessment_max_score'] += (float) ($criterion['max_score'] ?? 0); + } + $data['assessment_max_score'] = round($data['assessment_max_score'], 2); + $data['has_criteria'] = $criteriaMap !== []; + $data['criteria'] = $criteriaRows; + + if (!$data['has_criteria'] && $selectedAssessment !== null) { $data['assessment_max_score'] = (float) ($selectedAssessment['max_score'] ?? 0); } @@ -1133,29 +1520,90 @@ function validate_assessment_scores_batch_input(int $centerApplicationId, int $c $status = 'present'; } - $scoreRaw = clean_text((string) ($row['score'] ?? ''), 30); $notes = clean_text((string) ($row['notes'] ?? ''), 1000); + $criteriaScores = []; $score = null; - $shouldSave = $status !== 'present' || $scoreRaw !== '' || $notes !== ''; + $scoreRaw = ''; + $hasCriteriaInput = false; - 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 ($data['has_criteria']) { + $postedCriterionScores = $row['criteria'] ?? []; + if (!is_array($postedCriterionScores)) { + $postedCriterionScores = []; + } + + $missingCriteria = []; + $totalScore = 0.0; + foreach ($criteriaMap as $criterionId => $criterion) { + $rawValue = str_replace(',', '.', clean_text((string) ($postedCriterionScores[$criterionId] ?? ''), 30)); + $criteriaScores[$criterionId] = [ + 'criterion_id' => $criterionId, + 'score' => null, + 'score_raw' => $rawValue, + 'max_score' => (float) ($criterion['max_score'] ?? 0), + ]; + + if ($rawValue === '') { + $missingCriteria[] = (string) ($criterion['title'] ?? ''); + continue; + } + + $hasCriteriaInput = true; + if (!is_numeric($rawValue)) { + $errors['entries_' . $studentId] = 'كل بند يجب أن يحتوي على درجة رقمية صحيحة.'; + continue; + } + + $criterionScore = round((float) $rawValue, 2); + $criterionMax = (float) ($criterion['max_score'] ?? 0); + if ($criterionScore < 0 || $criterionScore > $criterionMax) { + $errors['entries_' . $studentId] = 'درجة البند يجب أن تكون بين 0 و ' . rtrim(rtrim(number_format($criterionMax, 2, '.', ''), '0'), '.') . '.'; + continue; + } + + $criteriaScores[$criterionId]['score'] = $criterionScore; + $totalScore += $criterionScore; + } + + $shouldSave = $status !== 'present' || $hasCriteriaInput || $notes !== ''; + if ($status === 'present' && $shouldSave && $missingCriteria !== []) { + $errors['entries_' . $studentId] = 'للطالب الحاضر يجب تعبئة جميع البنود النشطة قبل الحفظ.'; + } + if ($status === 'present' && $missingCriteria === [] && !isset($errors['entries_' . $studentId])) { + $score = round($totalScore, 2); + $scoreRaw = number_format($score, 2, '.', ''); + } + + if ($status !== 'present') { + foreach ($criteriaScores as $criterionId => $criterionScoreData) { + $criteriaScores[$criterionId]['score'] = null; + $criteriaScores[$criterionId]['score_raw'] = ''; + } + $score = null; + $scoreRaw = ''; + } + } else { + $scoreRaw = clean_text((string) ($row['score'] ?? ''), 30); + $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) $data['assessment_max_score'])) { + $errors['entries_' . $studentId] = 'الدرجة يجب أن تكون بين 0 و ' . rtrim(rtrim(number_format((float) $data['assessment_max_score'], 2, '.', ''), '0'), '.'); + } } } - } - if ($status === 'present' && $shouldSave && $scoreRaw === '') { - $errors['entries_' . $studentId] = 'أدخل الدرجة أو غيّر الحالة إلى غائب أو بعذر.'; - } + if ($status === 'present' && $shouldSave && $scoreRaw === '') { + $errors['entries_' . $studentId] = 'أدخل الدرجة أو غيّر الحالة إلى غائب أو بعذر.'; + } - if ($status !== 'present') { - $score = null; - $scoreRaw = ''; + if ($status !== 'present') { + $score = null; + $scoreRaw = ''; + } } if ($shouldSave) { @@ -1167,13 +1615,17 @@ function validate_assessment_scores_batch_input(int $centerApplicationId, int $c 'status' => $status, 'score' => $score, 'score_raw' => $scoreRaw, + 'total_score' => $score, 'notes' => $notes, + 'criteria_scores' => $criteriaScores, 'should_save' => $shouldSave, ]; } if (!$hasSaveableRow && $errors === []) { - $errors['form'] = 'أدخل درجات أو حدّد حالة طالب واحد على الأقل قبل الحفظ.'; + $errors['form'] = $data['has_criteria'] + ? 'أدخل درجات البنود أو حدّد حالة طالب واحد على الأقل قبل الحفظ.' + : 'أدخل درجات أو حدّد حالة طالب واحد على الأقل قبل الحفظ.'; } return [$data, $errors, $selectedAssessment]; @@ -1182,6 +1634,17 @@ function validate_assessment_scores_batch_input(int $centerApplicationId, int $c function save_assessment_scores_in_cycle(int $centerApplicationId, int $cycleId, array $data): int { $pdo = db_connection(); + $criteriaRows = !empty($data['has_criteria']) + ? list_assessment_criteria_by_assessment($centerApplicationId, $cycleId, (int) $data['assessment_type_id'], true) + : []; + $criteriaMap = []; + foreach ($criteriaRows as $criterion) { + $criteriaId = (int) ($criterion['id'] ?? 0); + if ($criteriaId > 0) { + $criteriaMap[$criteriaId] = $criterion; + } + } + $stmt = $pdo->prepare( 'INSERT INTO school_assessment_scores ( center_application_id, cycle_id, assessment_type_id, student_id, teacher_id, @@ -1191,6 +1654,7 @@ function save_assessment_scores_in_cycle(int $centerApplicationId, int $cycleId, :score, :max_score, :status, :notes, :assessed_on, NOW(), NOW() ) ON DUPLICATE KEY UPDATE + id = LAST_INSERT_ID(id), teacher_id = VALUES(teacher_id), score = VALUES(score), max_score = VALUES(max_score), @@ -1199,6 +1663,16 @@ function save_assessment_scores_in_cycle(int $centerApplicationId, int $cycleId, assessed_on = VALUES(assessed_on), updated_at = NOW()' ); + $deleteItemsStmt = $pdo->prepare('DELETE FROM school_assessment_score_items WHERE assessment_score_id = :assessment_score_id'); + $itemStmt = $pdo->prepare( + 'INSERT INTO school_assessment_score_items ( + center_application_id, cycle_id, assessment_score_id, assessment_type_id, criterion_id, + student_id, score, max_score, created_at, updated_at + ) VALUES ( + :center_application_id, :cycle_id, :assessment_score_id, :assessment_type_id, :criterion_id, + :student_id, :score, :max_score, NOW(), NOW() + )' + ); $saved = 0; foreach ($data['entries'] as $entry) { @@ -1219,6 +1693,29 @@ function save_assessment_scores_in_cycle(int $centerApplicationId, int $cycleId, ':assessed_on' => $data['assessed_on'], ]); + $assessmentScoreId = (int) $pdo->lastInsertId(); + if ($assessmentScoreId > 0) { + $deleteItemsStmt->execute([':assessment_score_id' => $assessmentScoreId]); + if ($criteriaMap !== [] && (string) ($entry['status'] ?? 'present') === 'present') { + foreach ($entry['criteria_scores'] as $criterionId => $criterionScoreData) { + if (!array_key_exists((int) $criterionId, $criteriaMap) || ($criterionScoreData['score'] ?? null) === null) { + continue; + } + + $itemStmt->execute([ + ':center_application_id' => $centerApplicationId, + ':cycle_id' => $cycleId, + ':assessment_score_id' => $assessmentScoreId, + ':assessment_type_id' => (int) $data['assessment_type_id'], + ':criterion_id' => (int) $criterionId, + ':student_id' => (int) $entry['student_id'], + ':score' => (float) $criterionScoreData['score'], + ':max_score' => (float) ($criteriaMap[(int) $criterionId]['max_score'] ?? 0), + ]); + } + } + } + $saved++; } @@ -1246,6 +1743,25 @@ function list_assessment_scores_by_assessment(int $centerApplicationId, int $cyc return $stmt->fetchAll(); } +function list_assessment_score_items_by_assessment(int $centerApplicationId, int $cycleId, int $assessmentTypeId): array +{ + $pdo = db_connection(); + $stmt = $pdo->prepare( + 'SELECT items.* + FROM school_assessment_score_items items + WHERE items.center_application_id = :center_application_id + AND items.cycle_id = :cycle_id + AND items.assessment_type_id = :assessment_type_id + ORDER BY items.assessment_score_id ASC, items.id ASC' + ); + $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 = []; @@ -1254,9 +1770,19 @@ function school_assessment_score_map_by_assessment(int $centerApplicationId, int if ($studentId <= 0 || array_key_exists($studentId, $map)) { continue; } + $row['criteria_scores'] = []; $map[$studentId] = $row; } + foreach (list_assessment_score_items_by_assessment($centerApplicationId, $cycleId, $assessmentTypeId) as $item) { + $studentId = (int) ($item['student_id'] ?? 0); + $criterionId = (int) ($item['criterion_id'] ?? 0); + if ($studentId <= 0 || $criterionId <= 0 || !isset($map[$studentId])) { + continue; + } + $map[$studentId]['criteria_scores'][$criterionId] = $item; + } + return $map; } diff --git a/students.php b/students.php index b86c4a4..f3f5579 100644 --- a/students.php +++ b/students.php @@ -14,6 +14,7 @@ $selectedCycle = null; $selectedCycleId = 0; $isCycleReadOnly = false; $cycleLabel = 'لا توجد دورة بعد'; +$nextStudentCode = ''; if ($application && $isApprovedSchool) { $cycleContext = resolve_school_cycle_context((int) $application['id'], $application, $requestedCycleId); @@ -21,12 +22,21 @@ if ($application && $isApprovedSchool) { $selectedCycleId = $selectedCycle ? (int) ($selectedCycle['id'] ?? 0) : 0; $isCycleReadOnly = (bool) $cycleContext['read_only']; $cycleLabel = $selectedCycle ? (string) $selectedCycle['cycle_name'] : $cycleLabel; + if ($selectedCycleId > 0) { + $nextStudentCode = next_student_code_for_cycle((int) $application['id'], $selectedCycleId); + $values['student_code'] = $nextStudentCode; + } } if ($_SERVER['REQUEST_METHOD'] === 'POST' && $application) { $action = $_POST['action'] ?? 'add'; $studentId = filter_input(INPUT_POST, 'student_id', FILTER_VALIDATE_INT) ?: 0; - [$values, $errors] = validate_student_input($_POST); + $isEditAction = $action === 'edit' && $studentId > 0; + [$values, $errors] = validate_student_input($_POST, $isEditAction); + + if (!$isEditAction && $selectedCycleId > 0 && $values['student_code'] === '') { + $values['student_code'] = $nextStudentCode !== '' ? $nextStudentCode : next_student_code_for_cycle((int) $application['id'], $selectedCycleId); + } if (!$isApprovedSchool) { $errors['form'] = 'لا يمكن فتح تسجيل الطلاب قبل اعتماد المركز.'; @@ -343,8 +353,9 @@ render_flash($flash);
- - + + +
يتم توليد رقم الطالب تلقائياً عند إضافة سجل جديد.
@@ -413,6 +424,8 @@ render_flash($flash);