init backend presensi
This commit is contained in:
1
app/Modules/Dashboard/Controllers/.gitkeep
Normal file
1
app/Modules/Dashboard/Controllers/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Dashboard Module - Controllers
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Dashboard\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Auth\Services\AuthService;
|
||||
use App\Modules\Dashboard\Services\DashboardScheduleService;
|
||||
use App\Modules\Attendance\Entities\AttendanceSession;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Dashboard Attendance API (e.g. live progress for current lesson).
|
||||
*/
|
||||
class DashboardAttendanceController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* GET /api/dashboard/attendance/progress/current
|
||||
*
|
||||
* Returns live attendance progress for the current schedule (if any).
|
||||
* expected_total = students in class; present/late from attendance_sessions today; absent = expected - (present + late).
|
||||
*/
|
||||
public function progressCurrent(): ResponseInterface
|
||||
{
|
||||
$authService = new AuthService();
|
||||
$userContext = $authService->currentUser();
|
||||
$scheduleService = new DashboardScheduleService();
|
||||
|
||||
$current = $scheduleService->getCurrentSchedule($userContext);
|
||||
|
||||
if (empty($current['is_active_now']) || empty($current['schedule_id'])) {
|
||||
return $this->successResponse(['active' => false], 'No active schedule');
|
||||
}
|
||||
|
||||
$scheduleId = (int) $current['schedule_id'];
|
||||
$classId = (int) ($current['class_id'] ?? 0);
|
||||
$tz = new \DateTimeZone('Asia/Jakarta');
|
||||
$today = (new \DateTimeImmutable('now', $tz))->format('Y-m-d');
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
$expectedTotal = 0;
|
||||
if ($classId > 0) {
|
||||
$expectedTotal = $db->table('students')->where('class_id', $classId)->countAllResults();
|
||||
}
|
||||
|
||||
$presentTotal = $db->table('attendance_sessions')
|
||||
->where('schedule_id', $scheduleId)
|
||||
->where('attendance_date', $today)
|
||||
->where('status', AttendanceSession::STATUS_PRESENT)
|
||||
->countAllResults();
|
||||
|
||||
$lateTotal = $db->table('attendance_sessions')
|
||||
->where('schedule_id', $scheduleId)
|
||||
->where('attendance_date', $today)
|
||||
->where('status', AttendanceSession::STATUS_LATE)
|
||||
->countAllResults();
|
||||
|
||||
$absentTotal = $expectedTotal - ($presentTotal + $lateTotal);
|
||||
if ($absentTotal < 0) {
|
||||
$absentTotal = 0;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'active' => true,
|
||||
'subject_name' => (string) ($current['subject_name'] ?? '-'),
|
||||
'class_name' => (string) ($current['class_name'] ?? '-'),
|
||||
'expected_total' => $expectedTotal,
|
||||
'present_total' => $presentTotal,
|
||||
'late_total' => $lateTotal,
|
||||
'absent_total' => $absentTotal,
|
||||
];
|
||||
|
||||
return $this->successResponse($data, 'Attendance progress');
|
||||
}
|
||||
}
|
||||
55
app/Modules/Dashboard/Controllers/DashboardController.php
Normal file
55
app/Modules/Dashboard/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Dashboard\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Dashboard\Services\DashboardService;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Dashboard Controller
|
||||
*
|
||||
* API endpoints for monitoring attendance system.
|
||||
*/
|
||||
class DashboardController extends BaseApiController
|
||||
{
|
||||
protected DashboardService $dashboardService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->dashboardService = new DashboardService();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/dashboard/summary
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function summary(): ResponseInterface
|
||||
{
|
||||
$data = $this->dashboardService->getSummary();
|
||||
return $this->successResponse($data, 'Dashboard summary');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/dashboard/realtime
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function realtime(): ResponseInterface
|
||||
{
|
||||
$data = $this->dashboardService->getRealtimeCheckins(20);
|
||||
return $this->successResponse($data, 'Last 20 check-ins');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/dashboard/devices
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function devices(): ResponseInterface
|
||||
{
|
||||
$data = $this->dashboardService->getDevices();
|
||||
return $this->successResponse($data, 'Device monitoring');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Dashboard\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Auth\Services\AuthService;
|
||||
use App\Modules\Dashboard\Services\DashboardScheduleService;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Dashboard Schedule Controller
|
||||
*
|
||||
* API for today's schedules (role-filtered).
|
||||
*/
|
||||
class DashboardScheduleController extends BaseApiController
|
||||
{
|
||||
protected DashboardScheduleService $scheduleService;
|
||||
protected AuthService $authService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->scheduleService = new DashboardScheduleService();
|
||||
$this->authService = new AuthService();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/dashboard/schedules/today
|
||||
*
|
||||
* Returns today's schedules (day_of_week from Asia/Jakarta) filtered by current user role.
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function today(): ResponseInterface
|
||||
{
|
||||
$userContext = $this->authService->currentUser();
|
||||
$schedules = $this->scheduleService->getSchedulesToday($userContext);
|
||||
|
||||
return $this->successResponse($schedules, 'Today\'s schedules');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/dashboard/schedules/by-date?date=YYYY-MM-DD
|
||||
*
|
||||
* Schedules for the given date (day_of_week from date in Asia/Jakarta). Role-filtered.
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function byDate(): ResponseInterface
|
||||
{
|
||||
$date = $this->request->getGet('date');
|
||||
if ($date === null || $date === '') {
|
||||
return $this->errorResponse('Query parameter date is required (Y-m-d)', null, null, 422);
|
||||
}
|
||||
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date);
|
||||
if ($dt === false || $dt->format('Y-m-d') !== $date) {
|
||||
return $this->errorResponse('Invalid date format. Use Y-m-d.', null, null, 422);
|
||||
}
|
||||
$userContext = $this->authService->currentUser();
|
||||
$schedules = $this->scheduleService->getSchedulesByDate($date, $userContext);
|
||||
return $this->successResponse($schedules, 'Schedules for date');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/dashboard/schedules/current
|
||||
*
|
||||
* Current lesson (active now) or next upcoming today. Asia/Jakarta. Role-filtered.
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function current(): ResponseInterface
|
||||
{
|
||||
$userContext = $this->authService->currentUser();
|
||||
$data = $this->scheduleService->getCurrentSchedule($userContext);
|
||||
|
||||
return $this->successResponse($data, 'Current schedule');
|
||||
}
|
||||
}
|
||||
33
app/Modules/Dashboard/Controllers/HealthController.php
Normal file
33
app/Modules/Dashboard/Controllers/HealthController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Dashboard\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
|
||||
/**
|
||||
* Health Controller
|
||||
*
|
||||
* Provides health check endpoint for API monitoring.
|
||||
*/
|
||||
class HealthController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* Health check endpoint
|
||||
*
|
||||
* Returns API status and service information.
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$data = [
|
||||
'service' => 'SMAN1 Attendance API',
|
||||
'status' => 'ok',
|
||||
];
|
||||
|
||||
return $this->successResponse(
|
||||
$data,
|
||||
'API is running'
|
||||
);
|
||||
}
|
||||
}
|
||||
121
app/Modules/Dashboard/Controllers/PresenceSettingsController.php
Normal file
121
app/Modules/Dashboard/Controllers/PresenceSettingsController.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Dashboard\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Dashboard\Models\SchoolPresenceSettingsModel;
|
||||
use App\Modules\Geo\Models\ZoneModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* API: Pengaturan Presensi terpusat (zona sekolah + jam masuk/pulang).
|
||||
* GET /api/dashboard/presence-settings
|
||||
* PUT /api/dashboard/presence-settings
|
||||
*/
|
||||
class PresenceSettingsController extends BaseApiController
|
||||
{
|
||||
protected const ZONE_CODE = 'SMA1-SCHOOL';
|
||||
|
||||
/**
|
||||
* GET /api/dashboard/presence-settings
|
||||
* Returns zone (lat, lng, radius) + jam masuk/pulang.
|
||||
*/
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
$zoneModel = new ZoneModel();
|
||||
$settingsModel = new SchoolPresenceSettingsModel();
|
||||
|
||||
$zone = $zoneModel->findActiveByZoneCode(self::ZONE_CODE);
|
||||
if (!$zone) {
|
||||
$zones = $zoneModel->findAllActive();
|
||||
$zone = !empty($zones) ? $zones[0] : null;
|
||||
}
|
||||
|
||||
$zoneData = null;
|
||||
if ($zone) {
|
||||
$zoneData = [
|
||||
'zone_code' => $zone->zone_code,
|
||||
'zone_name' => $zone->zone_name,
|
||||
'latitude' => (float) $zone->latitude,
|
||||
'longitude' => (float) $zone->longitude,
|
||||
'radius_meters' => (int) $zone->radius_meters,
|
||||
];
|
||||
} else {
|
||||
$zoneData = [
|
||||
'zone_code' => self::ZONE_CODE,
|
||||
'zone_name' => 'Zona Sekolah',
|
||||
'latitude' => 0.0,
|
||||
'longitude' => 0.0,
|
||||
'radius_meters' => 150,
|
||||
];
|
||||
}
|
||||
|
||||
$times = $settingsModel->getSettings();
|
||||
|
||||
$data = [
|
||||
'zone' => $zoneData,
|
||||
'times' => $times,
|
||||
];
|
||||
|
||||
return $this->successResponse($data, 'Pengaturan presensi');
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/dashboard/presence-settings
|
||||
* Body: { zone: { latitude, longitude, radius_meters }, times: { time_masuk_start, time_masuk_end, time_pulang_start, time_pulang_end } }
|
||||
*/
|
||||
public function update(): ResponseInterface
|
||||
{
|
||||
$body = $this->request->getJSON(true) ?? [];
|
||||
$zoneModel = new ZoneModel();
|
||||
$settingsModel = new SchoolPresenceSettingsModel();
|
||||
|
||||
$zone = $zoneModel->findByZoneCode(self::ZONE_CODE);
|
||||
$zoneInput = $body['zone'] ?? [];
|
||||
|
||||
if (!empty($zoneInput)) {
|
||||
$lat = isset($zoneInput['latitude']) ? (float) $zoneInput['latitude'] : null;
|
||||
$lng = isset($zoneInput['longitude']) ? (float) $zoneInput['longitude'] : null;
|
||||
$radius = isset($zoneInput['radius_meters']) ? (int) $zoneInput['radius_meters'] : null;
|
||||
|
||||
if ($lat !== null && $lng !== null && $radius !== null && $radius > 0) {
|
||||
$zonePayload = [
|
||||
'latitude' => $lat,
|
||||
'longitude' => $lng,
|
||||
'radius_meters' => $radius,
|
||||
'zone_name' => $zoneInput['zone_name'] ?? 'Zona Sekolah',
|
||||
'is_active' => 1,
|
||||
];
|
||||
if ($zone) {
|
||||
$zoneModel->update($zone->id, $zonePayload);
|
||||
} else {
|
||||
$zonePayload['zone_code'] = self::ZONE_CODE;
|
||||
$zoneModel->insert($zonePayload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$timesInput = $body['times'] ?? [];
|
||||
if (!empty($timesInput)) {
|
||||
$settingsModel->saveSettings([
|
||||
'time_masuk_start' => $timesInput['time_masuk_start'] ?? null,
|
||||
'time_masuk_end' => $timesInput['time_masuk_end'] ?? null,
|
||||
'time_pulang_start' => $timesInput['time_pulang_start'] ?? null,
|
||||
'time_pulang_end' => $timesInput['time_pulang_end'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$z = $zoneModel->findActiveByZoneCode(self::ZONE_CODE);
|
||||
$data = [
|
||||
'zone' => $z ? [
|
||||
'zone_code' => $z->zone_code,
|
||||
'latitude' => (float) $z->latitude,
|
||||
'longitude' => (float) $z->longitude,
|
||||
'radius_meters' => (int) $z->radius_meters,
|
||||
] : null,
|
||||
'times' => $settingsModel->getSettings(),
|
||||
];
|
||||
|
||||
return $this->successResponse($data, 'Pengaturan presensi berhasil disimpan');
|
||||
}
|
||||
}
|
||||
108
app/Modules/Dashboard/Controllers/QrAttendanceController.php
Normal file
108
app/Modules/Dashboard/Controllers/QrAttendanceController.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Dashboard\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Academic\Models\ClassModel;
|
||||
use App\Modules\Academic\Models\ScheduleModel;
|
||||
use App\Modules\Academic\Models\SubjectModel;
|
||||
use App\Modules\Auth\Entities\Role;
|
||||
use App\Modules\Auth\Services\AuthService;
|
||||
use App\Modules\Attendance\Models\QrAttendanceTokenModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Generate QR token untuk absen mapel (guru tampilkan QR, siswa scan).
|
||||
* POST /api/dashboard/qr-attendance/generate
|
||||
* Body: { schedule_id: number }
|
||||
*/
|
||||
class QrAttendanceController extends BaseApiController
|
||||
{
|
||||
protected AuthService $authService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->authService = new AuthService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate token untuk schedule_id. Hanya guru mapel jadwal tersebut / admin / wali kelas.
|
||||
*/
|
||||
public function generate(): ResponseInterface
|
||||
{
|
||||
$user = $this->authService->currentUser();
|
||||
if (!$user) {
|
||||
return $this->errorResponse('Unauthorized', null, null, 401);
|
||||
}
|
||||
|
||||
$payload = $this->request->getJSON(true) ?? [];
|
||||
$scheduleId = (int) ($payload['schedule_id'] ?? 0);
|
||||
if ($scheduleId < 1) {
|
||||
return $this->errorResponse('schedule_id wajib diisi', null, null, 400);
|
||||
}
|
||||
|
||||
$scheduleModel = new ScheduleModel();
|
||||
$schedule = $scheduleModel->getScheduleWithSlot($scheduleId);
|
||||
if (!$schedule) {
|
||||
return $this->errorResponse('Jadwal tidak ditemukan', null, null, 404);
|
||||
}
|
||||
|
||||
if (!$this->canAccessSchedule($schedule, $user)) {
|
||||
return $this->errorResponse('Anda tidak punya akses untuk jadwal ini', null, null, 403);
|
||||
}
|
||||
|
||||
$qrModel = new QrAttendanceTokenModel();
|
||||
$token = $qrModel->generateForSchedule($scheduleId, (int) $user['id']);
|
||||
if (!$token) {
|
||||
return $this->errorResponse('Gagal generate token', null, null, 500);
|
||||
}
|
||||
|
||||
$subjectName = '-';
|
||||
$className = '-';
|
||||
if (!empty($schedule['subject_id'])) {
|
||||
$subject = (new SubjectModel())->find($schedule['subject_id']);
|
||||
$subjectName = $subject ? (string) $subject->name : '-';
|
||||
}
|
||||
if (!empty($schedule['class_id'])) {
|
||||
$class = (new ClassModel())->find($schedule['class_id']);
|
||||
$className = $class ? (trim($class->grade . ' ' . $class->major . ' ' . $class->name) ?: ('Kelas #' . $class->id)) : '-';
|
||||
}
|
||||
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime('+' . QrAttendanceTokenModel::VALID_MINUTES . ' minutes'));
|
||||
|
||||
$data = [
|
||||
'token' => $token,
|
||||
'expires_at' => $expiresAt,
|
||||
'schedule_id' => $scheduleId,
|
||||
'subject_name' => $subjectName,
|
||||
'class_name' => $className,
|
||||
'valid_minutes' => QrAttendanceTokenModel::VALID_MINUTES,
|
||||
];
|
||||
|
||||
return $this->successResponse($data, 'QR token berhasil dibuat. Tampilkan QR untuk siswa scan.');
|
||||
}
|
||||
|
||||
protected function canAccessSchedule(array $schedule, array $user): bool
|
||||
{
|
||||
$roleCodes = array_column($user['roles'] ?? [], 'role_code');
|
||||
if (in_array(Role::CODE_ADMIN, $roleCodes, true) || in_array(Role::CODE_GURU_BK, $roleCodes, true)) {
|
||||
return true;
|
||||
}
|
||||
$classId = (int) ($schedule['class_id'] ?? 0);
|
||||
if (in_array(Role::CODE_WALI_KELAS, $roleCodes, true)) {
|
||||
$row = \Config\Database::connect()
|
||||
->table('classes')
|
||||
->select('id')
|
||||
->where('id', $classId)
|
||||
->where('wali_user_id', $user['id'])
|
||||
->get()
|
||||
->getRow();
|
||||
return $row !== null;
|
||||
}
|
||||
if (in_array(Role::CODE_GURU_MAPEL, $roleCodes, true)) {
|
||||
$teacherUserId = (int) ($schedule['teacher_user_id'] ?? 0);
|
||||
return $teacherUserId === (int) $user['id'];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
105
app/Modules/Dashboard/Controllers/RealtimeStreamController.php
Normal file
105
app/Modules/Dashboard/Controllers/RealtimeStreamController.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Dashboard\Controllers;
|
||||
|
||||
use App\Core\BaseApiController;
|
||||
use App\Modules\Auth\Services\AuthService;
|
||||
use App\Modules\Dashboard\Services\DashboardRealtimeService;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Realtime Stream Controller
|
||||
*
|
||||
* Server-Sent Events (SSE) for live attendance updates.
|
||||
* Role-aware: stream content is filtered by current user's roles (ADMIN / WALI_KELAS / GURU_MAPEL / ORANG_TUA).
|
||||
*/
|
||||
class RealtimeStreamController extends BaseApiController
|
||||
{
|
||||
protected DashboardRealtimeService $realtimeService;
|
||||
protected AuthService $authService;
|
||||
|
||||
/** Stream interval in seconds */
|
||||
protected int $intervalSeconds = 2;
|
||||
|
||||
/** Max stream duration in seconds */
|
||||
protected int $timeoutSeconds = 60;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->realtimeService = new DashboardRealtimeService();
|
||||
$this->authService = new AuthService();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/dashboard/stream
|
||||
*
|
||||
* SSE stream: every 2s check for new attendance, send event; timeout after 60s.
|
||||
* Uses after_id for incremental feed. User context from session applied for role filtering.
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
ignore_user_abort(true);
|
||||
set_time_limit(0);
|
||||
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
|
||||
$this->response->setHeader('Content-Type', 'text/event-stream');
|
||||
$this->response->setHeader('Cache-Control', 'no-cache');
|
||||
$this->response->setHeader('Connection', 'keep-alive');
|
||||
$this->response->setHeader('X-Accel-Buffering', 'no'); // nginx
|
||||
$this->response->setBody('');
|
||||
|
||||
if (function_exists('apache_setenv')) {
|
||||
@apache_setenv('no-gzip', '1');
|
||||
}
|
||||
@ini_set('output_buffering', 'off');
|
||||
@ini_set('zlib.output_compression', false);
|
||||
|
||||
$this->response->sendHeaders();
|
||||
|
||||
$currentUser = $this->authService->currentUser();
|
||||
$startTime = time();
|
||||
$lastId = (int) ($this->request->getGet('after_id') ?? 0);
|
||||
|
||||
while (true) {
|
||||
if (time() - $startTime >= $this->timeoutSeconds) {
|
||||
$this->sendEvent('timeout', ['message' => 'Stream ended after ' . $this->timeoutSeconds . 's']);
|
||||
break;
|
||||
}
|
||||
|
||||
$rows = $this->realtimeService->getAttendanceSinceId($lastId, 50, $currentUser);
|
||||
|
||||
if (empty($rows)) {
|
||||
$this->sendEvent('heartbeat', ['ts' => gmdate('Y-m-d\TH:i:s\Z')]);
|
||||
} else {
|
||||
foreach ($rows as $row) {
|
||||
$this->sendEvent('attendance', $row);
|
||||
$lastId = max($lastId, $row['id']);
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
sleep($this->intervalSeconds);
|
||||
}
|
||||
|
||||
return $this->response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one SSE event (event name + data line)
|
||||
*
|
||||
* @param string $event Event name
|
||||
* @param array $data Data to send as JSON
|
||||
*/
|
||||
protected function sendEvent(string $event, array $data): void
|
||||
{
|
||||
echo 'event: ' . $event . "\n";
|
||||
echo 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
|
||||
flush();
|
||||
}
|
||||
}
|
||||
1
app/Modules/Dashboard/Entities/.gitkeep
Normal file
1
app/Modules/Dashboard/Entities/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Dashboard Module - Entities
|
||||
1
app/Modules/Dashboard/Models/.gitkeep
Normal file
1
app/Modules/Dashboard/Models/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Dashboard Module - Models
|
||||
81
app/Modules/Dashboard/Models/SchoolPresenceSettingsModel.php
Normal file
81
app/Modules/Dashboard/Models/SchoolPresenceSettingsModel.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Dashboard\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
/**
|
||||
* Pengaturan presensi terpusat: jam masuk & jam pulang sekolah.
|
||||
* Satu row saja (id=1).
|
||||
*/
|
||||
class SchoolPresenceSettingsModel extends Model
|
||||
{
|
||||
protected $table = 'school_presence_settings';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = 'array';
|
||||
protected $allowedFields = [
|
||||
'time_masuk_start',
|
||||
'time_masuk_end',
|
||||
'time_pulang_start',
|
||||
'time_pulang_end',
|
||||
];
|
||||
protected $useTimestamps = true;
|
||||
protected $dateFormat = 'datetime';
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
|
||||
protected $validationRules = [
|
||||
'time_masuk_start' => 'permit_empty|valid_date[H:i:s]',
|
||||
'time_masuk_end' => 'permit_empty|valid_date[H:i:s]',
|
||||
'time_pulang_start' => 'permit_empty|valid_date[H:i:s]',
|
||||
'time_pulang_end' => 'permit_empty|valid_date[H:i:s]',
|
||||
];
|
||||
|
||||
/** ID row default */
|
||||
public const DEFAULT_ID = 1;
|
||||
|
||||
/**
|
||||
* Ambil satu row pengaturan (id=1). Jika belum ada, return default.
|
||||
*/
|
||||
public function getSettings(): array
|
||||
{
|
||||
$row = $this->find(self::DEFAULT_ID);
|
||||
if ($row && is_object($row)) {
|
||||
$row = (array) $row;
|
||||
}
|
||||
if (empty($row)) {
|
||||
return [
|
||||
'time_masuk_start' => '06:30:00',
|
||||
'time_masuk_end' => '07:00:00',
|
||||
'time_pulang_start' => '14:00:00',
|
||||
'time_pulang_end' => '14:30:00',
|
||||
];
|
||||
}
|
||||
return [
|
||||
'time_masuk_start' => $row['time_masuk_start'] ?? '06:30:00',
|
||||
'time_masuk_end' => $row['time_masuk_end'] ?? '07:00:00',
|
||||
'time_pulang_start' => $row['time_pulang_start'] ?? '14:00:00',
|
||||
'time_pulang_end' => $row['time_pulang_end'] ?? '14:30:00',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan pengaturan (upsert id=1).
|
||||
*/
|
||||
public function saveSettings(array $data): bool
|
||||
{
|
||||
$row = $this->find(self::DEFAULT_ID);
|
||||
$payload = [
|
||||
'time_masuk_start' => $data['time_masuk_start'] ?? null,
|
||||
'time_masuk_end' => $data['time_masuk_end'] ?? null,
|
||||
'time_pulang_start' => $data['time_pulang_start'] ?? null,
|
||||
'time_pulang_end' => $data['time_pulang_end'] ?? null,
|
||||
];
|
||||
if ($row) {
|
||||
return $this->update(self::DEFAULT_ID, $payload);
|
||||
}
|
||||
$payload['id'] = self::DEFAULT_ID;
|
||||
return $this->insert($payload) !== false;
|
||||
}
|
||||
}
|
||||
28
app/Modules/Dashboard/Routes.php
Normal file
28
app/Modules/Dashboard/Routes.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Dashboard Module Routes
|
||||
*
|
||||
* This file is automatically loaded by ModuleLoader.
|
||||
* Define your dashboard routes here.
|
||||
*
|
||||
* @var RouteCollection $routes
|
||||
*/
|
||||
|
||||
// Health check endpoint
|
||||
$routes->get('api/health', '\App\Modules\Dashboard\Controllers\HealthController::index');
|
||||
|
||||
// Dashboard API endpoints
|
||||
$routes->group('api/dashboard', ['namespace' => 'App\Modules\Dashboard\Controllers'], function ($routes) {
|
||||
$routes->get('summary', 'DashboardController::summary');
|
||||
$routes->get('realtime', 'DashboardController::realtime');
|
||||
$routes->get('devices', 'DashboardController::devices');
|
||||
$routes->get('presence-settings', 'PresenceSettingsController::index');
|
||||
$routes->put('presence-settings', 'PresenceSettingsController::update');
|
||||
$routes->post('qr-attendance/generate', 'QrAttendanceController::generate');
|
||||
$routes->get('stream', 'RealtimeStreamController::index');
|
||||
$routes->get('schedules/today', 'DashboardScheduleController::today');
|
||||
$routes->get('schedules/by-date', 'DashboardScheduleController::byDate');
|
||||
$routes->get('schedules/current', 'DashboardScheduleController::current');
|
||||
$routes->get('attendance/progress/current', 'DashboardAttendanceController::progressCurrent');
|
||||
});
|
||||
1
app/Modules/Dashboard/Services/.gitkeep
Normal file
1
app/Modules/Dashboard/Services/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Dashboard Module - Services
|
||||
176
app/Modules/Dashboard/Services/DashboardRealtimeService.php
Normal file
176
app/Modules/Dashboard/Services/DashboardRealtimeService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
271
app/Modules/Dashboard/Services/DashboardScheduleService.php
Normal file
271
app/Modules/Dashboard/Services/DashboardScheduleService.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
171
app/Modules/Dashboard/Services/DashboardService.php
Normal file
171
app/Modules/Dashboard/Services/DashboardService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user