This commit is contained in:
@@ -234,6 +234,20 @@ class Services extends BaseService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function staffDirectorySync(bool $getShared = true): \App\Services\StaffDirectorySyncService
|
||||||
|
{
|
||||||
|
if ($getShared) {
|
||||||
|
return static::getSharedInstance('staffDirectorySync');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \App\Services\StaffDirectorySyncService(
|
||||||
|
model(\App\Models\StaffModel::class),
|
||||||
|
model(\App\Models\UserModel::class),
|
||||||
|
model(\App\Models\ConfigurationModel::class),
|
||||||
|
\Config\Database::connect()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static function schoolYearManagement(bool $getShared = true): \App\Services\SchoolYearManagementService
|
public static function schoolYearManagement(bool $getShared = true): \App\Services\SchoolYearManagementService
|
||||||
{
|
{
|
||||||
if ($getShared) {
|
if ($getShared) {
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ class SessionTimeout
|
|||||||
// Session timeout in seconds (30 minutes)
|
// Session timeout in seconds (30 minutes)
|
||||||
public const TIMEOUT_DURATION = 1800;
|
public const TIMEOUT_DURATION = 1800;
|
||||||
|
|
||||||
// Show warning after 25 minutes of inactivity (5 minutes before timeout)
|
// Show warning during the last 30 seconds before timeout.
|
||||||
public const WARNING_THRESHOLD = 1500;
|
public const WARNING_THRESHOLD = 1770;
|
||||||
|
|
||||||
// Server-side check interval (in seconds)
|
// Server-side check interval (in seconds)
|
||||||
public const CHECK_INTERVAL = 60; // 1 minute
|
public const CHECK_INTERVAL = 5;
|
||||||
|
|
||||||
// Client-side check interval (in milliseconds)
|
// Client-side check interval (in milliseconds)
|
||||||
public const CLIENT_CHECK_INTERVAL = 60000; // 1 minute
|
public const CLIENT_CHECK_INTERVAL = 5000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class SchoolYearController extends BaseController
|
|||||||
'statuses' => SchoolYearStatus::ALL,
|
'statuses' => SchoolYearStatus::ALL,
|
||||||
'activeYear' => $activeYear,
|
'activeYear' => $activeYear,
|
||||||
'nextDraftYear' => $nextDraftYear,
|
'nextDraftYear' => $nextDraftYear,
|
||||||
|
'nextDraftDefaults' => service('schoolYearManagement')->nextDraftDefaults(),
|
||||||
'closingYear' => $closingYear,
|
'closingYear' => $closingYear,
|
||||||
'archivedCount' => $archivedCount,
|
'archivedCount' => $archivedCount,
|
||||||
'latestTransitions' => service('schoolYearManagement')->latestTransitionByYear(),
|
'latestTransitions' => service('schoolYearManagement')->latestTransitionByYear(),
|
||||||
|
|||||||
@@ -2309,6 +2309,8 @@ class AdministratorController extends BaseController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
service('staffDirectorySync')->syncUser((int) $userId);
|
||||||
|
|
||||||
return redirect()->to('/administrator/manage-users');
|
return redirect()->to('/administrator/manage-users');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,13 +72,18 @@ class HomeworkTrackingController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$limitToSemester = $this->hasTeacherAssignments($this->schoolYear, $this->semester);
|
$limitToSemester = $this->hasTeacherAssignments($this->schoolYear, $this->semester);
|
||||||
|
$semesterVariants = $this->getSemesterVariants($this->semester);
|
||||||
|
|
||||||
// Aggregate homework presence + first entered date per class_section_id + homework_index
|
// Aggregate submitted homework per class_section_id + homework_index.
|
||||||
|
// Adding a homework column creates blank rows for every student, so only
|
||||||
|
// indexes with at least one non-null score count as submitted.
|
||||||
$hwQ = $this->db->table('homework')
|
$hwQ = $this->db->table('homework')
|
||||||
->select('class_section_id, homework_index, MIN(created_at) AS first_created, COUNT(*) AS cnt')
|
->select('class_section_id, homework_index')
|
||||||
|
->select('MIN(CASE WHEN score IS NOT NULL THEN updated_at ELSE NULL END) AS first_submitted', false)
|
||||||
|
->select('COUNT(score) AS scored_count', false)
|
||||||
->where('school_year', $this->schoolYear);
|
->where('school_year', $this->schoolYear);
|
||||||
if ($limitToSemester && $this->semester !== '') {
|
if ($limitToSemester && $semesterVariants !== []) {
|
||||||
$hwQ->where('semester', $this->semester);
|
$hwQ->whereIn('semester', $semesterVariants);
|
||||||
}
|
}
|
||||||
$rows = $hwQ->groupBy('class_section_id, homework_index')
|
$rows = $hwQ->groupBy('class_section_id, homework_index')
|
||||||
->get()
|
->get()
|
||||||
@@ -90,60 +95,32 @@ class HomeworkTrackingController extends BaseController
|
|||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$csid = (int)($r['class_section_id'] ?? 0);
|
$csid = (int)($r['class_section_id'] ?? 0);
|
||||||
$hi = (int)($r['homework_index'] ?? 0);
|
$hi = (int)($r['homework_index'] ?? 0);
|
||||||
$cnt = (int)($r['cnt'] ?? 0);
|
$cnt = (int)($r['scored_count'] ?? 0);
|
||||||
if ($csid > 0 && $hi > 0 && $cnt > 0) {
|
if ($csid > 0 && $hi > 0 && $cnt > 0) {
|
||||||
$hasHomework[$csid][$hi] = true;
|
$hasHomework[$csid][$hi] = true;
|
||||||
$dateStr = substr((string)($r['first_created'] ?? ''), 0, 10);
|
$dateStr = substr((string)($r['first_submitted'] ?? ''), 0, 10);
|
||||||
$hwEnteredAt[$csid][$hi] = $dateStr ?: null;
|
$hwEnteredAt[$csid][$hi] = $dateStr ?: null;
|
||||||
$homeworkSubmissionCounts[$csid] = ($homeworkSubmissionCounts[$csid] ?? 0) + 1;
|
$homeworkSubmissionCounts[$csid] = ($homeworkSubmissionCounts[$csid] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build date-based presence mapped to the nearest prior non-NoSchool Sunday.
|
$indexToDate = [];
|
||||||
$hb = $this->db->table('homework')
|
foreach ($dateToIndex as $ymd => $homeworkIndex) {
|
||||||
->select("class_section_id, DATE(created_at) AS hw_date, MIN(created_at) AS first_created, COUNT(*) AS cnt", false)
|
if ($homeworkIndex !== null) {
|
||||||
->where('school_year', $this->schoolYear);
|
$indexToDate[(int) $homeworkIndex] = $ymd;
|
||||||
if ($limitToSemester && $this->semester !== '') {
|
}
|
||||||
$hb->where('semester', $this->semester);
|
|
||||||
}
|
}
|
||||||
$rowsByDate = $hb->groupBy('class_section_id, DATE(created_at)', '', false)
|
|
||||||
->orderBy('hw_date', 'ASC', false)
|
|
||||||
->get()
|
|
||||||
->getResultArray();
|
|
||||||
|
|
||||||
$hasHomeworkByDate = [];
|
$hasHomeworkByDate = [];
|
||||||
$hwEnteredAtByDate = [];
|
$hwEnteredAtByDate = [];
|
||||||
foreach ($rowsByDate as $r) {
|
foreach ($hasHomework as $csid => $indexes) {
|
||||||
$csid = (int)($r['class_section_id'] ?? 0);
|
foreach ($indexes as $homeworkIndex => $_submitted) {
|
||||||
$d = substr((string)($r['hw_date'] ?? ''), 0, 10);
|
$ymd = $indexToDate[(int) $homeworkIndex] ?? null;
|
||||||
$cnt = (int)($r['cnt'] ?? 0);
|
if ($ymd === null) {
|
||||||
$firstCreated = substr((string)($r['first_created'] ?? ''), 0, 10);
|
continue;
|
||||||
if ($csid <= 0 || !$d || $cnt <= 0) { continue; }
|
|
||||||
|
|
||||||
// Find the index of the last Sunday on or before $d
|
|
||||||
$baseIndex = -1;
|
|
||||||
for ($i = count($sundays) - 1; $i >= 0; $i--) {
|
|
||||||
if ($sundays[$i] <= $d) { $baseIndex = $i; break; }
|
|
||||||
}
|
|
||||||
if ($baseIndex < 0) { continue; }
|
|
||||||
|
|
||||||
// Map this homework day to only the nearest prior non–No School Sunday
|
|
||||||
$j = $baseIndex;
|
|
||||||
while ($j >= 0 && !empty($eventDays[$sundays[$j]])) { $j--; }
|
|
||||||
if ($j < 0) { continue; }
|
|
||||||
$sd = $sundays[$j];
|
|
||||||
|
|
||||||
// Mark homework on that Sunday for this class_section (single mapping)
|
|
||||||
$hasHomeworkByDate[$csid][$sd] = true;
|
|
||||||
if (empty($hwEnteredAtByDate[$csid][$sd])) {
|
|
||||||
$hwEnteredAtByDate[$csid][$sd] = $firstCreated ?: $d;
|
|
||||||
} else {
|
|
||||||
// Keep the earliest date for display
|
|
||||||
$existing = (string)$hwEnteredAtByDate[$csid][$sd];
|
|
||||||
$candidate = $firstCreated ?: $d;
|
|
||||||
if ($candidate && (!$existing || $candidate < $existing)) {
|
|
||||||
$hwEnteredAtByDate[$csid][$sd] = $candidate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$hasHomeworkByDate[(int) $csid][$ymd] = true;
|
||||||
|
$hwEnteredAtByDate[(int) $csid][$ymd] = $hwEnteredAt[(int) $csid][(int) $homeworkIndex] ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +229,21 @@ class HomeworkTrackingController extends BaseController
|
|||||||
return $this->teacherAssignmentCache[$year];
|
return $this->teacherAssignmentCache[$year];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getSemesterVariants(string $semester): array
|
||||||
|
{
|
||||||
|
$trimmed = trim($semester);
|
||||||
|
if ($trimmed === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique([
|
||||||
|
$trimmed,
|
||||||
|
ucfirst(strtolower($trimmed)),
|
||||||
|
strtolower($trimmed),
|
||||||
|
strtoupper($trimmed),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute start/end dates for the given semester within the school year.
|
* Compute start/end dates for the given semester within the school year.
|
||||||
* Fall: 09/21/{startYear} to 01/18/{startYear+1}
|
* Fall: 09/21/{startYear} to 01/18/{startYear+1}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\UserModel;
|
|||||||
use App\Models\UserRoleModel;
|
use App\Models\UserRoleModel;
|
||||||
use App\Models\ConfigurationModel;
|
use App\Models\ConfigurationModel;
|
||||||
use App\Models\StaffModel;
|
use App\Models\StaffModel;
|
||||||
|
use App\Services\StaffDirectorySyncService;
|
||||||
use CodeIgniter\Controller;
|
use CodeIgniter\Controller;
|
||||||
|
|
||||||
class RolePermissionController extends Controller
|
class RolePermissionController extends Controller
|
||||||
@@ -20,6 +21,7 @@ class RolePermissionController extends Controller
|
|||||||
protected $configModel;
|
protected $configModel;
|
||||||
protected $permissionModel;
|
protected $permissionModel;
|
||||||
protected $rolePermissionModel;
|
protected $rolePermissionModel;
|
||||||
|
protected StaffDirectorySyncService $staffDirectorySync;
|
||||||
protected $request;
|
protected $request;
|
||||||
protected $db;
|
protected $db;
|
||||||
protected $schoolYear;
|
protected $schoolYear;
|
||||||
@@ -31,6 +33,7 @@ class RolePermissionController extends Controller
|
|||||||
$this->userModel = new UserModel();
|
$this->userModel = new UserModel();
|
||||||
$this->userRoleModel = new UserRoleModel();
|
$this->userRoleModel = new UserRoleModel();
|
||||||
$this->staffModel = new StaffModel();
|
$this->staffModel = new StaffModel();
|
||||||
|
$this->staffDirectorySync = service('staffDirectorySync');
|
||||||
$this->configModel = new ConfigurationModel();
|
$this->configModel = new ConfigurationModel();
|
||||||
$this->permissionModel = new PermissionModel();
|
$this->permissionModel = new PermissionModel();
|
||||||
$this->rolePermissionModel = new RolePermissionModel();
|
$this->rolePermissionModel = new RolePermissionModel();
|
||||||
@@ -224,49 +227,7 @@ class RolePermissionController extends Controller
|
|||||||
|
|
||||||
private function updateStaffRecord(int $userId, array $newRoleNames): void
|
private function updateStaffRecord(int $userId, array $newRoleNames): void
|
||||||
{
|
{
|
||||||
$excludedRoles = ['parent', 'student', 'guest'];
|
$this->staffDirectorySync->syncUser($userId);
|
||||||
$loweredNewRoles = array_map('strtolower', $newRoleNames);
|
|
||||||
|
|
||||||
$user = $this->userModel->find($userId);
|
|
||||||
if (!$user) {
|
|
||||||
log_message('error', "updateStaffRecord: user_id {$userId} not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch existing staff record if it exists
|
|
||||||
$existingStaff = $this->staffModel->where('user_id', $userId)->first();
|
|
||||||
|
|
||||||
// Extract existing roles from DB if any
|
|
||||||
$existingRoles = [];
|
|
||||||
if (!empty($existingStaff['role_name'])) {
|
|
||||||
$existingRoles = array_map('strtolower', array_map('trim', explode(',', $existingStaff['role_name'])));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge and deduplicate roles
|
|
||||||
$allRoles = array_unique(array_merge($existingRoles, $loweredNewRoles));
|
|
||||||
|
|
||||||
// Determine staff roles (excluding parent/student/guest)
|
|
||||||
$staffRoles = array_filter($newRoleNames, fn($role) => !in_array($role, $excludedRoles));
|
|
||||||
|
|
||||||
// Determine active role
|
|
||||||
$activeRole = !empty($staffRoles) ? $staffRoles[0] : 'inactive';
|
|
||||||
|
|
||||||
$email = $this->generateUniqueStaffEmail($user['firstname'], $user['lastname'], $userId);
|
|
||||||
$now = utc_now();
|
|
||||||
|
|
||||||
$row = [
|
|
||||||
'user_id' => $userId,
|
|
||||||
'firstname' => $user['firstname'],
|
|
||||||
'lastname' => $user['lastname'],
|
|
||||||
'email' => $email,
|
|
||||||
'phone' => $user['cellphone'],
|
|
||||||
'role_name' => implode(', ', $allRoles), // historical roles
|
|
||||||
'active_role' => $activeRole,
|
|
||||||
'school_year' => $this->schoolYear,
|
|
||||||
'updated_at' => $now,
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->staffModel->upsert($row);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateUniqueStaffEmail(string $firstname, string $lastname, int $userId): string
|
private function generateUniqueStaffEmail(string $firstname, string $lastname, int $userId): string
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Models\StaffModel;
|
|||||||
use App\Models\UserModel;
|
use App\Models\UserModel;
|
||||||
use App\Models\TeacherClassModel;
|
use App\Models\TeacherClassModel;
|
||||||
use App\Models\ConfigurationModel;
|
use App\Models\ConfigurationModel;
|
||||||
|
use App\Services\StaffDirectorySyncService;
|
||||||
|
|
||||||
class StaffController extends BaseController
|
class StaffController extends BaseController
|
||||||
{
|
{
|
||||||
@@ -16,6 +17,7 @@ class StaffController extends BaseController
|
|||||||
protected $teacherClassModel;
|
protected $teacherClassModel;
|
||||||
protected $userModel;
|
protected $userModel;
|
||||||
protected $staffModel;
|
protected $staffModel;
|
||||||
|
protected StaffDirectorySyncService $staffDirectorySync;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -24,6 +26,7 @@ class StaffController extends BaseController
|
|||||||
$this->teacherClassModel = new TeacherClassModel();
|
$this->teacherClassModel = new TeacherClassModel();
|
||||||
$this->userModel = new UserModel();
|
$this->userModel = new UserModel();
|
||||||
$this->staffModel = new StaffModel();
|
$this->staffModel = new StaffModel();
|
||||||
|
$this->staffDirectorySync = service('staffDirectorySync');
|
||||||
|
|
||||||
// Retrieve the configuration values
|
// Retrieve the configuration values
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = $this->configModel->getConfig('semester');
|
||||||
@@ -38,61 +41,11 @@ class StaffController extends BaseController
|
|||||||
|
|
||||||
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
|
|
||||||
// roles we never show
|
$directory = $this->staffDirectorySync->activeStaffForSchoolYear($schoolYear);
|
||||||
$excludedRoles = ['student', 'parent', 'guest', 'inactive']; // ← add inactive here
|
|
||||||
|
|
||||||
$staffList = $this->staffModel
|
|
||||||
->whereNotIn('LOWER(active_role)', array_map('strtolower', $excludedRoles))
|
|
||||||
->orderBy('created_at', 'DESC')
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
// Preload assignments for the selected school year (across all semesters),
|
|
||||||
// mirroring the logic used in teacher_class_assignment.
|
|
||||||
$assignRows = $this->teacherClassModel
|
|
||||||
->select('teacher_class.teacher_id, teacher_class.position, classSection.class_section_name')
|
|
||||||
->join('classSection', 'classSection.class_section_id = teacher_class.class_section_id', 'left')
|
|
||||||
->where('teacher_class.school_year', $schoolYear)
|
|
||||||
->findAll();
|
|
||||||
|
|
||||||
$assignByTeacher = [];
|
|
||||||
foreach ($assignRows as $r) {
|
|
||||||
$tid = (int)($r['teacher_id'] ?? 0);
|
|
||||||
if ($tid <= 0) { continue; }
|
|
||||||
$name = trim((string)($r['class_section_name'] ?? ''));
|
|
||||||
if ($name === '') { continue; }
|
|
||||||
$pos = strtolower((string)($r['position'] ?? ''));
|
|
||||||
if (!in_array($pos, ['main','ta'], true)) { continue; }
|
|
||||||
$assignByTeacher[$tid][] = $name . ' (' . $pos . ')';
|
|
||||||
}
|
|
||||||
|
|
||||||
$issuesCount = 0;
|
|
||||||
foreach ($staffList as &$staff) {
|
|
||||||
// attach school_id
|
|
||||||
$staff['school_id'] = $this->userModel->getSchoolIdByUserId($staff['user_id'] ?? null);
|
|
||||||
|
|
||||||
// Verify and attach class assignments for teacher/TA roles for the selected school year
|
|
||||||
$role = strtolower((string)($staff['active_role'] ?? ''));
|
|
||||||
if (in_array($role, ['teacher', 'teacher_assistant'], true)) {
|
|
||||||
$tid = (int)($staff['user_id'] ?? 0);
|
|
||||||
$labels = $assignByTeacher[$tid] ?? [];
|
|
||||||
if (!empty($labels)) {
|
|
||||||
$staff['class_section'] = implode(', ', array_unique($labels));
|
|
||||||
$staff['verification_issue'] = false;
|
|
||||||
} else {
|
|
||||||
$staff['class_section'] = 'No class assigned';
|
|
||||||
$staff['verification_issue'] = true;
|
|
||||||
$issuesCount++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$staff['class_section'] = '—';
|
|
||||||
$staff['verification_issue'] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset($staff); // break reference
|
|
||||||
|
|
||||||
return view('staff/index', [
|
return view('staff/index', [
|
||||||
'staff' => $staffList,
|
'staff' => $directory['staff'],
|
||||||
'issues_count' => $issuesCount,
|
'issues_count' => $directory['issues_count'],
|
||||||
'semester' => $this->semester,
|
'semester' => $this->semester,
|
||||||
'school_year' => $schoolYear,
|
'school_year' => $schoolYear,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use App\Models\IpAttemptModel;
|
|||||||
use CodeIgniter\Controller;
|
use CodeIgniter\Controller;
|
||||||
use App\Controllers\View\EmailController;
|
use App\Controllers\View\EmailController;
|
||||||
use App\Models\LoginActivityModel; // Make sure this import is present
|
use App\Models\LoginActivityModel; // Make sure this import is present
|
||||||
|
use App\Services\StaffDirectorySyncService;
|
||||||
require_once APPPATH . 'Helpers/pbkdf2_helper.php';
|
require_once APPPATH . 'Helpers/pbkdf2_helper.php';
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ class UserController extends BaseController
|
|||||||
protected $passwordResetModel;
|
protected $passwordResetModel;
|
||||||
protected $loginActivityModel;
|
protected $loginActivityModel;
|
||||||
protected $resetRequestModel;
|
protected $resetRequestModel;
|
||||||
|
protected StaffDirectorySyncService $staffDirectorySync;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -48,6 +50,7 @@ class UserController extends BaseController
|
|||||||
$this->passwordResetModel = new PasswordResetModel();
|
$this->passwordResetModel = new PasswordResetModel();
|
||||||
$this->loginActivityModel = new LoginActivityModel();
|
$this->loginActivityModel = new LoginActivityModel();
|
||||||
$this->resetRequestModel = new PasswordResetRequestModel();
|
$this->resetRequestModel = new PasswordResetRequestModel();
|
||||||
|
$this->staffDirectorySync = service('staffDirectorySync');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function denyAccess(string $message)
|
private function denyAccess(string $message)
|
||||||
@@ -382,6 +385,8 @@ class UserController extends BaseController
|
|||||||
return redirect()->back()->withInput()->with('errors', $this->userRoleModel->errors());
|
return redirect()->back()->withInput()->with('errors', $this->userRoleModel->errors());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->staffDirectorySync->syncUser((int) $userId);
|
||||||
|
|
||||||
return redirect()->to('/user');
|
return redirect()->to('/user');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +417,7 @@ class UserController extends BaseController
|
|||||||
|
|
||||||
// Delete the user's roles from the user_roles table
|
// Delete the user's roles from the user_roles table
|
||||||
$this->userRoleModel->where('user_id', $id)->delete();
|
$this->userRoleModel->where('user_id', $id)->delete();
|
||||||
|
$this->staffDirectorySync->syncUser((int) $id);
|
||||||
|
|
||||||
return redirect()->to('/user');
|
return redirect()->to('/user');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class TeacherClassModel extends Model
|
|||||||
protected $returnType = 'array';
|
protected $returnType = 'array';
|
||||||
protected $useSoftDeletes = false;
|
protected $useSoftDeletes = false;
|
||||||
protected $skipValidation = false;
|
protected $skipValidation = false;
|
||||||
|
protected $afterInsert = ['syncStaffDirectoryAfterWrite'];
|
||||||
|
protected $afterUpdate = ['syncStaffDirectoryAfterWrite'];
|
||||||
/** Request-lifetime memo cache */
|
/** Request-lifetime memo cache */
|
||||||
protected array $assignedCache = [];
|
protected array $assignedCache = [];
|
||||||
protected array $assignedBySectionCache = [];
|
protected array $assignedBySectionCache = [];
|
||||||
@@ -57,6 +59,43 @@ class TeacherClassModel extends Model
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected function syncStaffDirectoryAfterWrite(array $data): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$userIds = [];
|
||||||
|
$teacherId = (int) ($data['data']['teacher_id'] ?? 0);
|
||||||
|
if ($teacherId > 0) {
|
||||||
|
$userIds[] = $teacherId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($userIds === [] && ! empty($data['id'])) {
|
||||||
|
$ids = is_array($data['id']) ? $data['id'] : [$data['id']];
|
||||||
|
$ids = array_values(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0));
|
||||||
|
if ($ids !== []) {
|
||||||
|
$rows = $this->db->table($this->table)
|
||||||
|
->select('teacher_id')
|
||||||
|
->whereIn($this->primaryKey, $ids)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$rowTeacherId = (int) ($row['teacher_id'] ?? 0);
|
||||||
|
if ($rowTeacherId > 0) {
|
||||||
|
$userIds[] = $rowTeacherId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_unique($userIds) as $userId) {
|
||||||
|
service('staffDirectorySync')->syncUser((int) $userId);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'TeacherClassModel staff directory sync failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
public function getClassSectionIdByUserId($user_id)
|
public function getClassSectionIdByUserId($user_id)
|
||||||
{
|
{
|
||||||
$result = $this->where('teacher_id', $user_id)->first();
|
$result = $this->where('teacher_id', $user_id)->first();
|
||||||
|
|||||||
@@ -23,6 +23,34 @@ class UserRoleModel extends Model
|
|||||||
protected $createdField = 'created_at';
|
protected $createdField = 'created_at';
|
||||||
protected $updatedField = 'updated_at';
|
protected $updatedField = 'updated_at';
|
||||||
protected $deletedField = 'deleted_at';
|
protected $deletedField = 'deleted_at';
|
||||||
|
protected $afterInsert = ['syncStaffDirectoryAfterWrite'];
|
||||||
|
protected $afterUpdate = ['syncStaffDirectoryAfterWrite'];
|
||||||
|
protected $afterDelete = ['syncStaffDirectoryAfterDelete'];
|
||||||
|
|
||||||
|
protected function syncStaffDirectoryAfterWrite(array $data): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$userId = (int) ($data['data']['user_id'] ?? 0);
|
||||||
|
if ($userId > 0) {
|
||||||
|
service('staffDirectorySync')->syncUser($userId);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'UserRoleModel staff directory sync failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function syncStaffDirectoryAfterDelete(array $data): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
service('staffDirectorySync')->syncAll();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'UserRoleModel staff directory delete sync failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ✅ Fetch all role names assigned to a specific user
|
* ✅ Fetch all role names assigned to a specific user
|
||||||
|
|||||||
@@ -25,8 +25,14 @@ final class SchoolYearManagementService
|
|||||||
|
|
||||||
public function createDraft(array $payload, ?int $userId = null): int
|
public function createDraft(array $payload, ?int $userId = null): int
|
||||||
{
|
{
|
||||||
|
$nextDraft = $this->nextDraftDefaults();
|
||||||
|
if ($nextDraft['name'] === null) {
|
||||||
|
throw new InvalidArgumentException('Create an initial school year before using automatic next-year draft creation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload['name'] = $nextDraft['name'];
|
||||||
$payload = $this->metadataPayload($payload);
|
$payload = $this->metadataPayload($payload);
|
||||||
$payload['previous_school_year_id'] = $this->previousYearIdForDraft((string) $payload['name']);
|
$payload['previous_school_year_id'] = (int) ($nextDraft['previous_year']['id'] ?? 0) ?: $this->previousYearIdForDraft((string) $payload['name']);
|
||||||
$payload['status'] = SchoolYearStatus::DRAFT;
|
$payload['status'] = SchoolYearStatus::DRAFT;
|
||||||
$payload['created_by'] = $userId;
|
$payload['created_by'] = $userId;
|
||||||
$payload['updated_by'] = $userId;
|
$payload['updated_by'] = $userId;
|
||||||
@@ -48,6 +54,19 @@ final class SchoolYearManagementService
|
|||||||
return (int) $id;
|
return (int) $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function nextDraftDefaults(): array
|
||||||
|
{
|
||||||
|
$previousYear = $this->sourceYearForNextDraft();
|
||||||
|
$name = $previousYear !== null
|
||||||
|
? $this->nextSchoolYearName((string) ($previousYear['name'] ?? ''))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'previous_year' => $previousYear,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function updateMetadata(int $id, array $payload, ?int $userId = null): void
|
public function updateMetadata(int $id, array $payload, ?int $userId = null): void
|
||||||
{
|
{
|
||||||
$year = $this->requireYear($id);
|
$year = $this->requireYear($id);
|
||||||
@@ -321,6 +340,29 @@ final class SchoolYearManagementService
|
|||||||
return $latestYear !== null ? ((int) ($latestYear['id'] ?? 0) ?: null) : null;
|
return $latestYear !== null ? ((int) ($latestYear['id'] ?? 0) ?: null) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function sourceYearForNextDraft(): ?array
|
||||||
|
{
|
||||||
|
$activeYear = $this->schoolYearModel->active();
|
||||||
|
if ($activeYear !== null) {
|
||||||
|
return $activeYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->schoolYearModel
|
||||||
|
->orderBy('name', 'DESC')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextSchoolYearName(string $name): ?string
|
||||||
|
{
|
||||||
|
if (! preg_match('/^(\d{4})-(\d{4})$/', $name, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = (int) $matches[2];
|
||||||
|
|
||||||
|
return $start . '-' . ($start + 1);
|
||||||
|
}
|
||||||
|
|
||||||
private function nullableDate(mixed $value): ?string
|
private function nullableDate(mixed $value): ?string
|
||||||
{
|
{
|
||||||
$value = trim((string) $value);
|
$value = trim((string) $value);
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ConfigurationModel;
|
||||||
|
use App\Models\StaffModel;
|
||||||
|
use App\Models\UserModel;
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
|
||||||
|
final class StaffDirectorySyncService
|
||||||
|
{
|
||||||
|
private const WORK_EMAIL_DOMAIN = '@alrahmaisgl.org';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly StaffModel $staffModel,
|
||||||
|
private readonly UserModel $userModel,
|
||||||
|
private readonly ConfigurationModel $configurationModel,
|
||||||
|
private readonly BaseConnection $db,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncAll(): void
|
||||||
|
{
|
||||||
|
$rows = $this->db->table('users')
|
||||||
|
->select('id')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$userId = (int) ($row['id'] ?? 0);
|
||||||
|
if ($userId > 0) {
|
||||||
|
$this->syncUser($userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncUser(int $userId): void
|
||||||
|
{
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
if (! $user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = $this->rolesForUser($userId);
|
||||||
|
$staffRoles = array_values(array_filter($roles, static function (array $role): bool {
|
||||||
|
return strtolower((string) ($role['name'] ?? '')) !== 'parent';
|
||||||
|
}));
|
||||||
|
|
||||||
|
$now = utc_now();
|
||||||
|
$schoolYear = (string) ($this->configurationModel->getConfig('school_year') ?? '');
|
||||||
|
$roleNames = array_values(array_unique(array_map(
|
||||||
|
static fn (array $role): string => strtolower((string) ($role['name'] ?? '')),
|
||||||
|
$roles
|
||||||
|
)));
|
||||||
|
|
||||||
|
$activeRole = ! empty($staffRoles)
|
||||||
|
? strtolower((string) ($staffRoles[0]['name'] ?? ''))
|
||||||
|
: 'inactive';
|
||||||
|
|
||||||
|
$this->staffModel->upsert([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'firstname' => (string) ($user['firstname'] ?? ''),
|
||||||
|
'lastname' => (string) ($user['lastname'] ?? ''),
|
||||||
|
'email' => $this->generateUniqueStaffEmail(
|
||||||
|
(string) ($user['firstname'] ?? ''),
|
||||||
|
(string) ($user['lastname'] ?? ''),
|
||||||
|
$userId
|
||||||
|
),
|
||||||
|
'phone' => (string) ($user['cellphone'] ?? ''),
|
||||||
|
'role_name' => implode(', ', $roleNames),
|
||||||
|
'active_role' => $activeRole,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activeStaffForSchoolYear(string $schoolYear): array
|
||||||
|
{
|
||||||
|
$this->syncAll();
|
||||||
|
|
||||||
|
$staffRows = $this->db->table('users u')
|
||||||
|
->select([
|
||||||
|
'u.id AS user_id',
|
||||||
|
'u.school_id',
|
||||||
|
'u.firstname',
|
||||||
|
'u.lastname',
|
||||||
|
'u.email AS personal_email',
|
||||||
|
'u.cellphone',
|
||||||
|
's.id AS staff_id',
|
||||||
|
's.email AS work_email',
|
||||||
|
's.active_role',
|
||||||
|
's.role_name',
|
||||||
|
"GROUP_CONCAT(DISTINCT r.name ORDER BY COALESCE(r.priority, 999), r.name SEPARATOR ', ') AS roles",
|
||||||
|
])
|
||||||
|
->join('user_roles ur', 'ur.user_id = u.id', 'inner')
|
||||||
|
->join('roles r', 'r.id = ur.role_id', 'inner')
|
||||||
|
->join('staff s', 's.user_id = u.id', 'left')
|
||||||
|
->where("LOWER(r.name) != 'parent'", null, false)
|
||||||
|
->where('COALESCE(r.is_active, 1) = 1', null, false);
|
||||||
|
|
||||||
|
if ($this->hasField('user_roles', 'deleted_at')) {
|
||||||
|
$staffRows->where('ur.deleted_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $staffRows
|
||||||
|
->groupBy('u.id, u.school_id, u.firstname, u.lastname, u.email, u.cellphone, s.id, s.email, s.active_role, s.role_name')
|
||||||
|
->orderBy('u.lastname', 'ASC')
|
||||||
|
->orderBy('u.firstname', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$assignments = $this->assignmentsByTeacher($schoolYear);
|
||||||
|
$issuesCount = 0;
|
||||||
|
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$userId = (int) ($row['user_id'] ?? 0);
|
||||||
|
$activeRole = strtolower((string) ($row['active_role'] ?? ''));
|
||||||
|
$roleText = strtolower((string) ($row['roles'] ?? ''));
|
||||||
|
$isTeacher = in_array($activeRole, ['teacher', 'teacher_assistant'], true)
|
||||||
|
|| str_contains($roleText, 'teacher')
|
||||||
|
|| preg_match('/(^|, )ta($|,)/', $roleText);
|
||||||
|
|
||||||
|
$labels = $assignments[$userId] ?? [];
|
||||||
|
if ($isTeacher) {
|
||||||
|
if ($labels !== []) {
|
||||||
|
$row['class_section'] = implode(', ', array_unique($labels));
|
||||||
|
$row['verification_issue'] = false;
|
||||||
|
} else {
|
||||||
|
$row['class_section'] = 'No class assigned';
|
||||||
|
$row['verification_issue'] = true;
|
||||||
|
$issuesCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$row['class_section'] = '-';
|
||||||
|
$row['verification_issue'] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'staff' => $rows,
|
||||||
|
'issues_count' => $issuesCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rolesForUser(int $userId): array
|
||||||
|
{
|
||||||
|
$builder = $this->db->table('user_roles ur')
|
||||||
|
->select('r.name, r.priority')
|
||||||
|
->join('roles r', 'r.id = ur.role_id', 'inner')
|
||||||
|
->where('ur.user_id', $userId)
|
||||||
|
->where('COALESCE(r.is_active, 1) = 1', null, false)
|
||||||
|
->orderBy('COALESCE(r.priority, 999)', 'ASC', false)
|
||||||
|
->orderBy('r.name', 'ASC');
|
||||||
|
|
||||||
|
if ($this->hasField('user_roles', 'deleted_at')) {
|
||||||
|
$builder->where('ur.deleted_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder->get()->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assignmentsByTeacher(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if ($schoolYear === '' || ! $this->db->tableExists('teacher_class')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table('teacher_class tc')
|
||||||
|
->select('tc.teacher_id, tc.position, cs.class_section_name')
|
||||||
|
->join('classSection cs', 'cs.class_section_id = tc.class_section_id', 'left')
|
||||||
|
->where('tc.school_year', $schoolYear)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$assignments = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$teacherId = (int) ($row['teacher_id'] ?? 0);
|
||||||
|
$name = trim((string) ($row['class_section_name'] ?? ''));
|
||||||
|
$position = strtolower((string) ($row['position'] ?? ''));
|
||||||
|
|
||||||
|
if ($teacherId <= 0 || $name === '' || ! in_array($position, ['main', 'ta'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignments[$teacherId][] = $name . ' (' . $position . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateUniqueStaffEmail(string $firstname, string $lastname, int $userId): string
|
||||||
|
{
|
||||||
|
$first = strtolower((string) preg_replace('/[^a-z]/i', '', $firstname));
|
||||||
|
$last = strtolower((string) preg_replace('/[^a-z]/i', '', $lastname));
|
||||||
|
|
||||||
|
if ($last === '') {
|
||||||
|
$last = 'user' . $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = $last;
|
||||||
|
$max = max(1, strlen($first));
|
||||||
|
for ($i = 1; $i <= $max; $i++) {
|
||||||
|
$base = substr($first, 0, $i) . $last;
|
||||||
|
$email = $base . self::WORK_EMAIL_DOMAIN;
|
||||||
|
|
||||||
|
$exists = $this->staffModel
|
||||||
|
->where('email', $email)
|
||||||
|
->where('user_id !=', $userId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $exists) {
|
||||||
|
return $email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base . $userId . self::WORK_EMAIL_DOMAIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasField(string $table, string $field): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->db->tableExists($table) && $this->db->fieldExists($field, $table);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,19 +40,6 @@ $queryString = http_build_query(array_filter($queryParams, static function ($val
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form class="row gy-2 gx-3 align-items-end justify-content-center mb-4" method="get" action="<?= site_url('admin/teacher-attendance/month') ?>">
|
<form class="row gy-2 gx-3 align-items-end justify-content-center mb-4" method="get" action="<?= site_url('admin/teacher-attendance/month') ?>">
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label">School Year</label>
|
|
||||||
<select name="school_year" class="form-select">
|
|
||||||
<?php
|
|
||||||
$years = isset($schoolYears) && is_array($schoolYears) ? $schoolYears : [];
|
|
||||||
if (empty($years) && !empty($schoolYear)) $years = [$schoolYear];
|
|
||||||
foreach ($years as $y): $val = is_array($y) && isset($y['school_year']) ? $y['school_year'] : (string)$y; ?>
|
|
||||||
<option value="<?= esc($val) ?>" <?= ((string)($schoolYear ?? '') === (string)$val) ? 'selected' : '' ?>>
|
|
||||||
<?= esc($val) ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Semester</label>
|
<label class="form-label">Semester</label>
|
||||||
<select name="semester" class="form-select">
|
<select name="semester" class="form-select">
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
<link rel="stylesheet" href="<?= base_url('assets/css/style.css') ?>">
|
<link rel="stylesheet" href="<?= base_url('assets/css/style.css') ?>">
|
||||||
<link rel="stylesheet" href="<?= base_url('assets/css/landing_page.css?v=1.1') ?>">
|
<link rel="stylesheet" href="<?= base_url('assets/css/landing_page.css?v=1.1') ?>">
|
||||||
<link rel="stylesheet" href="<?= base_url('assets/css/custom.css') ?>">
|
<link rel="stylesheet" href="<?= base_url('assets/css/custom.css') ?>">
|
||||||
|
<?php if (session()->get('is_logged_in')): ?>
|
||||||
|
<link rel="stylesheet" href="<?= base_url('assets/css/session-timeout.css') ?>">
|
||||||
|
<?php endif; ?>
|
||||||
<?= $this->renderSection('styles') ?>
|
<?= $this->renderSection('styles') ?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
@@ -396,6 +399,9 @@ html, body { overflow-x: hidden; }
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<?= $this->renderSection('scripts') ?>
|
<?= $this->renderSection('scripts') ?>
|
||||||
|
<?php if (session()->get('is_logged_in')): ?>
|
||||||
|
<script src="<?= base_url('assets/js/session_timeout.js') ?>"></script>
|
||||||
|
<?php endif; ?>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -40,6 +40,9 @@
|
|||||||
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.10/css/dataTables.bootstrap5.min.css">
|
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.10/css/dataTables.bootstrap5.min.css">
|
||||||
<!-- DataTables FixedHeader (Bootstrap 5 theme) -->
|
<!-- DataTables FixedHeader (Bootstrap 5 theme) -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/datatables.net-fixedheader-bs5@3.4.0/css/fixedHeader.bootstrap5.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/datatables.net-fixedheader-bs5@3.4.0/css/fixedHeader.bootstrap5.min.css">
|
||||||
|
<?php if (session()->get('is_logged_in')): ?>
|
||||||
|
<link rel="stylesheet" href="<?= base_url('assets/css/session-timeout.css') ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Note: Avoid loading a second Bootstrap build to prevent conflicts with CDN 5.3.3 -->
|
<!-- Note: Avoid loading a second Bootstrap build to prevent conflicts with CDN 5.3.3 -->
|
||||||
<?php
|
<?php
|
||||||
@@ -753,5 +756,8 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<?= $this->renderSection('scripts') ?>
|
<?= $this->renderSection('scripts') ?>
|
||||||
|
<?php if (session()->get('is_logged_in')): ?>
|
||||||
|
<script src="<?= base_url('assets/js/session_timeout.js') ?>"></script>
|
||||||
|
<?php endif; ?>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -194,7 +194,7 @@
|
|||||||
<h5 class="mb-1">Student Promotion Preview</h5>
|
<h5 class="mb-1">Student Promotion Preview</h5>
|
||||||
<div class="text-muted small">Promotion decisions must be generated and resolved before this school year can end.</div>
|
<div class="text-muted small">Promotion decisions must be generated and resolved before this school year can end.</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-outline-primary btn-sm" href="<?= site_url('grading/decisions?' . http_build_query(['school_year' => $source['name'] ?? ''])) ?>">
|
<a class="btn btn-outline-primary btn-sm" href="<?= site_url('grading/below-60/decisions') ?>">
|
||||||
Manage Promotion Decisions
|
Manage Promotion Decisions
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,15 +13,9 @@
|
|||||||
$money = static fn ($value): string => '$' . number_format((float) $value, 2);
|
$money = static fn ($value): string => '$' . number_format((float) $value, 2);
|
||||||
$activeId = (int) ($activeYear['id'] ?? 0);
|
$activeId = (int) ($activeYear['id'] ?? 0);
|
||||||
$nextDraftId = (int) ($nextDraftYear['id'] ?? 0);
|
$nextDraftId = (int) ($nextDraftYear['id'] ?? 0);
|
||||||
$defaultNewPreviousYear = $activeYear ?: ($schoolYears[0] ?? null);
|
$newDraftName = (string) ($nextDraftDefaults['name'] ?? '');
|
||||||
|
$defaultNewPreviousYear = $nextDraftDefaults['previous_year'] ?? null;
|
||||||
$defaultNewPreviousYearName = (string) ($defaultNewPreviousYear['name'] ?? 'None');
|
$defaultNewPreviousYearName = (string) ($defaultNewPreviousYear['name'] ?? 'None');
|
||||||
$previousYearNameByNewYearName = [];
|
|
||||||
foreach (($schoolYears ?? []) as $existingYear) {
|
|
||||||
$existingName = (string) ($existingYear['name'] ?? '');
|
|
||||||
if (preg_match('/^(\d{4})-(\d{4})$/', $existingName, $matches)) {
|
|
||||||
$previousYearNameByNewYearName[$matches[2] . '-' . ((int) $matches[2] + 1)] = $existingName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$closingPreviewUrl = $activeId > 0
|
$closingPreviewUrl = $activeId > 0
|
||||||
? site_url('administrator/school-years/' . $activeId . '/closing/preview' . ($nextDraftId > 0 ? '?' . http_build_query(['target_school_year_id' => $nextDraftId]) : ''))
|
? site_url('administrator/school-years/' . $activeId . '/closing/preview' . ($nextDraftId > 0 ? '?' . http_build_query(['target_school_year_id' => $nextDraftId]) : ''))
|
||||||
: '';
|
: '';
|
||||||
@@ -91,7 +85,13 @@
|
|||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label" for="new_school_year_name">School Year</label>
|
<label class="form-label" for="new_school_year_name">School Year</label>
|
||||||
<input class="form-control" id="new_school_year_name" name="name" placeholder="2026-2027" required pattern="\d{4}-\d{4}">
|
<input
|
||||||
|
class="form-control"
|
||||||
|
id="new_school_year_name"
|
||||||
|
type="text"
|
||||||
|
value="<?= esc($newDraftName !== '' ? $newDraftName : 'Not available', 'attr') ?>"
|
||||||
|
readonly
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label" for="new_previous_school_year_display">Previous Year</label>
|
<label class="form-label" for="new_previous_school_year_display">Previous Year</label>
|
||||||
@@ -100,7 +100,6 @@
|
|||||||
id="new_previous_school_year_display"
|
id="new_previous_school_year_display"
|
||||||
type="text"
|
type="text"
|
||||||
value="<?= esc($defaultNewPreviousYearName, 'attr') ?>"
|
value="<?= esc($defaultNewPreviousYearName, 'attr') ?>"
|
||||||
data-default-previous-year="<?= esc($defaultNewPreviousYearName, 'attr') ?>"
|
|
||||||
readonly
|
readonly
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +124,7 @@
|
|||||||
<input class="form-control" id="new_fall_makeup_exam_on" name="fall_makeup_exam_on" type="date">
|
<input class="form-control" id="new_fall_makeup_exam_on" name="fall_makeup_exam_on" type="date">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 d-flex gap-2">
|
<div class="col-md-2 d-flex gap-2">
|
||||||
<button class="btn btn-primary" type="submit">Save Draft</button>
|
<button class="btn btn-primary" type="submit" <?= $newDraftName === '' ? 'disabled' : '' ?>>Save Draft</button>
|
||||||
<button class="btn btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">Cancel</button>
|
<button class="btn btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -435,21 +434,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
order: [[0, 'desc']]
|
order: [[0, 'desc']]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const newYearInput = document.getElementById('new_school_year_name');
|
|
||||||
const previousYearDisplay = document.getElementById('new_previous_school_year_display');
|
|
||||||
const previousYearByNewYear = <?= json_encode($previousYearNameByNewYearName, JSON_UNESCAPED_SLASHES) ?>;
|
|
||||||
|
|
||||||
if (newYearInput && previousYearDisplay) {
|
|
||||||
const defaultPreviousYear = previousYearDisplay.dataset.defaultPreviousYear || 'None';
|
|
||||||
const updatePreviousYear = function () {
|
|
||||||
const newYearName = newYearInput.value.trim();
|
|
||||||
previousYearDisplay.value = previousYearByNewYear[newYearName] || defaultPreviousYear;
|
|
||||||
};
|
|
||||||
|
|
||||||
newYearInput.addEventListener('input', updatePreviousYear);
|
|
||||||
updatePreviousYear();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|||||||
@@ -21,19 +21,6 @@
|
|||||||
<label>Phone Number</label>
|
<label>Phone Number</label>
|
||||||
<input type="tel" name="phone" class="form-control" value="<?= esc($user['phone'] ?? '') ?>" placeholder="Enter phone number">
|
<input type="tel" name="phone" class="form-control" value="<?= esc($user['phone'] ?? '') ?>" placeholder="Enter phone number">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!--
|
|
||||||
<div class="mb-3">
|
|
||||||
<label>Role</label>
|
|
||||||
<select name="role_id" class="form-control" required>
|
|
||||||
<?php foreach ($roles as $role): ?>
|
|
||||||
<option value="<?= $role['id'] ?>" <?= $roleId == $role['id'] ? 'selected' : '' ?>>
|
|
||||||
<?= esc($role['name']) ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
-->
|
|
||||||
<button type="submit" class="btn btn-primary">Update</button>
|
<button type="submit" class="btn btn-primary">Update</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,15 +32,15 @@
|
|||||||
<?php foreach ($staff as $s): ?>
|
<?php foreach ($staff as $s): ?>
|
||||||
<tr class="<?= !empty($s['verification_issue']) ? 'table-warning' : '' ?>">
|
<tr class="<?= !empty($s['verification_issue']) ? 'table-warning' : '' ?>">
|
||||||
<td style="text-align: center;"><?= esc($order++); ?></td>
|
<td style="text-align: center;"><?= esc($order++); ?></td>
|
||||||
<td><?= esc($s['school_id']) ?></td>
|
<td><?= esc($s['school_id'] ?? '') ?></td>
|
||||||
<td><?= esc($s['firstname']) ?></td>
|
<td><?= esc($s['firstname'] ?? '') ?></td>
|
||||||
<td><?= esc($s['lastname']) ?></td>
|
<td><?= esc($s['lastname'] ?? '') ?></td>
|
||||||
<td><?= esc($s['email']) ?></td>
|
<td><?= esc($s['work_email'] ?? '') ?></td>
|
||||||
<td><?= esc($s['phone'] ?? '') ?></td>
|
<td><?= esc($s['cellphone'] ?? '') ?></td>
|
||||||
<td><?= esc($s['active_role']) ?></td>
|
<td><?= esc($s['roles'] ?? ($s['active_role'] ?? '')) ?></td>
|
||||||
<td><?= esc($s['class_section']) ?></td>
|
<td><?= esc($s['class_section']) ?></td>
|
||||||
<td>
|
<td>
|
||||||
<a href="<?= site_url('staff/edit/' . $s['id']) ?>" class="btn btn-sm btn-warning">Edit</a>
|
<a href="<?= site_url('staff/edit/' . (int) ($s['staff_id'] ?? 0)) ?>" class="btn btn-sm btn-warning">Edit</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
|||||||
+3
-5
@@ -4,10 +4,8 @@ services:
|
|||||||
container_name: mysql
|
container_name: mysql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: password
|
MYSQL_ROOT_PASSWORD: PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||||
MYSQL_DATABASE: school
|
MYSQL_DATABASE: school_prod
|
||||||
MYSQL_USER: root
|
|
||||||
MYSQL_PASSWORD: password
|
|
||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -27,4 +25,4 @@ services:
|
|||||||
- mysql
|
- mysql
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ class SessionTimeoutManager {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.config = {
|
this.config = {
|
||||||
timeout: 1800, // default 30 min
|
timeout: 1800, // default 30 min
|
||||||
warning_time: 1500, // default 5 min before timeout
|
warning_time: 30, // final 30 seconds before timeout
|
||||||
check_interval: 60000, // 1 minute
|
check_interval: 5000, // 5 seconds
|
||||||
logout_url: '/logout',
|
logout_url: '/logout',
|
||||||
keep_alive_url: '/session/ping-activity',
|
keep_alive_url: '/session/ping-activity',
|
||||||
check_url: '/session/check-timeout'
|
check_url: '/session/check-timeout'
|
||||||
@@ -141,10 +141,14 @@ class SessionTimeoutManager {
|
|||||||
} else {
|
} else {
|
||||||
this.updateWarning(data.time_remaining);
|
this.updateWarning(data.time_remaining);
|
||||||
}
|
}
|
||||||
|
this.scheduleLogout(data.time_remaining);
|
||||||
|
this.startCountdown(data.time_remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSessionActive(data) {
|
handleSessionActive(data) {
|
||||||
this.clearWarning();
|
this.clearWarning();
|
||||||
|
clearTimeout(this.timers.logoutTimer);
|
||||||
|
this.timers.logoutTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
showWarning(timeRemaining) {
|
showWarning(timeRemaining) {
|
||||||
@@ -161,6 +165,33 @@ class SessionTimeoutManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleLogout(timeRemaining) {
|
||||||
|
clearTimeout(this.timers.logoutTimer);
|
||||||
|
const delay = Math.max(0, Number(timeRemaining) || 0) * 1000;
|
||||||
|
this.timers.logoutTimer = setTimeout(() => {
|
||||||
|
this.handleSessionExpired({
|
||||||
|
redirect: this.config.logout_url,
|
||||||
|
message: 'Your session has expired due to inactivity. Please log in again.'
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
startCountdown(timeRemaining) {
|
||||||
|
clearInterval(this.timers.warningTimer);
|
||||||
|
let secondsRemaining = Math.max(0, Number(timeRemaining) || 0);
|
||||||
|
this.updateWarning(secondsRemaining);
|
||||||
|
|
||||||
|
this.timers.warningTimer = setInterval(() => {
|
||||||
|
secondsRemaining = Math.max(0, secondsRemaining - 1);
|
||||||
|
this.updateWarning(secondsRemaining);
|
||||||
|
|
||||||
|
if (secondsRemaining <= 0) {
|
||||||
|
clearInterval(this.timers.warningTimer);
|
||||||
|
this.timers.warningTimer = null;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
createWarningModal(timeRemaining) {
|
createWarningModal(timeRemaining) {
|
||||||
this.hideWarning();
|
this.hideWarning();
|
||||||
|
|
||||||
@@ -186,6 +217,9 @@ class SessionTimeoutManager {
|
|||||||
<p style="font-size: 1.1em; margin-bottom: 20px;">
|
<p style="font-size: 1.1em; margin-bottom: 20px;">
|
||||||
Your session will expire in <span class="countdown" style="font-weight: bold; color: #e74c3c;">${timeRemaining}</span> seconds due to inactivity.
|
Your session will expire in <span class="countdown" style="font-weight: bold; color: #e74c3c;">${timeRemaining}</span> seconds due to inactivity.
|
||||||
</p>
|
</p>
|
||||||
|
<p style="font-size: 1em; margin-bottom: 0;">
|
||||||
|
Click Continue Session if you want to keep using this session.
|
||||||
|
</p>
|
||||||
<div style="margin-top: 25px;">
|
<div style="margin-top: 25px;">
|
||||||
<button onclick="sessionTimeout.continueSession()"
|
<button onclick="sessionTimeout.continueSession()"
|
||||||
style="background: #27ae60; color: white; border: none; padding: 12px 24px;
|
style="background: #27ae60; color: white; border: none; padding: 12px 24px;
|
||||||
@@ -216,6 +250,10 @@ class SessionTimeoutManager {
|
|||||||
|
|
||||||
clearWarning() {
|
clearWarning() {
|
||||||
this.hideWarning();
|
this.hideWarning();
|
||||||
|
clearTimeout(this.timers.logoutTimer);
|
||||||
|
this.timers.logoutTimer = null;
|
||||||
|
clearInterval(this.timers.warningTimer);
|
||||||
|
this.timers.warningTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
continueSession() {
|
continueSession() {
|
||||||
@@ -229,6 +267,8 @@ class SessionTimeoutManager {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
clearInterval(this.timers.checkTimer);
|
clearInterval(this.timers.checkTimer);
|
||||||
|
clearTimeout(this.timers.logoutTimer);
|
||||||
|
clearInterval(this.timers.warningTimer);
|
||||||
this.hideWarning();
|
this.hideWarning();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ class SessionTimeoutManager {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.config = {
|
this.config = {
|
||||||
timeout: 1800, // 30 minutes
|
timeout: 1800, // 30 minutes
|
||||||
warning_time: 300, // 5 minutes before timeout
|
warning_time: 30, // final 30 seconds before timeout
|
||||||
check_interval: 60000, // 1 minute
|
check_interval: 5000, // 5 seconds
|
||||||
logout_url: '/logout',
|
logout_url: '/logout',
|
||||||
keep_alive_url: '/session/ping-activity',
|
keep_alive_url: '/session/ping-activity',
|
||||||
check_url: '/session/check-timeout'
|
check_url: '/session/check-timeout'
|
||||||
@@ -140,10 +140,14 @@ class SessionTimeoutManager {
|
|||||||
} else {
|
} else {
|
||||||
this.updateWarning(data.time_remaining);
|
this.updateWarning(data.time_remaining);
|
||||||
}
|
}
|
||||||
|
this.scheduleLogout(data.time_remaining);
|
||||||
|
this.startCountdown(data.time_remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSessionActive(data) {
|
handleSessionActive(data) {
|
||||||
this.clearWarning();
|
this.clearWarning();
|
||||||
|
clearTimeout(this.timers.logoutTimer);
|
||||||
|
this.timers.logoutTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
showWarning(timeRemaining) {
|
showWarning(timeRemaining) {
|
||||||
@@ -160,6 +164,33 @@ class SessionTimeoutManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleLogout(timeRemaining) {
|
||||||
|
clearTimeout(this.timers.logoutTimer);
|
||||||
|
const delay = Math.max(0, Number(timeRemaining) || 0) * 1000;
|
||||||
|
this.timers.logoutTimer = setTimeout(() => {
|
||||||
|
this.handleSessionExpired({
|
||||||
|
redirect: this.config.logout_url,
|
||||||
|
message: 'Your session has expired due to inactivity. Please log in again.'
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
startCountdown(timeRemaining) {
|
||||||
|
clearInterval(this.timers.warningTimer);
|
||||||
|
let secondsRemaining = Math.max(0, Number(timeRemaining) || 0);
|
||||||
|
this.updateWarning(secondsRemaining);
|
||||||
|
|
||||||
|
this.timers.warningTimer = setInterval(() => {
|
||||||
|
secondsRemaining = Math.max(0, secondsRemaining - 1);
|
||||||
|
this.updateWarning(secondsRemaining);
|
||||||
|
|
||||||
|
if (secondsRemaining <= 0) {
|
||||||
|
clearInterval(this.timers.warningTimer);
|
||||||
|
this.timers.warningTimer = null;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
createWarningModal(timeRemaining) {
|
createWarningModal(timeRemaining) {
|
||||||
this.hideWarning();
|
this.hideWarning();
|
||||||
|
|
||||||
@@ -185,6 +216,9 @@ class SessionTimeoutManager {
|
|||||||
<p style="font-size: 1.1em; margin-bottom: 20px;">
|
<p style="font-size: 1.1em; margin-bottom: 20px;">
|
||||||
Your session will expire in <span class="countdown" style="font-weight: bold; color: #e74c3c;">${timeRemaining}</span> seconds due to inactivity.
|
Your session will expire in <span class="countdown" style="font-weight: bold; color: #e74c3c;">${timeRemaining}</span> seconds due to inactivity.
|
||||||
</p>
|
</p>
|
||||||
|
<p style="font-size: 1em; margin-bottom: 0;">
|
||||||
|
Click Continue Session if you want to keep using this session.
|
||||||
|
</p>
|
||||||
<div style="margin-top: 25px;">
|
<div style="margin-top: 25px;">
|
||||||
<button onclick="sessionTimeout.continueSession()"
|
<button onclick="sessionTimeout.continueSession()"
|
||||||
style="background: #27ae60; color: white; border: none; padding: 12px 24px;
|
style="background: #27ae60; color: white; border: none; padding: 12px 24px;
|
||||||
@@ -215,6 +249,10 @@ class SessionTimeoutManager {
|
|||||||
|
|
||||||
clearWarning() {
|
clearWarning() {
|
||||||
this.hideWarning();
|
this.hideWarning();
|
||||||
|
clearTimeout(this.timers.logoutTimer);
|
||||||
|
this.timers.logoutTimer = null;
|
||||||
|
clearInterval(this.timers.warningTimer);
|
||||||
|
this.timers.warningTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
continueSession() {
|
continueSession() {
|
||||||
@@ -228,6 +266,8 @@ class SessionTimeoutManager {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
clearInterval(this.timers.checkTimer);
|
clearInterval(this.timers.checkTimer);
|
||||||
|
clearTimeout(this.timers.logoutTimer);
|
||||||
|
clearInterval(this.timers.warningTimer);
|
||||||
this.hideWarning();
|
this.hideWarning();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ class SessionTimeoutControllerTest extends CIUnitTestCase
|
|||||||
|
|
||||||
$this->assertTrue($body['success']);
|
$this->assertTrue($body['success']);
|
||||||
$this->assertSame(1800, $body['timeout']);
|
$this->assertSame(1800, $body['timeout']);
|
||||||
$this->assertSame(300, $body['warning_time']);
|
$this->assertSame(30, $body['warning_time']);
|
||||||
|
$this->assertSame(5000, $body['check_interval']);
|
||||||
$this->assertStringContainsString('session/check-timeout', $body['check_url']);
|
$this->assertStringContainsString('session/check-timeout', $body['check_url']);
|
||||||
$this->assertStringContainsString('session/ping-activity', $body['keep_alive_url']);
|
$this->assertStringContainsString('session/ping-activity', $body['keep_alive_url']);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user