init backend presensi

This commit is contained in:
mwpn
2026-03-05 14:37:36 +07:00
commit b4fda6b9c9
319 changed files with 27261 additions and 0 deletions

View File

@@ -0,0 +1 @@
# Dashboard Module - Services

View File

@@ -0,0 +1,176 @@
<?php
namespace App\Modules\Dashboard\Services;
use App\Modules\Auth\Entities\Role;
/**
* Dashboard Realtime Service
*
* Fetches latest attendance records for SSE stream (by id > afterId).
* Role-aware filtering: ADMIN (all), WALI_KELAS (by assigned class), GURU_MAPEL (by teacher), ORANG_TUA (by own children).
*/
class DashboardRealtimeService
{
/** Scope type: no filter */
public const SCOPE_ADMIN = 'admin';
/** Scope type: filter by class_id(s) where user is wali */
public const SCOPE_WALI_KELAS = 'wali_kelas';
/** Scope type: filter by schedules taught by this teacher (teacher_user_id) */
public const SCOPE_GURU_MAPEL = 'guru_mapel';
/** Scope type: filter by student_ids linked to parent */
public const SCOPE_ORANG_TUA = 'orang_tua';
/**
* Resolve user scope for realtime filtering from current user (with roles).
*
* @param array $user { id, name, email, roles: [ { role_code, role_name } ] }
* @return array{ type: string, class_ids?: int[], teacher_user_id?: int, student_ids?: int[] }
*/
public function resolveUserScope(array $user): array
{
$roles = $user['roles'] ?? [];
$roleCodes = array_column($roles, 'role_code');
if (in_array(Role::CODE_ADMIN, $roleCodes, true)) {
return ['type' => self::SCOPE_ADMIN];
}
if (in_array(Role::CODE_WALI_KELAS, $roleCodes, true)) {
$classIds = $this->getClassIdsForWali($user['id']);
if ($classIds !== []) {
return ['type' => self::SCOPE_WALI_KELAS, 'class_ids' => $classIds];
}
}
if (in_array(Role::CODE_GURU_MAPEL, $roleCodes, true)) {
return ['type' => self::SCOPE_GURU_MAPEL, 'teacher_user_id' => (int) ($user['id'] ?? 0)];
}
if (in_array(Role::CODE_ORANG_TUA, $roleCodes, true)) {
$studentIds = $this->getStudentIdsForParent($user['id']);
if ($studentIds !== []) {
return ['type' => self::SCOPE_ORANG_TUA, 'student_ids' => $studentIds];
}
}
return ['type' => self::SCOPE_ADMIN];
}
/**
* Get class IDs where user is wali (classes.wali_user_id = userId).
*/
protected function getClassIdsForWali(int $userId): array
{
$db = \Config\Database::connect();
$rows = $db->table('classes')->select('id')->where('wali_user_id', $userId)->get()->getResultArray();
return array_map('intval', array_column($rows, 'id'));
}
/**
* Get student IDs linked to parent (parent.user_id = userId via student_parents).
*/
protected function getStudentIdsForParent(int $userId): array
{
$db = \Config\Database::connect();
$rows = $db->table('student_parents AS sp')
->select('sp.student_id')
->join('parents AS p', 'p.id = sp.parent_id', 'inner')
->where('p.user_id', $userId)
->get()
->getResultArray();
return array_map('intval', array_column($rows, 'student_id'));
}
/**
* Get attendance sessions after a given ID (for SSE incremental feed), with optional role scope.
*
* @param int $afterId Only return rows with id > afterId
* @param int $limit Max rows to return
* @param array|null $userContext Current user from session (id, name, email, roles). If null, no scope filter.
* @return array<int, array{id: int, student_name: string, class_name: string, subject: string, checkin_at: string, status: string}>
*/
public function getAttendanceSinceId(int $afterId = 0, int $limit = 100, ?array $userContext = null): array
{
$db = \Config\Database::connect();
$builder = $db->table('attendance_sessions AS a');
$builder->select('
a.id,
s.name AS student_name,
c.name AS class_name,
COALESCE(sub.name, "-") AS subject,
a.checkin_at,
a.status
');
$builder->join('students AS s', 's.id = a.student_id', 'left');
$builder->join('classes AS c', 'c.id = s.class_id', 'left');
$builder->join('schedules AS sch', 'sch.id = a.schedule_id', 'left');
$builder->join('subjects AS sub', 'sub.id = sch.subject_id', 'left');
$builder->where('a.id >', $afterId);
if ($userContext !== null) {
$this->applyScopeToBuilder($builder, $this->resolveUserScope($userContext));
}
$builder->orderBy('a.id', 'ASC');
$builder->limit($limit);
$rows = $builder->get()->getResultArray();
$out = [];
foreach ($rows as $row) {
$out[] = [
'id' => (int) $row['id'],
'student_name' => (string) ($row['student_name'] ?? '-'),
'class_name' => (string) ($row['class_name'] ?? '-'),
'subject' => (string) ($row['subject'] ?? '-'),
'checkin_at' => (string) $row['checkin_at'],
'status' => (string) $row['status'],
];
}
return $out;
}
/**
* Apply scope conditions to the attendance query builder (no duplicate query).
*
* @param \CodeIgniter\Database\BaseBuilder $builder
* @param array $scope From resolveUserScope()
*/
protected function applyScopeToBuilder($builder, array $scope): void
{
switch ($scope['type'] ?? '') {
case self::SCOPE_WALI_KELAS:
$classIds = $scope['class_ids'] ?? [];
if ($classIds !== []) {
$builder->whereIn('s.class_id', $classIds);
} else {
$builder->where('1 =', 0);
}
break;
case self::SCOPE_GURU_MAPEL:
$teacherUserId = (int) ($scope['teacher_user_id'] ?? 0);
if ($teacherUserId > 0) {
$builder->where('sch.teacher_user_id', $teacherUserId);
} else {
$builder->where('1 =', 0);
}
break;
case self::SCOPE_ORANG_TUA:
$studentIds = $scope['student_ids'] ?? [];
if ($studentIds !== []) {
$builder->whereIn('a.student_id', $studentIds);
} else {
$builder->where('1 =', 0);
}
break;
case self::SCOPE_ADMIN:
default:
break;
}
}
}

View File

@@ -0,0 +1,271 @@
<?php
namespace App\Modules\Dashboard\Services;
use App\Modules\Auth\Entities\Role;
/**
* Dashboard Schedule Service
*
* Returns today's schedules filtered by user role (Asia/Jakarta).
*/
class DashboardScheduleService
{
/**
* Get today's day_of_week (1=Monday, 7=Sunday) in Asia/Jakarta.
*/
public function getTodayDayOfWeek(): int
{
$tz = new \DateTimeZone('Asia/Jakarta');
$now = new \DateTimeImmutable('now', $tz);
return (int) $now->format('N');
}
/**
* Get schedules for today filtered by user role.
* Uses lesson_slots for start/end time and users for teacher_name; fallback to schedule columns when NULL.
*
* @param array|null $userContext { id, roles: [ { role_code } ] }
* @return list<array{schedule_id: int, subject_name: string, class_name: string, teacher_name: string, start_time: string, end_time: string}>
*/
public function getSchedulesToday(?array $userContext = null): array
{
$dayOfWeek = $this->getTodayDayOfWeek();
$db = \Config\Database::connect();
$builder = $db->table('schedules AS sch');
$builder->select('
sch.id AS schedule_id,
COALESCE(sub.name, "-") AS subject_name,
COALESCE(c.name, "-") AS class_name,
COALESCE(u.name, sch.teacher_name) AS teacher_name,
COALESCE(ls.start_time, sch.start_time) AS start_time,
COALESCE(ls.end_time, sch.end_time) AS end_time
');
$builder->join('lesson_slots AS ls', 'ls.id = sch.lesson_slot_id', 'left');
$builder->join('users AS u', 'u.id = sch.teacher_user_id', 'left');
$builder->join('subjects AS sub', 'sub.id = sch.subject_id', 'left');
$builder->join('classes AS c', 'c.id = sch.class_id', 'left');
$builder->where('sch.day_of_week', $dayOfWeek);
$builder->orderBy('COALESCE(ls.start_time, sch.start_time)', 'ASC', false);
$this->applyRoleFilter($builder, $userContext);
$rows = $builder->get()->getResultArray();
$out = [];
foreach ($rows as $row) {
$out[] = [
'schedule_id' => (int) $row['schedule_id'],
'subject_name' => (string) ($row['subject_name'] ?? '-'),
'class_name' => (string) ($row['class_name'] ?? '-'),
'teacher_name' => (string) ($row['teacher_name'] ?? '-'),
'start_time' => (string) $row['start_time'],
'end_time' => (string) $row['end_time'],
];
}
return $out;
}
/**
* Get schedules for a given date (day_of_week from date in Asia/Jakarta). Same shape as getSchedulesToday.
*
* @param string $dateYmd Y-m-d
* @param array|null $userContext
* @return list<array{schedule_id: int, subject_name: string, class_name: string, teacher_name: string, start_time: string, end_time: string}>
*/
public function getSchedulesByDate(string $dateYmd, ?array $userContext = null): array
{
$tz = new \DateTimeZone('Asia/Jakarta');
$date = new \DateTimeImmutable($dateYmd . ' 12:00:00', $tz);
$dayOfWeek = (int) $date->format('N');
$db = \Config\Database::connect();
$builder = $db->table('schedules AS sch');
$builder->select('
sch.id AS schedule_id,
COALESCE(sub.name, "-") AS subject_name,
COALESCE(c.name, "-") AS class_name,
COALESCE(u.name, sch.teacher_name) AS teacher_name,
COALESCE(ls.start_time, sch.start_time) AS start_time,
COALESCE(ls.end_time, sch.end_time) AS end_time
');
$builder->join('lesson_slots AS ls', 'ls.id = sch.lesson_slot_id', 'left');
$builder->join('users AS u', 'u.id = sch.teacher_user_id', 'left');
$builder->join('subjects AS sub', 'sub.id = sch.subject_id', 'left');
$builder->join('classes AS c', 'c.id = sch.class_id', 'left');
$builder->where('sch.day_of_week', $dayOfWeek);
$builder->orderBy('COALESCE(ls.start_time, sch.start_time)', 'ASC', false);
$this->applyRoleFilter($builder, $userContext);
$rows = $builder->get()->getResultArray();
$out = [];
foreach ($rows as $row) {
$out[] = [
'schedule_id' => (int) $row['schedule_id'],
'subject_name' => (string) ($row['subject_name'] ?? '-'),
'class_name' => (string) ($row['class_name'] ?? '-'),
'teacher_name' => (string) ($row['teacher_name'] ?? '-'),
'start_time' => (string) $row['start_time'],
'end_time' => (string) $row['end_time'],
];
}
return $out;
}
/**
* Get current lesson (schedule active right now) or next upcoming today.
* Uses Asia/Jakarta. Same RBAC as getSchedulesToday. lesson_slots/users as source of truth with fallback.
*
* @param array|null $userContext
* @return array{is_active_now: bool, schedule_id?: int, subject_name?: string, class_name?: string, teacher_name?: string, start_time?: string, end_time?: string, next_schedule?: array}
*/
public function getCurrentSchedule(?array $userContext = null): array
{
$tz = new \DateTimeZone('Asia/Jakarta');
$now = new \DateTimeImmutable('now', $tz);
$dayOfWeek = (int) $now->format('N');
$currentTime = $now->format('H:i:s');
$db = \Config\Database::connect();
$base = 'sch.id AS schedule_id, sch.class_id AS class_id, COALESCE(sub.name, "-") AS subject_name, COALESCE(c.name, "-") AS class_name, '
. 'COALESCE(u.name, sch.teacher_name) AS teacher_name, '
. 'COALESCE(ls.start_time, sch.start_time) AS start_time, COALESCE(ls.end_time, sch.end_time) AS end_time';
$timeRangeWhere = '( COALESCE(ls.start_time, sch.start_time) <= ' . $db->escape($currentTime)
. ' AND COALESCE(ls.end_time, sch.end_time) > ' . $db->escape($currentTime) . ' )';
$builder = $db->table('schedules AS sch');
$builder->select($base);
$builder->join('lesson_slots AS ls', 'ls.id = sch.lesson_slot_id', 'left');
$builder->join('users AS u', 'u.id = sch.teacher_user_id', 'left');
$builder->join('subjects AS sub', 'sub.id = sch.subject_id', 'left');
$builder->join('classes AS c', 'c.id = sch.class_id', 'left');
$builder->where('sch.day_of_week', $dayOfWeek);
$builder->where($timeRangeWhere);
$builder->orderBy('COALESCE(ls.start_time, sch.start_time)', 'ASC', false);
$builder->limit(1);
$this->applyRoleFilter($builder, $userContext);
$rows = $builder->get()->getResultArray();
if (!empty($rows)) {
$row = $rows[0];
return [
'is_active_now' => true,
'schedule_id' => (int) $row['schedule_id'],
'class_id' => (int) ($row['class_id'] ?? 0),
'subject_name' => (string) ($row['subject_name'] ?? '-'),
'class_name' => (string) ($row['class_name'] ?? '-'),
'teacher_name' => (string) ($row['teacher_name'] ?? '-'),
'start_time' => (string) $row['start_time'],
'end_time' => (string) $row['end_time'],
];
}
$nextTimeWhere = '( COALESCE(ls.start_time, sch.start_time) > ' . $db->escape($currentTime) . ' )';
$nextBuilder = $db->table('schedules AS sch');
$nextBuilder->select($base);
$nextBuilder->join('lesson_slots AS ls', 'ls.id = sch.lesson_slot_id', 'left');
$nextBuilder->join('users AS u', 'u.id = sch.teacher_user_id', 'left');
$nextBuilder->join('subjects AS sub', 'sub.id = sch.subject_id', 'left');
$nextBuilder->join('classes AS c', 'c.id = sch.class_id', 'left');
$nextBuilder->where('sch.day_of_week', $dayOfWeek);
$nextBuilder->where($nextTimeWhere);
$nextBuilder->orderBy('COALESCE(ls.start_time, sch.start_time)', 'ASC', false);
$nextBuilder->limit(1);
$this->applyRoleFilter($nextBuilder, $userContext);
$nextRows = $nextBuilder->get()->getResultArray();
$nextSchedule = null;
if (!empty($nextRows)) {
$r = $nextRows[0];
$nextSchedule = [
'schedule_id' => (int) $r['schedule_id'],
'subject_name' => (string) ($r['subject_name'] ?? '-'),
'class_name' => (string) ($r['class_name'] ?? '-'),
'teacher_name' => (string) ($r['teacher_name'] ?? '-'),
'start_time' => (string) $r['start_time'],
'end_time' => (string) $r['end_time'],
];
}
$result = ['is_active_now' => false];
if ($nextSchedule !== null) {
$result['next_schedule'] = $nextSchedule;
}
return $result;
}
protected function applyRoleFilter($builder, ?array $userContext): void
{
if ($userContext === null) {
return;
}
$roles = $userContext['roles'] ?? [];
$codes = array_column($roles, 'role_code');
$userId = (int) ($userContext['id'] ?? 0);
if (in_array(Role::CODE_ADMIN, $codes, true) || in_array(Role::CODE_GURU_BK, $codes, true)) {
return;
}
if (in_array(Role::CODE_WALI_KELAS, $codes, true)) {
$classIds = $this->getClassIdsForWali($userId);
if ($classIds === []) {
$builder->where('1 =', 0);
return;
}
$builder->whereIn('sch.class_id', $classIds);
return;
}
if (in_array(Role::CODE_GURU_MAPEL, $codes, true)) {
$builder->where('sch.teacher_user_id', $userId);
return;
}
if (in_array(Role::CODE_ORANG_TUA, $codes, true)) {
$studentIds = $this->getStudentIdsForParent($userId);
if ($studentIds === []) {
$builder->where('1 =', 0);
return;
}
$db = \Config\Database::connect();
$subQuery = $db->table('students')->select('class_id')->whereIn('id', $studentIds)->get()->getResultArray();
$classIds = array_values(array_unique(array_map('intval', array_column($subQuery, 'class_id'))));
if ($classIds === []) {
$builder->where('1 =', 0);
return;
}
$builder->whereIn('sch.class_id', $classIds);
return;
}
}
protected function getClassIdsForWali(int $userId): array
{
$db = \Config\Database::connect();
$rows = $db->table('classes')->select('id')->where('wali_user_id', $userId)->get()->getResultArray();
return array_map('intval', array_column($rows, 'id'));
}
protected function getStudentIdsForParent(int $userId): array
{
$db = \Config\Database::connect();
$rows = $db->table('student_parents AS sp')
->select('sp.student_id')
->join('parents AS p', 'p.id = sp.parent_id', 'inner')
->where('p.user_id', $userId)
->get()
->getResultArray();
return array_map('intval', array_column($rows, 'student_id'));
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Modules\Dashboard\Services;
use App\Modules\Academic\Models\StudentModel;
use App\Modules\Attendance\Models\AttendanceSessionModel;
use App\Modules\Devices\Models\DeviceModel;
/**
* Dashboard Service
*
* Aggregates data for dashboard monitoring endpoints.
*/
class DashboardService
{
protected AttendanceSessionModel $attendanceModel;
protected DeviceModel $deviceModel;
protected StudentModel $studentModel;
/**
* Minutes threshold for device "online" status
*/
protected int $deviceOnlineThresholdMinutes = 2;
public function __construct()
{
$this->attendanceModel = new AttendanceSessionModel();
$this->deviceModel = new DeviceModel();
$this->studentModel = new StudentModel();
}
/**
* Get today's attendance summary
*
* @return array{total_students: int, present_today: int, late_today: int, outside_zone_today: int, no_schedule_today: int, invalid_device_today: int}
*/
public function getSummary(): array
{
$db = \Config\Database::connect();
$totalStudents = (int) $this->studentModel->countAll();
// Count by status for today only (date part of checkin_at = today)
$today = date('Y-m-d');
$builder = $db->table('attendance_sessions');
$builder->select('status, COUNT(*) as cnt');
$builder->where('DATE(checkin_at)', $today);
$builder->groupBy('status');
$rows = $builder->get()->getResultArray();
$counts = [
'PRESENT' => 0,
'LATE' => 0,
'OUTSIDE_ZONE' => 0,
'NO_SCHEDULE' => 0,
'INVALID_DEVICE' => 0,
];
foreach ($rows as $row) {
if (isset($counts[$row['status']])) {
$counts[$row['status']] = (int) $row['cnt'];
}
}
return [
'total_students' => $totalStudents,
'present_today' => $counts['PRESENT'],
'late_today' => $counts['LATE'],
'outside_zone_today' => $counts['OUTSIDE_ZONE'],
'no_schedule_today' => $counts['NO_SCHEDULE'],
'invalid_device_today' => $counts['INVALID_DEVICE'],
];
}
/**
* Get last N attendance check-ins with student, class, subject, device info
*
* @param int $limit
* @return array<int, array{student_name: string, class_name: string, subject: string, checkin_at: string, status: string, device_code: string}>
*/
public function getRealtimeCheckins(int $limit = 20): array
{
$db = \Config\Database::connect();
$builder = $db->table('attendance_sessions AS a');
$builder->select('
s.name AS student_name,
c.name AS class_name,
COALESCE(sub.name, "-") AS subject,
a.checkin_at,
a.status,
d.device_code
');
$builder->join('students AS s', 's.id = a.student_id', 'left');
$builder->join('classes AS c', 'c.id = s.class_id', 'left');
$builder->join('schedules AS sch', 'sch.id = a.schedule_id', 'left');
$builder->join('subjects AS sub', 'sub.id = sch.subject_id', 'left');
$builder->join('devices AS d', 'd.id = a.device_id', 'left');
$builder->orderBy('a.checkin_at', 'DESC');
$builder->limit($limit);
$rows = $builder->get()->getResultArray();
$out = [];
foreach ($rows as $row) {
$out[] = [
'student_name' => (string) ($row['student_name'] ?? '-'),
'class_name' => (string) ($row['class_name'] ?? '-'),
'subject' => (string) ($row['subject'] ?? '-'),
'checkin_at' => (string) $row['checkin_at'],
'status' => (string) $row['status'],
'device_code' => (string) ($row['device_code'] ?? '-'),
];
}
return $out;
}
/**
* Get devices with online status (online if last_seen_at within threshold minutes)
*
* @return array<int, array{
* id: int,
* device_code: string,
* device_name: string,
* is_active: bool,
* last_seen_at: string|null,
* online_status: string,
* latitude: float|null,
* longitude: float|null,
* radius_meters: int|null
* }>
*/
public function getDevices(): array
{
$devices = $this->deviceModel->findAll();
$thresholdSeconds = $this->deviceOnlineThresholdMinutes * 60;
$now = time();
$out = [];
foreach ($devices as $d) {
$lastSeenAt = $d->last_seen_at;
$lastSeenStr = null;
$lastSeenTs = null;
if ($lastSeenAt !== null) {
$lastSeenStr = is_object($lastSeenAt) && method_exists($lastSeenAt, 'format')
? $lastSeenAt->format('Y-m-d H:i:s')
: (string) $lastSeenAt;
$lastSeenTs = strtotime($lastSeenStr);
}
$onlineStatus = 'offline';
if ($lastSeenTs !== null && ($now - $lastSeenTs) < $thresholdSeconds) {
$onlineStatus = 'online';
}
$out[] = [
'id' => (int) $d->id,
'device_code' => $d->device_code,
'device_name' => (string) ($d->device_name ?? ''),
'is_active' => (bool) $d->is_active,
'last_seen_at' => $lastSeenStr,
'online_status' => $onlineStatus,
'latitude' => $d->latitude !== null ? (float) $d->latitude : null,
'longitude' => $d->longitude !== null ? (float) $d->longitude : null,
'radius_meters' => $d->radius_meters !== null ? (int) $d->radius_meters : null,
];
}
return $out;
}
}