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,158 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\ClassModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Class CRUD API (ADMIN only).
*/
class ClassController extends BaseApiController
{
/**
* GET /api/academic/classes
* Response: id, grade, major, name, wali_user_id, wali_name, full_label (e.g. "10 IPA A")
*/
public function index(): ResponseInterface
{
$db = \Config\Database::connect();
$rows = $db->table('classes')
->select('classes.id, classes.name, classes.grade, classes.major, classes.wali_user_id, users.name AS wali_user_name')
->join('users', 'users.id = classes.wali_user_id', 'left')
->orderBy('classes.grade', 'ASC')
->orderBy('classes.major', 'ASC')
->orderBy('classes.name', 'ASC')
->get()
->getResultArray();
$data = array_map(static function ($r) {
$grade = $r['grade'] !== null ? trim((string) $r['grade']) : '';
$major = $r['major'] !== null ? trim((string) $r['major']) : '';
$name = $r['name'] !== null ? trim((string) $r['name']) : '';
$parts = array_filter([$grade, $major, $name], static fn ($v) => $v !== '');
$fullLabel = implode(' ', $parts) !== '' ? implode(' ', $parts) : (string) $r['name'];
return [
'id' => (int) $r['id'],
'grade' => $grade,
'major' => $major,
'name' => $name,
'wali_user_id' => $r['wali_user_id'] !== null ? (int) $r['wali_user_id'] : null,
'wali_name' => $r['wali_user_name'] !== null ? (string) $r['wali_user_name'] : null,
'full_label' => $fullLabel,
];
}, $rows);
return $this->successResponse($data, 'Classes');
}
/**
* POST /api/academic/classes
* Requires: grade, major, name (rombel). Optional: wali_user_id.
*/
public function create(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$data = [
'name' => $payload['name'] ?? '',
'grade' => $payload['grade'] ?? '',
'major' => $payload['major'] ?? '',
'wali_user_id' => isset($payload['wali_user_id']) && $payload['wali_user_id'] !== '' && $payload['wali_user_id'] !== null
? (int) $payload['wali_user_id']
: null,
];
$model = new ClassModel();
if (!$model->validate($data)) {
return $this->errorResponse(
implode(' ', $model->errors()),
$model->errors(),
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
$id = $model->insert($data);
if ($id === false) {
return $this->errorResponse('Gagal menyimpan kelas', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
$row = $model->find($id);
$fullLabel = trim($row->grade . ' ' . $row->major . ' ' . $row->name) ?: (string) $row->name;
$out = [
'id' => (int) $row->id,
'grade' => (string) $row->grade,
'major' => (string) $row->major,
'name' => (string) $row->name,
'wali_user_id' => $row->wali_user_id !== null ? (int) $row->wali_user_id : null,
'full_label' => $fullLabel,
];
return $this->successResponse($out, 'Kelas berhasil ditambahkan', null, ResponseInterface::HTTP_CREATED);
}
/**
* PUT /api/academic/classes/{id}
*/
public function update(int $id): ResponseInterface
{
$model = new ClassModel();
$row = $model->find($id);
if (!$row) {
return $this->errorResponse('Kelas tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$payload = $this->request->getJSON(true) ?? [];
$data = [
'id' => $id,
'name' => $payload['name'] ?? $row->name,
'grade' => $payload['grade'] ?? $row->grade,
'major' => $payload['major'] ?? $row->major,
'wali_user_id' => isset($payload['wali_user_id']) && $payload['wali_user_id'] !== '' && $payload['wali_user_id'] !== null
? (int) $payload['wali_user_id']
: null,
];
if (!$model->validate($data)) {
return $this->errorResponse(
implode(' ', $model->errors()),
$model->errors(),
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
unset($data['id']);
if ($model->update($id, $data) === false) {
return $this->errorResponse('Gagal mengubah kelas', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
$updated = $model->find($id);
$fullLabel = trim($updated->grade . ' ' . $updated->major . ' ' . $updated->name) ?: (string) $updated->name;
$out = [
'id' => (int) $updated->id,
'grade' => (string) $updated->grade,
'major' => (string) $updated->major,
'name' => (string) $updated->name,
'wali_user_id' => $updated->wali_user_id !== null ? (int) $updated->wali_user_id : null,
'full_label' => $fullLabel,
];
return $this->successResponse($out, 'Kelas berhasil diubah');
}
/**
* DELETE /api/academic/classes/{id}
*/
public function delete(int $id): ResponseInterface
{
$model = new ClassModel();
if (!$model->find($id)) {
return $this->errorResponse('Kelas tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
if ($model->delete($id) === false) {
return $this->errorResponse('Gagal menghapus kelas', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->successResponse(null, 'Kelas berhasil dihapus');
}
}

View File

@@ -0,0 +1,185 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\DapodikSyncJobModel;
use App\Modules\Academic\Models\DapodikRombelMappingModel;
use App\Modules\Academic\Services\DapodikSyncService;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Dapodik sync and rombel mapping API (ADMIN only).
*/
class DapodikSyncController extends BaseApiController
{
/**
* POST /api/academic/dapodik/sync/students
* Starts sync job, returns job_id immediately, processes in background (same request after response sent).
* If a students-sync job is already running, returns existing job_id (job locking).
* Body/Query: limit, max_pages (optional).
*/
public function syncStudents(): ResponseInterface
{
$body = $this->request->getJSON(true) ?? [];
$limit = (int) ($body['limit'] ?? $this->request->getGet('limit') ?? 100);
$maxPages = (int) ($body['max_pages'] ?? $this->request->getGet('max_pages') ?? 50);
$limit = max(1, min(500, $limit));
$maxPages = max(1, min(100, $maxPages));
$jobModel = new DapodikSyncJobModel();
// Job locking: if there is already a running students-sync job, reuse it.
$existing = $jobModel
->where('type', DapodikSyncJobModel::TYPE_STUDENTS)
->where('status', DapodikSyncJobModel::STATUS_RUNNING)
->orderBy('started_at', 'DESC')
->first();
if ($existing) {
return $this->successResponse([
'job_id' => (int) $existing['id'],
'total_rows' => (int) ($existing['total_rows'] ?? 0),
'status' => $existing['status'],
], 'Sync already running');
}
$service = new DapodikSyncService();
$estimate = $service->estimateTotalStudents($limit);
$totalRows = $estimate['total'];
$now = date('Y-m-d H:i:s');
$jobId = $jobModel->insert([
'type' => DapodikSyncJobModel::TYPE_STUDENTS,
'total_rows' => $totalRows,
'processed_rows'=> 0,
'status' => DapodikSyncJobModel::STATUS_RUNNING,
'message' => null,
'started_at' => $now,
'finished_at' => null,
]);
$payload = [
'job_id' => (int) $jobId,
'total_rows' => $totalRows,
'status' => 'running',
];
$this->successResponse($payload, 'Sync started')->send();
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
ignore_user_abort(true);
set_time_limit(0);
$service->syncStudentsWithJob((int) $jobId, $limit, $maxPages);
exit;
}
/**
* GET /api/academic/dapodik/sync/status/{job_id}
* Returns job progress for polling.
*/
public function status(int $jobId): ResponseInterface
{
$jobModel = new DapodikSyncJobModel();
$job = $jobModel->find($jobId);
if (! $job) {
return $this->errorResponse('Job not found', null, null, 404);
}
$total = (int) ($job['total_rows'] ?? 0);
$processed = (int) ($job['processed_rows'] ?? 0);
$percent = $total > 0 ? min(100, (int) round(($processed / $total) * 100)) : ($processed > 0 ? 1 : 0);
return $this->successResponse([
'total_rows' => $total,
'processed_rows' => $processed,
'percent' => $percent,
'status' => $job['status'],
'message' => $job['message'] ?? null,
]);
}
/**
* GET /api/academic/dapodik/rombels
* Query: unmapped_only=1 to filter.
*/
public function rombels(): ResponseInterface
{
$unmappedOnly = $this->request->getGet('unmapped_only') === '1' || $this->request->getGet('unmapped_only') === 'true';
$db = \Config\Database::connect();
$builder = $db->table('dapodik_rombel_mappings AS m')
->select('m.id, m.dapodik_rombel, m.class_id, m.last_seen_at, m.updated_at, c.grade, c.major, c.name AS class_name')
->join('classes AS c', 'c.id = m.class_id', 'left')
->orderBy('m.dapodik_rombel', 'ASC');
if ($unmappedOnly) {
$builder->where('m.class_id', null);
}
$rows = $builder->get()->getResultArray();
$data = array_map(static function ($r) {
$classLabel = null;
if ($r['class_id'] !== null && isset($r['grade'])) {
$classLabel = trim(($r['grade'] ?? '') . ' ' . ($r['major'] ?? '') . ' ' . ($r['class_name'] ?? ''));
}
return [
'id' => (int) $r['id'],
'dapodik_rombel' => (string) $r['dapodik_rombel'],
'class_id' => $r['class_id'] !== null ? (int) $r['class_id'] : null,
'class_label' => $classLabel,
'last_seen_at' => $r['last_seen_at'],
'updated_at' => $r['updated_at'],
];
}, $rows);
return $this->successResponse($data, 'Rombel mappings');
}
/**
* PUT /api/academic/dapodik/rombels/{id}
* Body: class_id (nullable)
*/
public function updateRombel(int $id): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? $this->request->getPost();
$classId = null;
if (isset($payload['class_id']) && $payload['class_id'] !== '' && $payload['class_id'] !== null) {
$classId = (int) $payload['class_id'];
$db = \Config\Database::connect();
$exists = $db->table('classes')->where('id', $classId)->countAllResults();
if ($exists < 1) {
return $this->errorResponse('class_id must exist in classes', null, null, 422);
}
}
$model = new DapodikRombelMappingModel();
$row = $model->find($id);
if (! $row) {
return $this->errorResponse('Mapping not found', null, null, 404);
}
$model->update($id, ['class_id' => $classId]);
$updated = $model->find($id);
$classLabel = null;
if ($updated['class_id']) {
$db = \Config\Database::connect();
$c = $db->table('classes')->select('grade, major, name')->where('id', $updated['class_id'])->get()->getRowArray();
if ($c) {
$classLabel = trim(($c['grade'] ?? '') . ' ' . ($c['major'] ?? '') . ' ' . ($c['name'] ?? ''));
}
}
return $this->successResponse([
'id' => (int) $updated['id'],
'dapodik_rombel' => (string) $updated['dapodik_rombel'],
'class_id' => $updated['class_id'] !== null ? (int) $updated['class_id'] : null,
'class_label' => $classLabel,
], 'Mapping updated');
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\LessonSlotModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Lesson Slot CRUD API (ADMIN only).
*/
class LessonSlotController extends BaseApiController
{
protected LessonSlotModel $model;
public function __construct()
{
$this->model = new LessonSlotModel();
}
/**
* GET /api/academic/lesson-slots
*/
public function index(): ResponseInterface
{
$slots = $this->model->orderBy('slot_number', 'ASC')->findAll();
$data = array_map([$this, 'slotToArray'], $slots);
return $this->successResponse($data, 'Lesson slots');
}
/**
* POST /api/academic/lesson-slots
*/
public function create(): ResponseInterface
{
$input = $this->request->getJSON(true) ?? [];
$data = [
'slot_number' => (int) ($input['slot_number'] ?? 0),
'start_time' => (string) ($input['start_time'] ?? ''),
'end_time' => (string) ($input['end_time'] ?? ''),
'is_active' => isset($input['is_active']) ? (int) (bool) $input['is_active'] : 1,
];
if (!$this->model->validateStartBeforeEnd($data)) {
return $this->errorResponse('End time must be after start time', null, null, 422);
}
$id = $this->model->insert($data);
if ($id === false) {
$msg = $this->model->errors()['end_time'] ?? implode(' ', $this->model->errors());
return $this->errorResponse($msg ?: 'Validation failed', null, null, 422);
}
$row = $this->model->find($id);
return $this->successResponse($this->slotToArray($row), 'Lesson slot created', null, 201);
}
/**
* PUT /api/academic/lesson-slots/{id}
*/
public function update($id): ResponseInterface
{
$id = (int) $id;
$slot = $this->model->find($id);
if (!$slot) {
return $this->errorResponse('Lesson slot not found', null, null, 404);
}
$input = $this->request->getJSON(true) ?? [];
$data = [];
if (array_key_exists('slot_number', $input)) {
$data['slot_number'] = (int) $input['slot_number'];
}
if (array_key_exists('start_time', $input)) {
$data['start_time'] = (string) $input['start_time'];
}
if (array_key_exists('end_time', $input)) {
$data['end_time'] = (string) $input['end_time'];
}
if (array_key_exists('is_active', $input)) {
$data['is_active'] = (int) (bool) $input['is_active'];
}
if ($data !== [] && !$this->model->validateStartBeforeEnd(array_merge(
['start_time' => $data['start_time'] ?? $slot->start_time, 'end_time' => $data['end_time'] ?? $slot->end_time]
))) {
return $this->errorResponse('End time must be after start time', null, null, 422);
}
$this->model->update($id, $data);
if ($this->model->errors()) {
return $this->errorResponse(implode(' ', $this->model->errors()), null, null, 422);
}
$row = $this->model->find($id);
return $this->successResponse($this->slotToArray($row), 'Lesson slot updated');
}
/**
* DELETE /api/academic/lesson-slots/{id}
*/
public function delete($id): ResponseInterface
{
$id = (int) $id;
if (!$this->model->find($id)) {
return $this->errorResponse('Lesson slot not found', null, null, 404);
}
$this->model->delete($id);
return $this->successResponse(null, 'Lesson slot deleted');
}
protected function slotToArray($slot): array
{
if (is_array($slot)) {
return [
'id' => (int) ($slot['id'] ?? 0),
'slot_number' => (int) ($slot['slot_number'] ?? 0),
'start_time' => (string) ($slot['start_time'] ?? ''),
'end_time' => (string) ($slot['end_time'] ?? ''),
];
}
return [
'id' => (int) $slot->id,
'slot_number' => (int) $slot->slot_number,
'start_time' => (string) $slot->start_time,
'end_time' => (string) $slot->end_time,
];
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\LessonSlotModel;
use App\Modules\Academic\Models\ScheduleModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Weekly Schedule Management API (ADMIN only).
*/
class ScheduleManagementController extends BaseApiController
{
protected ScheduleModel $scheduleModel;
protected LessonSlotModel $lessonSlotModel;
public function __construct()
{
$this->scheduleModel = new ScheduleModel();
$this->lessonSlotModel = new LessonSlotModel();
}
/**
* GET /api/academic/schedules/class/{classId}
* Returns weekly schedule grid: slots with days 15, each cell schedule or null.
*/
public function getByClass($classId): ResponseInterface
{
$classId = (int) $classId;
$slots = $this->lessonSlotModel->where('is_active', 1)->orderBy('slot_number', 'ASC')->findAll();
$rows = $this->scheduleModel->where('class_id', $classId)->findAll();
$bySlotDay = [];
foreach ($rows as $row) {
$slotId = $row->lesson_slot_id ?? 0;
$day = (int) $row->day_of_week;
if ($slotId && $day >= 1 && $day <= 7) {
$bySlotDay[$slotId][$day] = $this->scheduleToArray($row);
}
}
$grid = [];
foreach ($slots as $slot) {
$slotId = (int) $slot->id;
$days = [];
for ($d = 1; $d <= 5; $d++) {
$days[$d] = $bySlotDay[$slotId][$d] ?? null;
}
$grid[] = [
'lesson_slot_id' => $slotId,
'slot_number' => (int) $slot->slot_number,
'start_time' => (string) $slot->start_time,
'end_time' => (string) $slot->end_time,
'days' => $days,
];
}
return $this->successResponse($grid, 'Weekly schedule');
}
/**
* POST /api/academic/schedules/bulk-save
* Body: { class_id, schedules: [ { day_of_week, lesson_slot_id, subject_id, teacher_user_id [, room] }, ... ] }
* Deletes existing schedules for class then inserts new ones in a transaction.
*/
public function bulkSave(): ResponseInterface
{
$input = $this->request->getJSON(true) ?? [];
$classId = (int) ($input['class_id'] ?? 0);
$items = $input['schedules'] ?? [];
if ($classId <= 0) {
return $this->errorResponse('class_id is required and must be positive', null, null, 422);
}
if (!is_array($items)) {
return $this->errorResponse('schedules must be an array', null, null, 422);
}
$db = \Config\Database::connect();
$db->transStart();
try {
$this->scheduleModel->where('class_id', $classId)->delete();
$this->scheduleModel->skipValidation(true);
foreach ($items as $item) {
$day = (int) ($item['day_of_week'] ?? 0);
$slot = (int) ($item['lesson_slot_id'] ?? 0);
$subj = (int) ($item['subject_id'] ?? 0);
$teacher = (int) ($item['teacher_user_id'] ?? 0);
if ($day < 1 || $day > 7 || $slot <= 0 || $subj <= 0) {
$db->transRollback();
return $this->errorResponse('Each schedule must have day_of_week (1-7), lesson_slot_id, subject_id', null, null, 422);
}
$data = [
'class_id' => $classId,
'subject_id' => $subj,
'teacher_user_id' => $teacher ?: null,
'lesson_slot_id' => $slot,
'day_of_week' => $day,
'room' => isset($item['room']) ? (string) $item['room'] : null,
'is_active' => 1,
];
if ($this->scheduleModel->insert($data) === false) {
$db->transRollback();
return $this->errorResponse('Failed to insert schedule', null, null, 422);
}
}
$this->scheduleModel->skipValidation(false);
$db->transComplete();
} catch (\Throwable $e) {
if ($db->transStatus() === false) {
$db->transRollback();
}
return $this->errorResponse($e->getMessage(), null, null, 500);
}
if ($db->transStatus() === false) {
return $this->errorResponse('Database transaction failed', null, null, 500);
}
return $this->successResponse(null, 'Schedules saved');
}
protected function scheduleToArray($schedule): array
{
if (is_array($schedule)) {
return [
'id' => (int) ($schedule['id'] ?? 0),
'class_id' => (int) ($schedule['class_id'] ?? 0),
'subject_id' => (int) ($schedule['subject_id'] ?? 0),
'teacher_user_id' => isset($schedule['teacher_user_id']) ? (int) $schedule['teacher_user_id'] : null,
'lesson_slot_id' => (int) ($schedule['lesson_slot_id'] ?? 0),
'day_of_week' => (int) ($schedule['day_of_week'] ?? 0),
'room' => isset($schedule['room']) ? (string) $schedule['room'] : null,
];
}
return [
'id' => (int) $schedule->id,
'class_id' => (int) $schedule->class_id,
'subject_id' => (int) $schedule->subject_id,
'teacher_user_id' => $schedule->teacher_user_id !== null ? (int) $schedule->teacher_user_id : null,
'lesson_slot_id' => (int) $schedule->lesson_slot_id,
'day_of_week' => (int) $schedule->day_of_week,
'room' => $schedule->room !== null ? (string) $schedule->room : null,
];
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\StudentModel;
use App\Modules\Academic\Models\ClassModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Student CRUD API (ADMIN only).
*/
class StudentController extends BaseApiController
{
/**
* GET /api/academic/students
* Query: class_id (optional), search (optional), unmapped_only (optional), page (default 1), per_page (default 25).
*/
public function index(): ResponseInterface
{
$classId = $this->request->getGet('class_id');
$search = $this->request->getGet('search');
$search = is_string($search) ? trim($search) : '';
$unmappedOnly = $this->request->getGet('unmapped_only') === '1' || $this->request->getGet('unmapped_only') === 'true';
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = max(5, min(100, (int) ($this->request->getGet('per_page') ?? 20)));
$db = \Config\Database::connect();
$builder = $db->table('students')
->select('students.id, students.nisn, students.name, students.gender, students.class_id, students.is_active,
classes.grade, classes.major, classes.name AS class_name')
->join('classes', 'classes.id = students.class_id', 'left')
->orderBy('students.name', 'ASC');
if ($unmappedOnly) {
$builder->where('students.class_id', null);
} elseif ($classId !== null && $classId !== '') {
$builder->where('students.class_id', (int) $classId);
}
if ($search !== '') {
$builder->groupStart()
->like('students.name', $search)
->orLike('students.nisn', $search)
->groupEnd();
}
$total = $builder->countAllResults(false);
$rows = $builder->limit($perPage, ($page - 1) * $perPage)->get()->getResultArray();
$data = array_map(static function ($r) {
$grade = $r['grade'] !== null ? trim((string) $r['grade']) : '';
$major = $r['major'] !== null ? trim((string) $r['major']) : '';
$cName = $r['class_name'] !== null ? trim((string) $r['class_name']) : '';
$parts = array_filter([$grade, $major, $cName], static fn ($v) => $v !== '');
$fullLabel = $parts !== [] ? implode(' ', $parts) : null;
return [
'id' => (int) $r['id'],
'nisn' => (string) $r['nisn'],
'name' => (string) $r['name'],
'gender' => $r['gender'] !== null ? (string) $r['gender'] : null,
'class_id' => $r['class_id'] !== null ? (int) $r['class_id'] : null,
'class_label' => $fullLabel,
'is_active' => (int) ($r['is_active'] ?? 1),
];
}, $rows);
$totalPages = $total > 0 ? (int) ceil($total / $perPage) : 0;
$meta = [
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'total_pages' => $totalPages,
];
return $this->successResponse($data, 'Students', $meta);
}
/**
* POST /api/academic/students
*/
public function create(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$data = $this->payloadToStudentData($payload);
$model = new StudentModel();
if (! $model->validate($data)) {
return $this->errorResponse(
implode(' ', $model->errors()),
$model->errors(),
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
$id = $model->insert($data);
if ($id === false) {
return $this->errorResponse('Gagal menyimpan siswa', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
$row = $model->find($id);
return $this->successResponse($this->rowToResponse($row), 'Siswa berhasil ditambahkan', null, ResponseInterface::HTTP_CREATED);
}
/**
* PUT /api/academic/students/{id}
*/
public function update(int $id): ResponseInterface
{
$model = new StudentModel();
$row = $model->find($id);
if (! $row) {
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$payload = $this->request->getJSON(true) ?? [];
$data = $this->payloadToStudentData($payload, $row);
if (! $model->validate(array_merge(['id' => $id], $data))) {
return $this->errorResponse(
implode(' ', $model->errors()),
$model->errors(),
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
if ($model->update($id, $data) === false) {
return $this->errorResponse('Gagal mengubah siswa', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
$updated = $model->find($id);
return $this->successResponse($this->rowToResponse($updated), 'Siswa berhasil diubah');
}
/**
* DELETE /api/academic/students/{id}
*/
public function delete(int $id): ResponseInterface
{
$model = new StudentModel();
if (! $model->find($id)) {
return $this->errorResponse('Siswa tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
if ($model->delete($id) === false) {
return $this->errorResponse('Gagal menghapus siswa', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->successResponse(null, 'Siswa berhasil dihapus');
}
private function payloadToStudentData(array $payload, $existing = null): array
{
$classId = isset($payload['class_id']) && $payload['class_id'] !== '' && $payload['class_id'] !== null
? (int) $payload['class_id']
: null;
$gender = isset($payload['gender']) && in_array($payload['gender'], ['L', 'P'], true)
? $payload['gender']
: ($existing && isset($existing->gender) ? $existing->gender : null);
$isActive = array_key_exists('is_active', $payload)
? (int) (bool) $payload['is_active']
: ($existing && isset($existing->is_active) ? (int) $existing->is_active : 1);
return [
'nisn' => trim($payload['nisn'] ?? $existing->nisn ?? ''),
'name' => trim($payload['name'] ?? $existing->name ?? ''),
'gender' => $gender,
'class_id' => $classId,
'is_active'=> $isActive,
];
}
private function rowToResponse($row): array
{
$classId = $row->class_id ?? null;
$classLabel = null;
if ($classId !== null) {
$classModel = new ClassModel();
$c = $classModel->find($classId);
if ($c) {
$classLabel = trim($c->grade . ' ' . $c->major . ' ' . $c->name) ?: (string) $c->name;
}
}
return [
'id' => (int) $row->id,
'nisn' => (string) $row->nisn,
'name' => (string) $row->name,
'gender' => $row->gender !== null ? (string) $row->gender : null,
'class_id' => $classId !== null ? (int) $classId : null,
'class_label' => $classLabel,
'is_active' => (int) ($row->is_active ?? 1),
];
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\SubjectModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Subject CRUD API (ADMIN only).
*/
class SubjectController extends BaseApiController
{
/**
* GET /api/academic/subjects
*/
public function index(): ResponseInterface
{
$model = new SubjectModel();
$rows = $model->orderBy('name', 'ASC')->findAll();
$data = array_map(static function ($s) {
return [
'id' => (int) $s->id,
'name' => (string) $s->name,
'code' => $s->code !== null ? (string) $s->code : null,
];
}, $rows);
return $this->successResponse($data, 'Subjects');
}
/**
* POST /api/academic/subjects
*/
public function create(): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$data = [
'name' => trim($payload['name'] ?? ''),
'code' => isset($payload['code']) && $payload['code'] !== '' ? trim($payload['code']) : null,
];
$model = new SubjectModel();
if (!$model->validate($data)) {
return $this->errorResponse(
implode(' ', $model->errors()),
$model->errors(),
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
$id = $model->insert($data);
if ($id === false) {
return $this->errorResponse('Gagal menyimpan mata pelajaran', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
$row = $model->find($id);
$out = [
'id' => (int) $row->id,
'name' => (string) $row->name,
'code' => $row->code !== null ? (string) $row->code : null,
];
return $this->successResponse($out, 'Mata pelajaran berhasil ditambahkan', null, ResponseInterface::HTTP_CREATED);
}
/**
* PUT /api/academic/subjects/{id}
*/
public function update(int $id): ResponseInterface
{
$model = new SubjectModel();
$row = $model->find($id);
if (!$row) {
return $this->errorResponse('Mata pelajaran tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
$payload = $this->request->getJSON(true) ?? [];
$data = [
'id' => $id,
'name' => trim($payload['name'] ?? $row->name),
'code' => isset($payload['code']) && $payload['code'] !== '' ? trim($payload['code']) : null,
];
if (!$model->validate($data)) {
return $this->errorResponse(
implode(' ', $model->errors()),
$model->errors(),
null,
ResponseInterface::HTTP_UNPROCESSABLE_ENTITY
);
}
unset($data['id']);
if ($model->update($id, $data) === false) {
return $this->errorResponse('Gagal mengubah mata pelajaran', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
$updated = $model->find($id);
$out = [
'id' => (int) $updated->id,
'name' => (string) $updated->name,
'code' => $updated->code !== null ? (string) $updated->code : null,
];
return $this->successResponse($out, 'Mata pelajaran berhasil diubah');
}
/**
* DELETE /api/academic/subjects/{id}
*/
public function delete(int $id): ResponseInterface
{
$model = new SubjectModel();
if (!$model->find($id)) {
return $this->errorResponse('Mata pelajaran tidak ditemukan', null, null, ResponseInterface::HTTP_NOT_FOUND);
}
if ($model->delete($id) === false) {
return $this->errorResponse('Gagal menghapus mata pelajaran', null, null, ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->successResponse(null, 'Mata pelajaran berhasil dihapus');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Auth\Models\RoleModel;
use App\Modules\Auth\Models\UserModel;
use App\Modules\Auth\Models\UserRoleModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Teachers list API (ADMIN only). Returns users with role GURU_MAPEL or WALI_KELAS.
*/
class TeacherController extends BaseApiController
{
/**
* GET /api/academic/teachers
*/
public function index(): ResponseInterface
{
$roleModel = new RoleModel();
$waliRole = $roleModel->findByCode('WALI_KELAS');
$guruRole = $roleModel->findByCode('GURU_MAPEL');
if (!$waliRole || !$guruRole) {
return $this->successResponse([], 'Teachers');
}
$db = \Config\Database::connect();
$userIdList = $db->table('user_roles')
->select('user_id')
->whereIn('role_id', [$waliRole->id, $guruRole->id])
->get()
->getResultArray();
$userIdList = array_values(array_unique(array_map(static fn ($r) => (int) $r['user_id'], $userIdList)));
if ($userIdList === []) {
return $this->successResponse([], 'Teachers');
}
$userModel = new UserModel();
$userRoleModel = new UserRoleModel();
$users = $userModel->whereIn('id', $userIdList)->findAll();
$data = [];
foreach ($users as $u) {
$roleIds = $userRoleModel->getRoleIdsForUser((int) $u->id);
$roles = [];
foreach ($roleIds as $rid) {
$r = $roleModel->find($rid);
if ($r && in_array($r->role_code, ['GURU_MAPEL', 'WALI_KELAS'], true)) {
$roles[] = ['role_code' => $r->role_code, 'role_name' => $r->role_name];
}
}
$data[] = [
'id' => (int) $u->id,
'name' => (string) $u->name,
'email' => (string) $u->email,
'roles' => $roles,
];
}
usort($data, static fn ($a, $b) => strcasecmp($a['name'], $b['name']));
return $this->successResponse($data, 'Teachers');
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Modules\Academic\Controllers;
use App\Core\BaseApiController;
use App\Modules\Academic\Models\TeacherSubjectModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Assign mapel per guru (ADMIN only).
*/
class TeacherSubjectController extends BaseApiController
{
/**
* GET /api/academic/teacher-subjects/{teacherId}
* Return: { teacher_user_id, subject_ids: [...] }
*/
public function getByTeacher(int $teacherId): ResponseInterface
{
$model = new TeacherSubjectModel();
$ids = $model->getSubjectIdsForTeacher($teacherId);
return $this->successResponse([
'teacher_user_id' => $teacherId,
'subject_ids' => $ids,
], 'Teacher subjects');
}
/**
* PUT /api/academic/teacher-subjects/{teacherId}
* Body: { subject_ids: [1,2,3] }
*/
public function updateForTeacher(int $teacherId): ResponseInterface
{
$payload = $this->request->getJSON(true) ?? [];
$ids = $payload['subject_ids'] ?? [];
if (! is_array($ids)) {
return $this->errorResponse('subject_ids must be an array', null, null, 422);
}
$cleanIds = [];
foreach ($ids as $id) {
$id = (int) $id;
if ($id > 0) {
$cleanIds[$id] = true;
}
}
$subjectIds = array_keys($cleanIds);
$model = new TeacherSubjectModel();
$db = \Config\Database::connect();
$db->transStart();
try {
$model->where('teacher_user_id', $teacherId)->delete();
foreach ($subjectIds as $sid) {
$model->insert([
'teacher_user_id' => $teacherId,
'subject_id' => $sid,
]);
}
$db->transComplete();
} catch (\Throwable $e) {
$db->transRollback();
return $this->errorResponse($e->getMessage(), null, null, 500);
}
if ($db->transStatus() === false) {
return $this->errorResponse('Database transaction failed', null, null, 500);
}
return $this->successResponse([
'teacher_user_id' => $teacherId,
'subject_ids' => $subjectIds,
], 'Teacher subjects updated');
}
/**
* GET /api/academic/teacher-subjects/map
* Return: { [subject_id]: [teacher_user_id, ...], ... }
*/
public function map(): ResponseInterface
{
$model = new TeacherSubjectModel();
$map = $model->getMapSubjectToTeacherIds();
return $this->successResponse($map, 'Subject to teacher map');
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Modules\Academic\Entities;
use CodeIgniter\Entity\Entity;
/**
* Class Entity
*
* Represents a class/kelas in the system.
*/
class ClassEntity extends Entity
{
/**
* Attributes that can be mass assigned
*
* @var array<string>
*/
protected $allowedFields = [
'name',
'grade',
'major',
'wali_user_id',
];
/**
* Attributes that should be cast to specific types
*
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'wali_user_id' => '?integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Modules\Academic\Entities;
use CodeIgniter\Entity\Entity;
/**
* Lesson Slot Entity
*/
class LessonSlot extends Entity
{
protected $allowedFields = [
'slot_number',
'start_time',
'end_time',
'is_active',
];
protected $casts = [
'id' => 'integer',
'slot_number' => 'integer',
'is_active' => 'boolean',
];
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Modules\Academic\Entities;
use CodeIgniter\Entity\Entity;
/**
* Schedule Entity
*
* Represents a class schedule in the system.
*/
class Schedule extends Entity
{
/**
* Attributes that can be mass assigned
*
* @var array<string>
*/
protected $allowedFields = [
'class_id',
'subject_id',
'teacher_user_id',
'teacher_name',
'lesson_slot_id',
'day_of_week',
'start_time',
'end_time',
'room',
'is_active',
];
/**
* Attributes that should be cast to specific types
*
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'class_id' => 'integer',
'subject_id' => 'integer',
'teacher_user_id' => 'integer',
'lesson_slot_id' => 'integer',
'day_of_week' => 'integer',
'is_active' => 'boolean',
'start_time' => 'string',
'end_time' => 'string',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Modules\Academic\Entities;
use CodeIgniter\Entity\Entity;
/**
* Student Entity
*
* Represents a student in the system.
*/
class Student extends Entity
{
/**
* Attributes that can be mass assigned
*
* @var array<string>
*/
protected $allowedFields = [
'nisn',
'name',
'gender',
'class_id',
'is_active',
'face_external_id',
'face_hash',
'dapodik_id',
'parent_link_code',
];
/**
* Attributes that should be cast to specific types
*
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'class_id' => '?integer',
'is_active' => 'integer',
'face_hash' => '?string',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Modules\Academic\Entities;
use CodeIgniter\Entity\Entity;
/**
* Subject Entity
*
* Represents a subject/mata pelajaran in the system.
*/
class Subject extends Entity
{
/**
* Attributes that can be mass assigned
*
* @var array<string>
*/
protected $allowedFields = [
'name',
'code',
];
/**
* Attributes that should be cast to specific types
*
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Modules\Academic\Models;
use App\Modules\Academic\Entities\ClassEntity;
use CodeIgniter\Model;
/**
* Class Model
*
* Handles database operations for classes.
*/
class ClassModel extends Model
{
protected $table = 'classes';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = ClassEntity::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'name',
'grade',
'major',
'wali_user_id',
];
// Dates
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
// Validation
protected $validationRules = [
'name' => 'required|max_length[100]',
'grade' => 'required|max_length[50]',
'major' => 'required|max_length[50]',
'wali_user_id' => 'permit_empty|integer|is_not_unique[users.id]',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Modules\Academic\Models;
use CodeIgniter\Model;
/**
* Dapodik rombel string -> internal class_id mapping.
*/
class DapodikRombelMappingModel extends Model
{
protected $table = 'dapodik_rombel_mappings';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $allowedFields = [
'dapodik_rombel',
'class_id',
'last_seen_at',
];
public function getByRombel(string $rombel): ?array
{
$row = $this->where('dapodik_rombel', $rombel)->first();
return $row !== null ? $row : null;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Modules\Academic\Models;
use CodeIgniter\Model;
/**
* Dapodik sync job tracker for progress reporting.
*/
class DapodikSyncJobModel extends Model
{
protected $table = 'dapodik_sync_jobs';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $allowedFields = [
'type',
'total_rows',
'processed_rows',
'status',
'message',
'started_at',
'finished_at',
];
public const STATUS_PENDING = 'pending';
public const STATUS_RUNNING = 'running';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const TYPE_STUDENTS = 'students';
public const TYPE_CLASSES = 'classes';
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Modules\Academic\Models;
use App\Modules\Academic\Entities\LessonSlot;
use CodeIgniter\Model;
/**
* Lesson Slot Model
*/
class LessonSlotModel extends Model
{
protected $table = 'lesson_slots';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = LessonSlot::class;
protected $allowedFields = [
'slot_number',
'start_time',
'end_time',
'is_active',
];
protected $useTimestamps = false;
protected $validationRules = [
'slot_number' => 'required|integer|greater_than[0]|is_unique[lesson_slots.slot_number,id,{id}]',
'start_time' => 'required|valid_date[H:i:s]',
'end_time' => 'required|valid_date[H:i:s]',
'is_active' => 'permit_empty|in_list[0,1]',
];
protected $validationMessages = [
'end_time' => [
'required' => 'End time is required.',
],
];
protected function getSlotStartBeforeEndRule(): array
{
return [
'end_time' => 'required|valid_date[H:i:s]|greater_than_field[start_time]',
];
}
/**
* Validate that start_time < end_time (custom rule or after validation).
*/
public function validateStartBeforeEnd(array $data): bool
{
$start = $data['start_time'] ?? '';
$end = $data['end_time'] ?? '';
if ($start === '' || $end === '') {
return true;
}
return strtotime($start) < strtotime($end);
}
/**
* Override insert to check start < end.
*/
public function insert($data = null, bool $returnID = true)
{
if (is_array($data) && !$this->validateStartBeforeEnd($data)) {
$this->errors['end_time'] = 'End time must be after start time.';
return false;
}
return parent::insert($data, $returnID);
}
/**
* Override update to check start < end (merge with existing when partial).
*/
public function update($id = null, $data = null): bool
{
if (is_array($data) && (array_key_exists('start_time', $data) || array_key_exists('end_time', $data))) {
$existing = $this->find($id);
$exStart = $existing && is_object($existing) ? $existing->start_time : ($existing['start_time'] ?? '');
$exEnd = $existing && is_object($existing) ? $existing->end_time : ($existing['end_time'] ?? '');
$merged = [
'start_time' => $data['start_time'] ?? $exStart,
'end_time' => $data['end_time'] ?? $exEnd,
];
if (!$this->validateStartBeforeEnd($merged)) {
$this->errors['end_time'] = 'End time must be after start time.';
return false;
}
}
return parent::update($id, $data);
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace App\Modules\Academic\Models;
use App\Modules\Academic\Entities\Schedule;
use CodeIgniter\Model;
/**
* Schedule Model
*
* Handles database operations for schedules.
*/
class ScheduleModel extends Model
{
protected $table = 'schedules';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = Schedule::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'class_id',
'subject_id',
'teacher_user_id',
'teacher_name',
'lesson_slot_id',
'day_of_week',
'start_time',
'end_time',
'room',
'is_active',
];
// Dates
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
// Validation
protected $validationRules = [
'class_id' => 'required|integer|is_not_unique[classes.id]',
'subject_id' => 'required|integer|is_not_unique[subjects.id]',
'teacher_name' => 'required|max_length[255]',
'day_of_week' => 'required|integer|greater_than_equal_to[1]|less_than_equal_to[7]',
'start_time' => 'required|valid_date[H:i:s]',
'end_time' => 'required|valid_date[H:i:s]',
'room' => 'permit_empty|max_length[100]',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
/**
* Find active schedule for class on specific day and time (uses schedule columns only).
*
* @deprecated Prefer getActiveScheduleRow() for slot/user as source of truth with fallback.
* @param int $classId
* @param int $dayOfWeek
* @param string $time Time in H:i:s format
* @return Schedule|null
*/
public function findActiveSchedule(int $classId, int $dayOfWeek, string $time): ?Schedule
{
return $this->where('class_id', $classId)
->where('day_of_week', $dayOfWeek)
->where('start_time <=', $time)
->where('end_time >', $time)
->first();
}
/**
* Get active schedule row with lesson_slots and users as source of truth.
* start_time, end_time from lesson_slots; teacher_name from users; fallback to schedule columns when NULL.
*
* @param int $classId
* @param int $dayOfWeek
* @param string $time Time in H:i:s format
* @return array{id: int, class_id: int, subject_id: int, teacher_user_id: int|null, teacher_name: string, room: string|null, start_time: string, end_time: string, day_of_week: int}|null
*/
public function getActiveScheduleRow(int $classId, int $dayOfWeek, string $time): ?array
{
$db = \Config\Database::connect();
$sql = 'SELECT sch.id AS id, sch.class_id, sch.subject_id, sch.teacher_user_id, '
. 'COALESCE(u.name, sch.teacher_name) AS teacher_name, sch.room, '
. 'COALESCE(ls.start_time, sch.start_time) AS start_time, COALESCE(ls.end_time, sch.end_time) AS end_time, sch.day_of_week '
. 'FROM schedules sch '
. 'LEFT JOIN lesson_slots ls ON ls.id = sch.lesson_slot_id '
. 'LEFT JOIN users u ON u.id = sch.teacher_user_id '
. 'WHERE sch.class_id = ? AND sch.day_of_week = ? '
. 'AND ( COALESCE(ls.start_time, sch.start_time) ) <= ? AND ( COALESCE(ls.end_time, sch.end_time) ) > ? '
. 'LIMIT 1';
$row = $db->query($sql, [$classId, $dayOfWeek, $time, $time])->getRowArray();
if ($row === null) {
return null;
}
return [
'id' => (int) $row['id'],
'class_id' => (int) $row['class_id'],
'subject_id' => (int) $row['subject_id'],
'teacher_user_id' => isset($row['teacher_user_id']) ? (int) $row['teacher_user_id'] : null,
'teacher_name' => (string) ($row['teacher_name'] ?? ''),
'room' => isset($row['room']) ? (string) $row['room'] : null,
'start_time' => (string) $row['start_time'],
'end_time' => (string) $row['end_time'],
'day_of_week' => (int) $row['day_of_week'],
];
}
/**
* Get latest schedule for class on given day where start_time <= time (may already be ended).
* Uses lesson_slots/users as source of truth. ORDER BY start_time DESC, LIMIT 1.
*
* @param int $classId
* @param int $dayOfWeek
* @param string $time Time in H:i:s format
* @return array{id: int, class_id: int, subject_id: int, teacher_user_id: int|null, teacher_name: string, room: string|null, start_time: string, end_time: string, day_of_week: int}|null
*/
public function getLatestScheduleForClassToday(int $classId, int $dayOfWeek, string $time): ?array
{
$db = \Config\Database::connect();
$sql = 'SELECT sch.id AS id, sch.class_id, sch.subject_id, sch.teacher_user_id, '
. 'COALESCE(u.name, sch.teacher_name) AS teacher_name, sch.room, '
. 'COALESCE(ls.start_time, sch.start_time) AS start_time, COALESCE(ls.end_time, sch.end_time) AS end_time, sch.day_of_week '
. 'FROM schedules sch '
. 'LEFT JOIN lesson_slots ls ON ls.id = sch.lesson_slot_id '
. 'LEFT JOIN users u ON u.id = sch.teacher_user_id '
. 'WHERE sch.class_id = ? AND sch.day_of_week = ? '
. 'AND ( COALESCE(ls.start_time, sch.start_time) ) <= ? '
. 'ORDER BY ( COALESCE(ls.start_time, sch.start_time) ) DESC '
. 'LIMIT 1';
$row = $db->query($sql, [$classId, $dayOfWeek, $time])->getRowArray();
if ($row === null) {
return null;
}
return [
'id' => (int) $row['id'],
'class_id' => (int) $row['class_id'],
'subject_id' => (int) $row['subject_id'],
'teacher_user_id' => isset($row['teacher_user_id']) ? (int) $row['teacher_user_id'] : null,
'teacher_name' => (string) ($row['teacher_name'] ?? ''),
'room' => isset($row['room']) ? (string) $row['room'] : null,
'start_time' => (string) $row['start_time'],
'end_time' => (string) $row['end_time'],
'day_of_week' => (int) $row['day_of_week'],
];
}
/**
* Get one schedule row by id with lesson_slots and users as source of truth.
*
* @param int $scheduleId
* @return array{id: int, class_id: int, subject_id: int, teacher_user_id: int|null, teacher_name: string, start_time: string, end_time: string, day_of_week: int, room: string|null}|null
*/
public function getScheduleWithSlot(int $scheduleId): ?array
{
$db = \Config\Database::connect();
$sql = 'SELECT sch.id AS id, sch.class_id, sch.subject_id, sch.teacher_user_id, '
. '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, '
. 'sch.day_of_week, sch.room '
. 'FROM schedules sch '
. 'LEFT JOIN lesson_slots ls ON ls.id = sch.lesson_slot_id '
. 'LEFT JOIN users u ON u.id = sch.teacher_user_id '
. 'WHERE sch.id = ? LIMIT 1';
$row = $db->query($sql, [$scheduleId])->getRowArray();
if ($row === null) {
return null;
}
return [
'id' => (int) $row['id'],
'class_id' => (int) $row['class_id'],
'subject_id' => (int) $row['subject_id'],
'teacher_user_id' => isset($row['teacher_user_id']) ? (int) $row['teacher_user_id'] : null,
'teacher_name' => (string) ($row['teacher_name'] ?? ''),
'start_time' => (string) $row['start_time'],
'end_time' => (string) $row['end_time'],
'day_of_week' => (int) $row['day_of_week'],
'room' => isset($row['room']) ? (string) $row['room'] : null,
];
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Modules\Academic\Models;
use App\Modules\Academic\Entities\Student;
use CodeIgniter\Model;
/**
* Student Model
*
* Handles database operations for students.
*/
class StudentModel extends Model
{
protected $table = 'students';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = Student::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'dapodik_id',
'face_external_id',
'face_hash',
'nisn',
'name',
'gender',
'class_id',
'is_active',
'parent_link_code',
];
// Dates
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
// Validation (class_id nullable for unmapped/Dapodik)
protected $validationRules = [
'dapodik_id' => 'permit_empty|max_length[64]|is_unique[students.dapodik_id,id,{id}]',
'face_external_id' => 'permit_empty|max_length[100]|is_unique[students.face_external_id,id,{id}]',
'face_hash' => 'permit_empty|max_length[32]',
'nisn' => 'required|max_length[50]|is_unique[students.nisn,id,{id}]',
'name' => 'required|max_length[255]',
'gender' => 'permit_empty|in_list[L,P]',
'class_id' => 'permit_empty|integer|is_not_unique[classes.id]',
'is_active' => 'permit_empty|in_list[0,1]',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
/**
* Find student by NISN
*
* @param string $nisn
* @return Student|null
*/
public function findByNisn(string $nisn): ?Student
{
return $this->where('nisn', $nisn)->first();
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Modules\Academic\Models;
use App\Modules\Academic\Entities\Subject;
use CodeIgniter\Model;
/**
* Subject Model
*
* Handles database operations for subjects.
*/
class SubjectModel extends Model
{
protected $table = 'subjects';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = Subject::class;
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'name',
'code',
];
// Dates
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
// Validation
protected $validationRules = [
'name' => 'required|max_length[255]',
'code' => 'permit_empty|max_length[50]',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Modules\Academic\Models;
use CodeIgniter\Model;
class TeacherSubjectModel extends Model
{
protected $table = 'teacher_subjects';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = [
'teacher_user_id',
'subject_id',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
/**
* Get subject IDs for a teacher.
*
* @param int $teacherId
* @return array<int>
*/
public function getSubjectIdsForTeacher(int $teacherId): array
{
$rows = $this->select('subject_id')
->where('teacher_user_id', $teacherId)
->findAll();
return array_map(static fn ($row) => (int) $row['subject_id'], $rows);
}
/**
* Get mapping: subject_id => [teacher_user_id, ...].
*
* @return array<int, array<int>>
*/
public function getMapSubjectToTeacherIds(): array
{
$rows = $this->select('teacher_user_id, subject_id')->findAll();
$map = [];
foreach ($rows as $r) {
$s = (int) $r['subject_id'];
$t = (int) $r['teacher_user_id'];
$map[$s][] = $t;
}
return $map;
}
}

View File

@@ -0,0 +1,45 @@
<?php
/**
* Academic Module Routes
*
* This file is automatically loaded by ModuleLoader.
* Define your academic management routes here.
*
* @var \CodeIgniter\Router\RouteCollection $routes
*/
$routes->group('api/academic', [
'namespace' => 'App\Modules\Academic\Controllers',
'filter' => 'admin_only',
], function ($routes) {
$routes->get('lesson-slots', 'LessonSlotController::index');
$routes->post('lesson-slots', 'LessonSlotController::create');
$routes->put('lesson-slots/(:num)', 'LessonSlotController::update/$1');
$routes->delete('lesson-slots/(:num)', 'LessonSlotController::delete/$1');
$routes->get('schedules/class/(:num)', 'ScheduleManagementController::getByClass/$1');
$routes->post('schedules/bulk-save', 'ScheduleManagementController::bulkSave');
$routes->get('teachers', 'TeacherController::index');
$routes->get('teacher-subjects/(:num)', 'TeacherSubjectController::getByTeacher/$1');
$routes->put('teacher-subjects/(:num)', 'TeacherSubjectController::updateForTeacher/$1');
$routes->get('teacher-subjects/map', 'TeacherSubjectController::map');
$routes->get('subjects', 'SubjectController::index');
$routes->post('subjects', 'SubjectController::create');
$routes->put('subjects/(:num)', 'SubjectController::update/$1');
$routes->delete('subjects/(:num)', 'SubjectController::delete/$1');
$routes->get('classes', 'ClassController::index');
$routes->post('classes', 'ClassController::create');
$routes->put('classes/(:num)', 'ClassController::update/$1');
$routes->delete('classes/(:num)', 'ClassController::delete/$1');
$routes->get('students', 'StudentController::index');
$routes->post('students', 'StudentController::create');
$routes->put('students/(:num)', 'StudentController::update/$1');
$routes->delete('students/(:num)', 'StudentController::delete/$1');
$routes->post('dapodik/sync/students', 'DapodikSyncController::syncStudents');
$routes->get('dapodik/sync/status/(:num)', 'DapodikSyncController::status/$1');
$routes->get('dapodik/rombels', 'DapodikSyncController::rombels');
$routes->put('dapodik/rombels/(:num)', 'DapodikSyncController::updateRombel/$1');
});

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Modules\Academic\Services;
/**
* Dapodik WebService API client.
* Uses env: DAPODIK_BASE_URL, DAPODIK_TOKEN, DAPODIK_NPSN.
* Do not log or expose token.
*/
class DapodikClient
{
protected string $baseUrl;
protected string $token;
protected string $npsn;
public function __construct(?string $baseUrl = null, ?string $token = null, ?string $npsn = null)
{
$this->baseUrl = rtrim($baseUrl ?? (string) env('DAPODIK_BASE_URL', ''), '/');
$this->token = $token ?? (string) env('DAPODIK_TOKEN', '');
$this->npsn = $npsn ?? (string) env('DAPODIK_NPSN', '');
}
/**
* GET getSekolah
*
* @return array{success: bool, data?: array, error?: string}
*/
public function getSekolah(): array
{
$url = $this->baseUrl . '/getSekolah';
if ($this->npsn !== '') {
$url .= '?npsn=' . rawurlencode($this->npsn);
}
return $this->request('GET', $url);
}
/**
* GET getPesertaDidik with pagination
*
* @param int $start
* @param int $limit
* @return array{success: bool, data?: array, rows?: array, id?: mixed, start?: int, limit?: int, results?: int, error?: string}
*/
public function getPesertaDidik(int $start = 0, int $limit = 200): array
{
$params = [];
if ($this->npsn !== '') {
$params['npsn'] = $this->npsn;
}
$params['start'] = $start;
$params['limit'] = $limit;
$url = $this->baseUrl . '/getPesertaDidik?' . http_build_query($params);
return $this->request('GET', $url);
}
/**
* Execute HTTP request. Returns decoded JSON with success flag and error message on failure.
*/
protected function request(string $method, string $url): array
{
$ch = curl_init();
if ($ch === false) {
return ['success' => false, 'error' => 'cURL init failed'];
}
$headers = [
'Accept: application/json',
'Authorization: Bearer ' . $this->token,
];
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CUSTOMREQUEST => $method,
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) {
return ['success' => false, 'error' => 'Network error: ' . ($errno === CURLE_OPERATION_TIMEDOUT ? 'timeout' : 'curl ' . $errno)];
}
$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['success' => false, 'error' => 'Invalid JSON response', 'http_code' => $httpCode];
}
if ($httpCode < 200 || $httpCode >= 300) {
$msg = is_array($decoded) && isset($decoded['message']) ? $decoded['message'] : 'HTTP ' . $httpCode;
return ['success' => false, 'error' => $msg, 'http_code' => $httpCode, 'data' => $decoded];
}
return array_merge(['success' => true], $decoded);
}
/**
* Normalize Dapodik response to a list of rows.
* Dapodik returns { results, id, start, limit, rows: [...] }
*
* @param array $response Response from getPesertaDidik
* @return array<int, array<string, mixed>>
*/
public static function normalizePesertaDidikRows(array $response): array
{
if (! isset($response['rows']) || ! is_array($response['rows'])) {
return [];
}
$rows = $response['rows'];
$out = [];
foreach ($rows as $i => $row) {
$out[] = is_array($row) ? $row : (array) $row;
}
return $out;
}
}

View File

@@ -0,0 +1,312 @@
<?php
namespace App\Modules\Academic\Services;
use App\Modules\Academic\Models\DapodikRombelMappingModel;
use App\Modules\Academic\Models\DapodikSyncJobModel;
use App\Modules\Academic\Models\StudentModel;
/**
* Syncs Dapodik peserta didik into local students with rombel -> class mapping.
*/
class DapodikSyncService
{
protected DapodikClient $client;
protected DapodikRombelMappingModel $mappingModel;
protected StudentModel $studentModel;
protected DapodikSyncJobModel $jobModel;
public function __construct(?DapodikClient $client = null, ?DapodikSyncJobModel $jobModel = null)
{
$this->client = $client ?? new DapodikClient();
$this->mappingModel = new DapodikRombelMappingModel();
$this->studentModel = new StudentModel();
$this->jobModel = $jobModel ?? new DapodikSyncJobModel();
}
/**
* Sync students from Dapodik with job tracking. Paginates getPesertaDidik, upserts per batch.
*
* @param int $jobId Job row id for progress updates
* @param int $limit Per-page limit
* @param int $maxPages Max pages to fetch
* @return void
*/
public function syncStudentsWithJob(int $jobId, int $limit = 100, int $maxPages = 50): void
{
$start = 0;
$page = 0;
$now = date('Y-m-d H:i:s');
$processedTotal = 0;
$totalRows = 0;
$knownTotal = false;
$db = \Config\Database::connect();
try {
while ($page < $maxPages) {
$response = $this->client->getPesertaDidik($start, $limit);
if (! ($response['success'] ?? false)) {
$err = $response['error'] ?? 'Unknown error';
$this->jobModel->update($jobId, [
'status' => DapodikSyncJobModel::STATUS_FAILED,
'message' => $err,
'finished_at' => $now,
]);
return;
}
$rows = DapodikClient::normalizePesertaDidikRows($response);
if ($rows === []) {
break;
}
if (! $knownTotal) {
$results = $response['results'] ?? null;
if (is_numeric($results) && (int) $results > 0) {
$totalRows = (int) $results;
$knownTotal = true;
$this->jobModel->update($jobId, ['total_rows' => $totalRows]);
}
}
$db->transStart();
try {
$batchCount = 0;
foreach ($rows as $row) {
$dapodikId = $this->extractDapodikId($row);
$nisn = $this->extractString($row, ['nisn']);
$nama = $this->extractString($row, ['nama', 'name']);
$gender = $this->extractGender($row);
$rombel = $this->extractString($row, ['rombel', 'nama_rombel', 'rombongan_belajar']);
// Require at least one stable identifier
if ($dapodikId === null && $nisn === '') {
continue;
}
$mapping = $this->mappingModel->getByRombel($rombel);
if ($mapping === null) {
$this->mappingModel->insert([
'dapodik_rombel' => $rombel,
'class_id' => null,
'last_seen_at' => $now,
]);
$classId = null;
} else {
$this->mappingModel->update($mapping['id'], ['last_seen_at' => $now]);
$classId = ! empty($mapping['class_id']) ? (int) $mapping['class_id'] : null;
}
$data = [
'dapodik_id' => $dapodikId,
'nisn' => $nisn,
'name' => $nama !== '' ? $nama : ($nisn !== '' ? 'Siswa ' . $nisn : 'Siswa Dapodik'),
'gender' => $gender,
'class_id' => $classId,
'is_active' => 1,
];
// Use upsert so repeated syncs update instead of inserting duplicates
$this->studentModel->skipValidation(true);
$this->studentModel->upsert($data);
$batchCount++;
}
$db->transComplete();
} catch (\Throwable $e) {
$db->transRollback();
$this->jobModel->update($jobId, [
'status' => DapodikSyncJobModel::STATUS_FAILED,
'message' => $e->getMessage(),
'finished_at' => $now,
]);
return;
}
$processedTotal += $batchCount;
$this->jobModel->update($jobId, [
'processed_rows' => $processedTotal,
]);
if (count($rows) < $limit) {
if (! $knownTotal) {
$this->jobModel->update($jobId, ['total_rows' => $processedTotal]);
}
break;
}
$start += $limit;
$page++;
}
$this->jobModel->update($jobId, [
'status' => DapodikSyncJobModel::STATUS_COMPLETED,
'finished_at' => $now,
]);
} catch (\Throwable $e) {
$this->jobModel->update($jobId, [
'status' => DapodikSyncJobModel::STATUS_FAILED,
'message' => $e->getMessage(),
'finished_at' => date('Y-m-d H:i:s'),
]);
}
}
/**
* Extract Dapodik stable identifier for a student row.
*
* @param array $row
* @return string|null
*/
private function extractDapodikId(array $row): ?string
{
foreach (['peserta_didik_id', 'id', 'pd_id'] as $key) {
if (isset($row[$key]) && is_scalar($row[$key])) {
$v = trim((string) $row[$key]);
if ($v !== '') {
return $v;
}
}
}
return null;
}
/**
* Estimate total rows from first Dapodik response (if available).
*
* @param int $limit Per-page limit
* @return array{total: int, from_api: bool}
*/
public function estimateTotalStudents(int $limit = 100): array
{
$response = $this->client->getPesertaDidik(0, $limit);
if (! ($response['success'] ?? false)) {
return ['total' => 0, 'from_api' => false];
}
$results = $response['results'] ?? null;
if (is_numeric($results) && (int) $results > 0) {
return ['total' => (int) $results, 'from_api' => true];
}
$rows = DapodikClient::normalizePesertaDidikRows($response);
return ['total' => count($rows) > 0 ? count($rows) : 0, 'from_api' => false];
}
/**
* Legacy sync (no job). Kept for backwards compatibility.
*
* @param int $limit Per-page limit
* @param int $maxPages Max pages to fetch
* @return array{fetched_total: int, inserted_students: int, updated_students: int, unmapped_students_count: int, mappings_created: int, mappings_seen_updated: int, errors: array<int, string>}
*/
public function syncStudents(int $limit = 200, int $maxPages = 50): array
{
$summary = [
'fetched_total' => 0,
'inserted_students' => 0,
'updated_students' => 0,
'unmapped_students_count' => 0,
'mappings_created' => 0,
'mappings_seen_updated' => 0,
'errors' => [],
];
$start = 0;
$page = 0;
$now = date('Y-m-d H:i:s');
while ($page < $maxPages) {
$response = $this->client->getPesertaDidik($start, $limit);
if (! ($response['success'] ?? false)) {
$summary['errors'][] = $response['error'] ?? 'Unknown error';
break;
}
$rows = DapodikClient::normalizePesertaDidikRows($response);
if ($rows === []) {
break;
}
$summary['fetched_total'] += count($rows);
foreach ($rows as $row) {
$nisn = $this->extractString($row, ['nisn']);
$nama = $this->extractString($row, ['nama', 'name']);
$gender = $this->extractGender($row);
$rombel = $this->extractString($row, ['rombel', 'nama_rombel', 'rombongan_belajar']);
if ($nisn === '') {
continue;
}
$mapping = $this->mappingModel->getByRombel($rombel);
if ($mapping === null) {
$this->mappingModel->insert([
'dapodik_rombel' => $rombel,
'class_id' => null,
'last_seen_at' => $now,
]);
$summary['mappings_created']++;
$classId = null;
} else {
$this->mappingModel->update($mapping['id'], ['last_seen_at' => $now]);
$summary['mappings_seen_updated']++;
$classId = ! empty($mapping['class_id']) ? (int) $mapping['class_id'] : null;
}
if ($classId === null) {
$summary['unmapped_students_count']++;
}
$existing = $this->studentModel->findByNisn($nisn);
$data = [
'nisn' => $nisn,
'name' => $nama !== '' ? $nama : 'Siswa ' . $nisn,
'gender' => $gender,
'class_id' => $classId,
'is_active'=> 1,
];
$this->studentModel->skipValidation(false);
if ($existing) {
$this->studentModel->update($existing->id, $data);
$summary['updated_students']++;
} else {
$this->studentModel->insert($data);
$summary['inserted_students']++;
}
}
if (count($rows) < $limit) {
break;
}
$start += $limit;
$page++;
}
return $summary;
}
private function extractString(array $row, array $keys): string
{
foreach ($keys as $k) {
if (isset($row[$k]) && is_scalar($row[$k])) {
return trim((string) $row[$k]);
}
}
return '';
}
private function extractGender(array $row): ?string
{
$v = $row['jenis_kelamin'] ?? $row['gender'] ?? $row['jk'] ?? null;
if ($v === null || $v === '') {
return null;
}
$v = strtoupper(trim((string) $v));
if ($v === 'L' || $v === 'LAKI-LAKI' || $v === 'Laki-laki') {
return 'L';
}
if ($v === 'P' || $v === 'PEREMPUAN' || $v === 'Perempuan') {
return 'P';
}
return null;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Modules\Academic\Services;
use App\Modules\Academic\Models\ScheduleModel;
use App\Modules\Academic\Models\StudentModel;
/**
* Schedule Resolver Service
*
* Handles schedule resolution logic for students.
*/
class ScheduleResolverService
{
protected StudentModel $studentModel;
protected ScheduleModel $scheduleModel;
public function __construct()
{
$this->studentModel = new StudentModel();
$this->scheduleModel = new ScheduleModel();
}
/**
* Get active schedule for a student at a specific datetime
*
* Rules:
* - Find student's class_id
* - Determine day_of_week from datetime (1=Monday, 7=Sunday)
* - Find schedule where start_time <= time < end_time
*
* @param int $studentId Student ID
* @param string $datetime Datetime string (Y-m-d H:i:s format)
* @return array|null Returns schedule detail or null if not found
*/
public function getActiveSchedule(int $studentId, string $datetime): ?array
{
// Find student
$student = $this->studentModel->find($studentId);
if (!$student) {
return null;
}
// Get class_id from student
$classId = $student->class_id;
// Parse datetime to get day of week and time
$timestamp = strtotime($datetime);
if ($timestamp === false) {
return null;
}
// Get day of week (1=Monday, 7=Sunday)
// PHP date('N') returns 1-7 (Monday-Sunday)
$dayOfWeek = (int) date('N', $timestamp);
// Get time in H:i:s format
$time = date('H:i:s', $timestamp);
// Find active schedule (lesson_slots/users as source of truth with fallback to schedule columns)
$row = $this->scheduleModel->getActiveScheduleRow($classId, $dayOfWeek, $time);
if ($row === null) {
return null;
}
return [
'schedule_id' => $row['id'],
'class_id' => $row['class_id'],
'subject_id' => $row['subject_id'],
'teacher_name' => $row['teacher_name'],
'room' => $row['room'],
'start_time' => $row['start_time'],
'end_time' => $row['end_time'],
];
}
}