init backend presensi
This commit is contained in:
158
app/Modules/Academic/Controllers/ClassController.php
Normal file
158
app/Modules/Academic/Controllers/ClassController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
185
app/Modules/Academic/Controllers/DapodikSyncController.php
Normal file
185
app/Modules/Academic/Controllers/DapodikSyncController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
129
app/Modules/Academic/Controllers/LessonSlotController.php
Normal file
129
app/Modules/Academic/Controllers/LessonSlotController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 1–5, 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
195
app/Modules/Academic/Controllers/StudentController.php
Normal file
195
app/Modules/Academic/Controllers/StudentController.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
121
app/Modules/Academic/Controllers/SubjectController.php
Normal file
121
app/Modules/Academic/Controllers/SubjectController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
63
app/Modules/Academic/Controllers/TeacherController.php
Normal file
63
app/Modules/Academic/Controllers/TeacherController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user