492 lines
24 KiB
PHP
492 lines
24 KiB
PHP
<?= $this->extend('layout/management_layout') ?>
|
|
<?= $this->section('content') ?>
|
|
<?php
|
|
$scoresLocked = !empty($scoresLocked);
|
|
$lockAttr = $scoresLocked ? 'disabled' : '';
|
|
?>
|
|
<div class="container-fluid py-4 px-3">
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="text-center mx-auto mb-4" style="max-width: 600px;">
|
|
<h2 class="text-dark">Edit Student Comments & Reviews (<?= esc($semester) ?> Semester)</h2>
|
|
</div>
|
|
|
|
<?php if (session()->get('status')): ?>
|
|
<div class="alert alert-success"><?= session()->get('status') ?></div>
|
|
<?php endif; ?>
|
|
<?php if (session()->get('error')): ?>
|
|
<div class="alert alert-danger"><?= esc(session()->get('error')) ?></div>
|
|
<?php endif; ?>
|
|
<?php if ($errors = session()->get('errors')): ?>
|
|
<div class="alert alert-danger">
|
|
<ul class="mb-0">
|
|
<?php foreach ($errors as $error): ?>
|
|
<li><?= esc($error) ?></li>
|
|
<?php endforeach; ?>
|
|
</ul>
|
|
</div>
|
|
<?php endif; ?>
|
|
<?php if ($scoresLocked): ?>
|
|
<div class="alert alert-warning">
|
|
Scores are locked for this class. Unlock to edit.
|
|
</div>
|
|
<?php endif; ?>
|
|
<div class="d-flex justify-content-between mt-4 flex-wrap gap-2">
|
|
<button type="submit" form="commentsForm" class="btn btn-success" <?= $lockAttr ?>>
|
|
<i class="bi bi-save"></i> Save All Changes
|
|
</button>
|
|
<a href="<?= base_url('/grading') ?>" class="btn btn-secondary">
|
|
<i class="bi bi-x-circle"></i> Back to Scores
|
|
</a>
|
|
|
|
</div>
|
|
<br>
|
|
<form id="commentsForm" action="<?= base_url('grading/updateComments') ?>" method="post">
|
|
<?= csrf_field() ?>
|
|
<input type="hidden" name="semester" value="<?= esc($semester) ?>">
|
|
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
|
|
<input type="hidden" name="class_section_id" value="<?= esc($classSectionId) ?>">
|
|
<input type="hidden" id="proofreadCsrfName" value="<?= csrf_token() ?>">
|
|
<input type="hidden" id="proofreadCsrfHash" value="<?= csrf_hash() ?>">
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
|
<div>
|
|
<button type="button" id="proofreadAllBtn" class="btn btn-outline-primary btn-sm" <?= $lockAttr ?>>
|
|
<i class="bi bi-magic"></i> Proofread All Reviews
|
|
</button>
|
|
</div>
|
|
<div class="text-muted small" id="proofreadAllStatus"></div>
|
|
</div>
|
|
|
|
<div class="table-responsive w-100">
|
|
<table id="commentsTable" class="table table-bordered table-striped w-100 comments-table" data-no-mgmt-sticky>
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>#</th>
|
|
<th>School ID</th>
|
|
<th>Student Name</th>
|
|
<th>PTAP Comment</th>
|
|
<th>PTAP Review</th>
|
|
<?php if ($semester === 'Fall'): ?>
|
|
<th>Midterm Comment</th>
|
|
<th>Midterm Review</th>
|
|
<?php elseif ($semester === 'Spring'): ?>
|
|
<th>Final Comment</th>
|
|
<th>Final Review</th>
|
|
<?php endif; ?>
|
|
<th>Attendance Comment</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php $rowNum = 1; ?>
|
|
<?php foreach ($students as $student): ?>
|
|
<?php $studentId = $student['student_id']; ?>
|
|
<tr>
|
|
<td><?= $rowNum++ ?></td>
|
|
<td><?= esc($student['school_id']) ?></td>
|
|
<td>
|
|
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)$studentId ?>">
|
|
<?= esc($student['firstname']) ?> <?= esc($student['lastname']) ?>
|
|
</a>
|
|
</td>
|
|
|
|
<!-- PTAP Comment and Review -->
|
|
<td>
|
|
<?php $ptapCommentId = 'ptap-comment-' . $studentId; ?>
|
|
<?php $ptapReviewId = 'ptap-review-' . $studentId; ?>
|
|
<textarea
|
|
id="<?= esc($ptapCommentId) ?>"
|
|
name="comments[<?= $studentId ?>][ptap]"
|
|
rows="2"
|
|
class="form-control"
|
|
data-first-name="<?= esc($student['firstname']) ?>"
|
|
minlength="100"
|
|
maxlength="350"
|
|
placeholder="Enter PTAP comment" <?= $lockAttr ?>><?= esc($comments[$studentId]['ptap']['comment'] ?? '') ?></textarea>
|
|
</td>
|
|
<td class="ptap-review-cell">
|
|
<div class="d-flex gap-2 align-items-start">
|
|
<textarea
|
|
id="<?= esc($ptapReviewId) ?>"
|
|
name="reviews[<?= $studentId ?>][ptap]"
|
|
rows="2"
|
|
class="form-control review-field"
|
|
placeholder="Enter PTAP review" <?= $lockAttr ?>><?= esc($comments[$studentId]['ptap']['comment_review'] ?? '') ?></textarea>
|
|
<div class="d-flex flex-column gap-1 align-items-stretch">
|
|
<button
|
|
type="button"
|
|
class="btn btn-outline-secondary btn-sm copy-ptap-btn"
|
|
data-copy-source="#<?= esc($ptapCommentId) ?>"
|
|
data-copy-target="#<?= esc($ptapReviewId) ?>" <?= $lockAttr ?>>
|
|
Paste
|
|
</button>
|
|
<div class="small text-muted proofread-status" data-status-for="<?= esc($ptapReviewId) ?>"></div>
|
|
<ul class="proofread-list small mb-0" data-list-for="<?= esc($ptapReviewId) ?>"></ul>
|
|
</div>
|
|
</div>
|
|
<?php if (isset($comments[$studentId]['ptap']['reviewed_by'])): ?>
|
|
<div class="text-muted small mt-1">
|
|
Last reviewed by: <?= esc($comments[$studentId]['ptap']['reviewed_by']) ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
</td>
|
|
|
|
<!-- Conditional Midterm (Fall) or Final (Spring) -->
|
|
<?php if ($semester === 'Fall'): ?>
|
|
<td>
|
|
<?php $midCommentId = 'midterm-comment-' . $studentId; ?>
|
|
<?php $midReviewId = 'midterm-review-' . $studentId; ?>
|
|
<textarea
|
|
id="<?= esc($midCommentId) ?>"
|
|
name="comments[<?= $studentId ?>][midterm]"
|
|
rows="2"
|
|
class="form-control"
|
|
data-first-name="<?= esc($student['firstname']) ?>"
|
|
minlength="100"
|
|
maxlength="350"
|
|
placeholder="Enter Midterm comment" <?= $lockAttr ?>><?= esc($comments[$studentId]['midterm']['comment'] ?? '') ?></textarea>
|
|
</td>
|
|
<td>
|
|
<div class="d-flex gap-2 align-items-start">
|
|
<textarea
|
|
id="<?= esc($midReviewId) ?>"
|
|
name="reviews[<?= $studentId ?>][midterm]"
|
|
rows="2"
|
|
class="form-control review-field"
|
|
data-first-name="<?= esc($student['firstname']) ?>"
|
|
minlength="100"
|
|
maxlength="350"
|
|
placeholder="Enter Midterm review" <?= $lockAttr ?>><?= esc($comments[$studentId]['midterm']['comment_review'] ?? '') ?></textarea>
|
|
<div class="d-flex flex-column gap-1 align-items-stretch">
|
|
<button
|
|
type="button"
|
|
class="btn btn-outline-secondary btn-sm copy-midterm-btn"
|
|
data-copy-source="#<?= esc($midCommentId) ?>"
|
|
data-copy-target="#<?= esc($midReviewId) ?>" <?= $lockAttr ?>>
|
|
Paste
|
|
</button>
|
|
<div class="small text-muted proofread-status" data-status-for="<?= esc($midReviewId) ?>"></div>
|
|
<ul class="proofread-list small mb-0" data-list-for="<?= esc($midReviewId) ?>"></ul>
|
|
</div>
|
|
</div>
|
|
<?php if (isset($comments[$studentId]['midterm']['reviewed_by'])): ?>
|
|
<div class="text-muted small mt-1">
|
|
Last reviewed by: <?= esc($comments[$studentId]['midterm']['reviewed_by']) ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
</td>
|
|
<?php elseif ($semester === 'Spring'): ?>
|
|
<td>
|
|
<?php $finalCommentId = 'final-comment-' . $studentId; ?>
|
|
<?php $finalReviewId = 'final-review-' . $studentId; ?>
|
|
<textarea
|
|
id="<?= esc($finalCommentId) ?>"
|
|
name="comments[<?= $studentId ?>][final]"
|
|
rows="2"
|
|
class="form-control"
|
|
data-first-name="<?= esc($student['firstname']) ?>"
|
|
minlength="100"
|
|
maxlength="350"
|
|
placeholder="Enter Final comment" <?= $lockAttr ?>><?= esc($comments[$studentId]['final']['comment'] ?? '') ?></textarea>
|
|
</td>
|
|
<td>
|
|
<div class="d-flex gap-2 align-items-start">
|
|
<textarea
|
|
id="<?= esc($finalReviewId) ?>"
|
|
name="reviews[<?= $studentId ?>][final]"
|
|
rows="2"
|
|
class="form-control review-field"
|
|
placeholder="Enter Final review" <?= $lockAttr ?>><?= esc($comments[$studentId]['final']['comment_review'] ?? '') ?></textarea>
|
|
<div class="d-flex flex-column gap-1 align-items-stretch">
|
|
<button
|
|
type="button"
|
|
class="btn btn-outline-secondary btn-sm copy-final-btn"
|
|
data-copy-source="#<?= esc($finalCommentId) ?>"
|
|
data-copy-target="#<?= esc($finalReviewId) ?>" <?= $lockAttr ?>>
|
|
Paste
|
|
</button>
|
|
<div class="small text-muted proofread-status" data-status-for="<?= esc($finalReviewId) ?>"></div>
|
|
<ul class="proofread-list small mb-0" data-list-for="<?= esc($finalReviewId) ?>"></ul>
|
|
</div>
|
|
</div>
|
|
<?php if (isset($comments[$studentId]['final']['reviewed_by'])): ?>
|
|
<div class="text-muted small mt-1">
|
|
Last reviewed by: <?= esc($comments[$studentId]['final']['reviewed_by']) ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
</td>
|
|
<?php endif; ?>
|
|
<!-- Attendance Comment only -->
|
|
<td>
|
|
<textarea
|
|
name="comments[<?= $studentId ?>][attendance]"
|
|
rows="2"
|
|
class="form-control"
|
|
data-first-name="<?= esc($student['firstname']) ?>"
|
|
minlength="100"
|
|
maxlength="350"
|
|
placeholder="Enter Attendance comment" <?= $lockAttr ?>><?= esc($comments[$studentId]['attendance']['comment'] ?? '') ?></textarea>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="d-flex justify-content-between mt-4 flex-wrap gap-2">
|
|
<button type="submit" class="btn btn-success" <?= $lockAttr ?>>
|
|
<i class="bi bi-save"></i> Save All Changes
|
|
</button>
|
|
<a href="<?= base_url('/grading') ?>" class="btn btn-secondary">
|
|
<i class="bi bi-x-circle"></i> Back to Scores
|
|
</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.review-field {
|
|
background-color: #f8f9fa;
|
|
border-left: 3px solid #dee2e6;
|
|
}
|
|
.review-field:focus {
|
|
background-color: #fff;
|
|
border-left: 3px solid #86b7fe;
|
|
}
|
|
textarea.form-control {
|
|
min-height: 80px;
|
|
}
|
|
.text-muted small {
|
|
font-size: 0.75rem;
|
|
}
|
|
.comments-table {
|
|
width: 100%;
|
|
table-layout: auto;
|
|
}
|
|
.table-responsive {
|
|
width: 100%;
|
|
overflow-x: auto;
|
|
}
|
|
/* Stretch table to available space */
|
|
.comments-table th,
|
|
.comments-table td {
|
|
white-space: nowrap;
|
|
}
|
|
.proofread-list {
|
|
list-style: none;
|
|
padding-left: 0;
|
|
margin-bottom: 0;
|
|
white-space: normal;
|
|
}
|
|
.proofread-status {
|
|
min-height: 1.25rem;
|
|
white-space: normal;
|
|
}
|
|
</style>
|
|
|
|
<?= $this->endSection() ?>
|
|
|
|
<?= $this->section('scripts') ?>
|
|
<script>
|
|
window.PROOFREAD_ENDPOINT = "<?= site_url('api/proofread') ?>";
|
|
</script>
|
|
<script src="<?= base_url('assets/js/proofread.js') ?>"></script>
|
|
<script>
|
|
// Attach DataTables sorting for comments table
|
|
(function ($) {
|
|
function initCommentsTable() {
|
|
if (!($ && $.fn && $.fn.DataTable)) return;
|
|
const $table = $('#commentsTable');
|
|
if (!$table.length) return;
|
|
|
|
$table.DataTable({
|
|
paging: false,
|
|
info: false,
|
|
searching: false,
|
|
autoWidth: false,
|
|
order: [[2, 'asc']], // Student Name
|
|
columnDefs: [
|
|
{ targets: 0, orderable: false, searchable: false },
|
|
{ targets: 1, className: 'text-nowrap' },
|
|
],
|
|
fixedHeader: {
|
|
header: true,
|
|
headerOffset: typeof getFixedHeaderOffset === 'function' ? getFixedHeaderOffset() : 0,
|
|
},
|
|
drawCallback: function () {
|
|
const api = this.api();
|
|
api.column(0, { search: 'applied', order: 'applied' }).nodes().each(function (cell, i) {
|
|
cell.textContent = i + 1;
|
|
});
|
|
},
|
|
});
|
|
}
|
|
$(document).ready(initCommentsTable);
|
|
})(jQuery);
|
|
</script>
|
|
<script>
|
|
// Auto-expand textareas on load and input
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
document.querySelectorAll('textarea').forEach(textarea => {
|
|
textarea.style.height = 'auto';
|
|
textarea.style.height = (textarea.scrollHeight) + 'px';
|
|
|
|
textarea.addEventListener('input', function() {
|
|
this.style.height = 'auto';
|
|
this.style.height = (this.scrollHeight) + 'px';
|
|
});
|
|
});
|
|
|
|
const form = document.getElementById('commentsForm');
|
|
if (form) {
|
|
const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const csrfInput = form.querySelector('input[type="hidden"][name*="csrf"]');
|
|
const csrfCookieName = '<?= esc(config('Security')->csrfCookieName ?? (config('Security')->cookieName ?? 'csrf_cookie_name')) ?>';
|
|
|
|
const getCookie = function(name) {
|
|
try {
|
|
const parts = document.cookie.split(';').map(function(value) { return value.trim(); });
|
|
const match = parts.find(function(value) { return value.indexOf(name + '=') === 0; });
|
|
return match ? match.split('=')[1] : '';
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
const syncCsrfToken = function() {
|
|
if (!csrfInput) return;
|
|
const fresh = decodeURIComponent(getCookie(csrfCookieName) || '');
|
|
if (fresh) csrfInput.value = fresh;
|
|
};
|
|
|
|
const parseFieldName = function(name) {
|
|
const match = /^(comments|reviews)\[(\d+)\]\[([^\]]+)\]$/.exec(name || '');
|
|
if (!match) return null;
|
|
return { group: match[1], studentId: match[2], scoreType: match[3] };
|
|
};
|
|
|
|
const isValidComment = function(field, value) {
|
|
if (!value) return false;
|
|
if (value.length < 100 || value.length > 350) return false;
|
|
const firstName = field.dataset.firstName || '';
|
|
if (!firstName) return true;
|
|
const startsWithName = new RegExp(`^${escapeRegex(firstName)}\\b`, 'i');
|
|
return startsWithName.test(value);
|
|
};
|
|
|
|
const shouldAutoSave = function(field, value) {
|
|
if (field.disabled) return false;
|
|
const lastSaved = field.getAttribute('data-last-saved') || '';
|
|
if (value === lastSaved) return false;
|
|
const parsed = parseFieldName(field.name || '');
|
|
if (!parsed) return false;
|
|
if (parsed.group === 'comments') {
|
|
return isValidComment(field, value);
|
|
}
|
|
return value !== '';
|
|
};
|
|
|
|
const buildAutoSavePayload = function(field) {
|
|
const parsed = parseFieldName(field.name || '');
|
|
if (!parsed) return null;
|
|
|
|
const data = new FormData();
|
|
if (csrfInput) {
|
|
const csrfValue = decodeURIComponent(getCookie(csrfCookieName) || '') || csrfInput.value;
|
|
data.append(csrfInput.name, csrfValue);
|
|
}
|
|
['semester', 'school_year', 'class_section_id'].forEach(function(name) {
|
|
const input = form.querySelector('[name="' + name + '"]');
|
|
if (input) data.append(name, input.value);
|
|
});
|
|
|
|
if (parsed.group === 'comments') {
|
|
data.append(field.name, field.value);
|
|
} else if (parsed.group === 'reviews') {
|
|
const commentName = `comments[${parsed.studentId}][${parsed.scoreType}]`;
|
|
const commentField = form.querySelector(`[name="${commentName}"]`);
|
|
data.append(commentName, commentField ? commentField.value : '');
|
|
data.append(field.name, field.value);
|
|
}
|
|
|
|
return data;
|
|
};
|
|
|
|
const autoSaveField = function(field) {
|
|
const value = field.value.trim();
|
|
if (!shouldAutoSave(field, value)) return;
|
|
const payload = buildAutoSavePayload(field);
|
|
if (!payload) return;
|
|
fetch(form.action, {
|
|
method: 'POST',
|
|
body: payload,
|
|
credentials: 'same-origin'
|
|
})
|
|
.then(function() {
|
|
field.setAttribute('data-last-saved', value);
|
|
syncCsrfToken();
|
|
})
|
|
.catch(function() {});
|
|
};
|
|
|
|
form.addEventListener('submit', function(event) {
|
|
syncCsrfToken();
|
|
const errors = [];
|
|
form.querySelectorAll('textarea[data-first-name]').forEach(field => {
|
|
const value = field.value.trim();
|
|
if (value === '') return;
|
|
|
|
const min = 100;
|
|
const max = 350;
|
|
const firstName = field.dataset.firstName || 'student';
|
|
|
|
if (value.length < min || value.length > max) {
|
|
errors.push(`Comment for ${firstName} must be between ${min} and ${max} characters.`);
|
|
return;
|
|
}
|
|
|
|
if (field.dataset.firstName) {
|
|
const startsWithName = new RegExp(`^${escapeRegex(field.dataset.firstName)}\\b`, 'i');
|
|
if (!startsWithName.test(value)) {
|
|
errors.push(`Comment for ${firstName} must start with the student's first name.`);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (errors.length > 0) {
|
|
event.preventDefault();
|
|
alert(errors.join('\\n'));
|
|
}
|
|
});
|
|
|
|
form.querySelectorAll('textarea[name^="comments["], textarea[name^="reviews["]').forEach(function(field) {
|
|
field.setAttribute('data-last-saved', field.value.trim());
|
|
field.addEventListener('blur', function() {
|
|
autoSaveField(this);
|
|
});
|
|
});
|
|
}
|
|
|
|
const wireCopyButtons = (selector) => {
|
|
document.querySelectorAll(selector).forEach(button => {
|
|
button.addEventListener('click', function() {
|
|
const source = document.querySelector(this.dataset.copySource || '');
|
|
const target = document.querySelector(this.dataset.copyTarget || '');
|
|
if (!source || !target) return;
|
|
target.value = source.value;
|
|
target.style.height = 'auto';
|
|
target.style.height = (target.scrollHeight) + 'px';
|
|
target.focus();
|
|
});
|
|
});
|
|
};
|
|
|
|
wireCopyButtons('.copy-ptap-btn');
|
|
wireCopyButtons('.copy-midterm-btn');
|
|
wireCopyButtons('.copy-final-btn');
|
|
});
|
|
</script>
|
|
<?= $this->endSection() ?>
|