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 - Controllers

View File

@@ -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');
}
}

View 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');
}
}

View File

@@ -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');
}
}

View 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'
);
}
}

View 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');
}
}

View 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;
}
}

View 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();
}
}