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);
@@ -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);
-
الرقم / الكود
-
+
رقم الطالب
+
+
يتم توليد رقم الطالب تلقائياً عند إضافة سجل جديد.
= e($errors['student_code']) ?>
@@ -413,6 +424,8 @@ render_flash($flash);