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