diff --git a/.env.example b/.env.example index c0660ea..c2ca31b 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,17 @@ -APP_NAME=Laravel +# ============================================== +# SIAKAD - Sistem Informasi Akademik +# Production Ready Environment Configuration +# ============================================== + +APP_NAME=SIAKAD APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://localhost -APP_LOCALE=en +APP_LOCALE=id APP_FALLBACK_LOCALE=en -APP_FAKER_LOCALE=en_US +APP_FAKER_LOCALE=id_ID APP_MAINTENANCE_DRIVER=file # APP_MAINTENANCE_STORE=database @@ -15,51 +20,94 @@ APP_MAINTENANCE_DRIVER=file BCRYPT_ROUNDS=12 -LOG_CHANNEL=stack +# ============================================== +# LOGGING - Use 'daily' for production +# ============================================== +LOG_CHANNEL=daily LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug +# ============================================== +# DATABASE +# For production, use MySQL or PostgreSQL +# ============================================== + +# SQLite (Development) DB_CONNECTION=sqlite + +# MySQL (Production - uncomment and configure) +# DB_CONNECTION=mysql # DB_HOST=127.0.0.1 # DB_PORT=3306 -# DB_DATABASE=laravel +# DB_DATABASE=siakad # DB_USERNAME=root # DB_PASSWORD= +# PostgreSQL (Production Alternative) +# DB_CONNECTION=pgsql +# DB_HOST=127.0.0.1 +# DB_PORT=5432 +# DB_DATABASE=siakad +# DB_USERNAME=postgres +# DB_PASSWORD= + +# ============================================== +# SESSION & CACHE +# ============================================== SESSION_DRIVER=database SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null +CACHE_STORE=database +# For production with Redis: +# CACHE_STORE=redis + +# ============================================== +# QUEUE & BROADCASTING +# ============================================== BROADCAST_CONNECTION=log FILESYSTEM_DISK=local QUEUE_CONNECTION=database -CACHE_STORE=database -# CACHE_PREFIX= - -MEMCACHED_HOST=127.0.0.1 - +# ============================================== +# REDIS (Optional - for production caching) +# ============================================== REDIS_CLIENT=phpredis REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 +# ============================================== +# MAIL +# ============================================== MAIL_MAILER=log MAIL_SCHEME=null MAIL_HOST=127.0.0.1 MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null -MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_ADDRESS="noreply@siakad.example.com" MAIL_FROM_NAME="${APP_NAME}" +# ============================================== +# AWS (Optional) +# ============================================== AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false +# ============================================== +# VITE +# ============================================== VITE_APP_NAME="${APP_NAME}" + +# ============================================== +# AI - Gemini API +# Get your API key at: https://aistudio.google.com/ +# ============================================== +GEMINI_API_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..df0bb84 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: SIAKAD CI + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master] + +jobs: + tests: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: siakad_test + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + extensions: mbstring, pdo, pdo_mysql, bcmath + coverage: xdebug + + - name: Get Composer Cache Directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install Dependencies + run: composer install --no-ansi --no-interaction --no-scripts --prefer-dist + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install NPM Dependencies + run: npm ci + + - name: Build Assets + run: npm run build + + - name: Copy Environment File + run: cp .env.example .env + + - name: Configure Environment + run: | + sed -i 's/DB_CONNECTION=sqlite/DB_CONNECTION=mysql/' .env + sed -i 's/# DB_HOST=127.0.0.1/DB_HOST=127.0.0.1/' .env + sed -i 's/# DB_PORT=3306/DB_PORT=3306/' .env + sed -i 's/# DB_DATABASE=siakad/DB_DATABASE=siakad_test/' .env + sed -i 's/# DB_USERNAME=root/DB_USERNAME=root/' .env + sed -i 's/# DB_PASSWORD=/DB_PASSWORD=password/' .env + + - name: Generate Application Key + run: php artisan key:generate + + - name: Run Migrations + run: php artisan migrate --force + + - name: Run Tests + run: php artisan test --parallel + + - name: Generate Test Coverage Report + run: php artisan test --coverage --min=20 + continue-on-error: true diff --git a/README.md b/README.md index 0165a77..2df9338 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,263 @@ -
+
+
+
+
+
+
+ Production-grade academic information system built with Laravel 12 +
-
-
-
-
+ Features โข
+ Tech Stack โข
+ Installation โข
+ Screenshots โข
+ Architecture
+
+
+
+
| + +**Backend** + +- Laravel 12 +- PHP 8.2 +- MySQL 8.0 +- Pest PHP + + | ++ +**Frontend** + +- Blade Templates +- Alpine.js +- Tailwind CSS +- Vite 7 -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + | +-- [Simple, fast routing engine](https://laravel.com/docs/routing). -- [Powerful dependency injection container](https://laravel.com/docs/container). -- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. -- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). -- Database agnostic [schema migrations](https://laravel.com/docs/migrations). -- [Robust background job processing](https://laravel.com/docs/queues). -- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). +**DevOps** -Laravel is accessible, powerful, and provides tools required for large, robust applications. +- GitHub Actions +- Health Monitoring +- Daily Logs +- Rate Limiting -## Learning Laravel + | +
+ Built with โค๏ธ using Laravel 12 +
diff --git a/app/Http/Controllers/Admin/DosenController.php b/app/Http/Controllers/Admin/DosenController.php index d2f8840..7f80310 100644 --- a/app/Http/Controllers/Admin/DosenController.php +++ b/app/Http/Controllers/Admin/DosenController.php @@ -29,7 +29,23 @@ public function index(Request $request) $query->where('prodi_id', $prodiId); } - $dosen = $query->orderBy('nidn')->paginate(config('siakad.pagination', 15)); + // Sorting + $sortColumn = $request->get('sort', 'nidn'); + $sortDirection = $request->get('order', 'asc'); + + if ($sortColumn === 'name') { + $query->join('users', 'dosen.user_id', '=', 'users.id') + ->select('dosen.*') + ->orderBy('users.name', $sortDirection); + } elseif ($sortColumn === 'prodi') { + $query->join('prodi', 'dosen.prodi_id', '=', 'prodi.id') + ->select('dosen.*') + ->orderBy('prodi.nama', $sortDirection); + } else { + $query->orderBy('nidn', $sortDirection); + } + + $dosen = $query->paginate(config('siakad.pagination', 15))->withQueryString(); $prodiList = Prodi::with('fakultas')->get(); return view('admin.dosen.index', compact('dosen', 'prodiList')); diff --git a/app/Http/Controllers/Admin/KelasController.php b/app/Http/Controllers/Admin/KelasController.php index db5c741..6a7b9d0 100644 --- a/app/Http/Controllers/Admin/KelasController.php +++ b/app/Http/Controllers/Admin/KelasController.php @@ -16,11 +16,42 @@ public function __construct(AkademikService $akademikService) $this->akademikService = $akademikService; } - public function index() + public function index(Request $request) { - $kelas = \App\Models\Kelas::with(['mataKuliah', 'dosen', 'jadwal'])->get(); + $query = \App\Models\Kelas::with(['mataKuliah', 'dosen.user', 'jadwal']); + + // Search + if ($search = $request->get('search')) { + $query->where(function($q) use ($search) { + $q->where('nama_kelas', 'like', "%{$search}%") + ->orWhereHas('mataKuliah', fn($q2) => $q2->where('nama_mk', 'like', "%{$search}%")->orWhere('kode_mk', 'like', "%{$search}%")) + ->orWhereHas('dosen.user', fn($q3) => $q3->where('name', 'like', "%{$search}%")); + }); + } + + // Sorting + $sortColumn = $request->get('sort', 'nama_kelas'); + $sortDirection = $request->get('order', 'asc'); + + if ($sortColumn === 'mata_kuliah') { + $query->join('mata_kuliah', 'kelas.mata_kuliah_id', '=', 'mata_kuliah.id') + ->select('kelas.*') + ->orderBy('mata_kuliah.nama_mk', $sortDirection); + } elseif ($sortColumn === 'dosen') { + $query->join('dosen', 'kelas.dosen_id', '=', 'dosen.id') + ->join('users', 'dosen.user_id', '=', 'users.id') + ->select('kelas.*') + ->orderBy('users.name', $sortDirection); + } elseif (in_array($sortColumn, ['nama_kelas', 'kapasitas'])) { + $query->orderBy($sortColumn, $sortDirection); + } else { + $query->orderBy('nama_kelas', 'asc'); + } + + $kelas = $query->paginate(config('siakad.pagination', 15))->withQueryString(); $mataKuliah = $this->akademikService->getAllMataKuliah(); $dosen = \App\Models\Dosen::with('user')->get(); + return view('admin.kelas.index', compact('kelas', 'mataKuliah', 'dosen')); } diff --git a/app/Http/Controllers/Admin/KpController.php b/app/Http/Controllers/Admin/KpController.php index 7e5ede9..49f7814 100644 --- a/app/Http/Controllers/Admin/KpController.php +++ b/app/Http/Controllers/Admin/KpController.php @@ -26,7 +26,31 @@ public function index(Request $request) }); } - $kpList = $query->orderBy('created_at', 'desc')->paginate(20); + // Sorting + $sortColumn = $request->get('sort', 'created_at'); + $sortDirection = $request->get('order', 'desc'); + + if ($sortColumn === 'mahasiswa_name') { + $query->join('mahasiswa', 'kerja_praktek.mahasiswa_id', '=', 'mahasiswa.id') + ->join('users', 'mahasiswa.user_id', '=', 'users.id') + ->select('kerja_praktek.*') + ->orderBy('users.name', $sortDirection); + } elseif ($sortColumn === 'mahasiswa_nim') { + $query->join('mahasiswa', 'kerja_praktek.mahasiswa_id', '=', 'mahasiswa.id') + ->select('kerja_praktek.*') + ->orderBy('mahasiswa.nim', $sortDirection); + } elseif ($sortColumn === 'pembimbing_name') { + $query->leftJoin('dosen', 'kerja_praktek.pembimbing_id', '=', 'dosen.id') + ->leftJoin('users', 'dosen.user_id', '=', 'users.id') + ->select('kerja_praktek.*') + ->orderBy('users.name', $sortDirection); + } elseif (in_array($sortColumn, ['nama_perusahaan', 'status', 'created_at'])) { + $query->orderBy($sortColumn, $sortDirection); + } else { + $query->orderBy('created_at', 'desc'); + } + + $kpList = $query->paginate(20)->withQueryString(); $dosenList = Dosen::with('user')->get(); $statusList = KerjaPraktek::getStatusList(); diff --git a/app/Http/Controllers/Admin/KrsApprovalController.php b/app/Http/Controllers/Admin/KrsApprovalController.php index a09dc5e..27c9c38 100644 --- a/app/Http/Controllers/Admin/KrsApprovalController.php +++ b/app/Http/Controllers/Admin/KrsApprovalController.php @@ -14,8 +14,33 @@ public function index(Request $request) $krsList = Krs::with(['mahasiswa.user', 'mahasiswa.prodi', 'tahunAkademik', 'krsDetail.kelas.mataKuliah']) ->when($status !== 'all', fn($q) => $q->where('status', $status)) - ->orderBy('updated_at', 'desc') - ->paginate(config('siakad.pagination', 15)); + ->when($status !== 'all', fn($q) => $q->where('status', $status)); + + // Sorting + $sortColumn = $request->get('sort', 'updated_at'); + $sortDirection = $request->get('order', 'desc'); + + if ($sortColumn === 'name') { + $krsList = $krsList->join('mahasiswa', 'krs.mahasiswa_id', '=', 'mahasiswa.id') + ->join('users', 'mahasiswa.user_id', '=', 'users.id') + ->select('krs.*') + ->orderBy('users.name', $sortDirection); + } elseif ($sortColumn === 'nim') { + $krsList = $krsList->join('mahasiswa', 'krs.mahasiswa_id', '=', 'mahasiswa.id') + ->select('krs.*') + ->orderBy('mahasiswa.nim', $sortDirection); + } elseif ($sortColumn === 'prodi') { + $krsList = $krsList->join('mahasiswa', 'krs.mahasiswa_id', '=', 'mahasiswa.id') + ->join('prodi', 'mahasiswa.prodi_id', '=', 'prodi.id') + ->select('krs.*') + ->orderBy('prodi.nama', $sortDirection); + } elseif ($sortColumn === 'status') { + $krsList = $krsList->orderBy('status', $sortDirection); + } else { + $krsList = $krsList->orderBy('updated_at', 'desc'); + } + + $krsList = $krsList->paginate(config('siakad.pagination', 15))->withQueryString(); $statusCounts = [ 'pending' => Krs::where('status', 'pending')->count(), diff --git a/app/Http/Controllers/Admin/MahasiswaController.php b/app/Http/Controllers/Admin/MahasiswaController.php index 6b923a1..2ecb6bf 100644 --- a/app/Http/Controllers/Admin/MahasiswaController.php +++ b/app/Http/Controllers/Admin/MahasiswaController.php @@ -46,7 +46,31 @@ public function index(Request $request) $query->where('angkatan', $angkatan); } - $mahasiswa = $query->orderBy('nim')->paginate(config('siakad.pagination', 15)); + // Variable Sorting + $sortColumn = $request->get('sort', 'nim'); + $sortDirection = $request->get('order', 'asc'); + + if ($sortColumn === 'name') { + // Sort by relation is tricky in simple eloquent, usually requires join. + // For simplicity/performance without join, we skip or handle simpler. + // Let's use leftJoin for robust sorting if requested, + // OR just stick to basic column sorting + NIM default for now to avoid complexity risk. + // But User wants "Sort All Tables". + // Implementation detail: Simple sortBy on Collection is slow for Pagination. + // We use Join for Name sorting. + $query->join('users', 'mahasiswa.user_id', '=', 'users.id') + ->select('mahasiswa.*') // Avoid column collision + ->orderBy('users.name', $sortDirection); + } elseif ($sortColumn === 'prodi') { + $query->join('prodi', 'mahasiswa.prodi_id', '=', 'prodi.id') + ->select('mahasiswa.*') + ->orderBy('prodi.nama', $sortDirection); + } else { + // Default direct columns + $query->orderBy($sortColumn, $sortDirection); + } + + $mahasiswa = $query->paginate(config('siakad.pagination', 15))->withQueryString(); $fakultasList = \App\Models\Fakultas::orderBy('nama')->get(); // Eager load fakultas so we can access fakultas_id in the view for JS filtering @@ -56,6 +80,52 @@ public function index(Request $request) return view('admin.mahasiswa.index', compact('mahasiswa', 'fakultasList', 'prodiList', 'angkatanList')); } + public function export(Request $request) + { + $query = Mahasiswa::with(['user', 'prodi']); + + if ($search = $request->get('search')) { + $query->where(function ($q) use ($search) { + $q->where('nim', 'like', "%{$search}%") + ->orWhereHas('user', fn($q2) => $q2->where('name', 'like', "%{$search}%")); + }); + } + if ($fakultasId = $request->get('fakultas_id')) { + $query->whereHas('prodi', function ($q) use ($fakultasId) { + $q->where('fakultas_id', $fakultasId); + }); + } + if ($prodiId = $request->get('prodi_id')) { + $query->where('prodi_id', $prodiId); + } + if ($angkatan = $request->get('angkatan')) { + $query->where('angkatan', $angkatan); + } + + // Export usually sorted by NIM + $query->orderBy('nim'); + + return response()->streamDownload(function() use ($query) { + $handle = fopen('php://output', 'w'); + fputs($handle, "\xEF\xBB\xBF"); + fputcsv($handle, ['NIM', 'Nama Mahasiswa', 'Prodi', 'Angkatan', 'Status', 'IPK']); + + $query->chunk(500, function($rows) use ($handle) { + foreach ($rows as $row) { + fputcsv($handle, [ + $row->nim, + $row->user->name ?? '-', + $row->prodi->nama ?? '-', + $row->angkatan, + $row->status, + $row->ipk ?? 0, + ]); + } + }); + fclose($handle); + }, 'data-mahasiswa-' . date('Y-m-d') . '.csv'); + } + public function show(Mahasiswa $mahasiswa) { $mahasiswa->load(['user', 'prodi.fakultas', 'krs.tahunAkademik', 'krs.krsDetail.kelas.mataKuliah']); diff --git a/app/Http/Controllers/Admin/MataKuliahController.php b/app/Http/Controllers/Admin/MataKuliahController.php index de11a48..611beea 100644 --- a/app/Http/Controllers/Admin/MataKuliahController.php +++ b/app/Http/Controllers/Admin/MataKuliahController.php @@ -15,12 +15,92 @@ public function __construct(AkademikService $akademikService) $this->akademikService = $akademikService; } - public function index() + public function index(Request $request) { - $mataKuliah = $this->akademikService->getAllMataKuliah(); + $query = \App\Models\MataKuliah::query(); + + // 1. Filter Category (Prefix) + // Note: Using hardcoded logic matching the view's previous behavior + if ($request->filled('category')) { + $query->where('kode_mk', 'like', $request->category . '%'); + } + + // 2. Search + if ($request->filled('search')) { + $search = $request->search; + $query->where(function($q) use ($search) { + $q->where('nama_mk', 'like', "%{$search}%") + ->orWhere('kode_mk', 'like', "%{$search}%"); + }); + } + + // 3. Sorting + $sortColumn = $request->get('sort', 'kode_mk'); + $sortDirection = $request->get('order', 'asc'); + + // Whitelist columns to prevent SQL injection or errors + $allowedSorts = ['kode_mk', 'nama_mk', 'sks', 'semester', 'created_at']; + if (in_array($sortColumn, $allowedSorts)) { + $query->orderBy($sortColumn, $sortDirection); + } else { + $query->orderBy('kode_mk', 'asc'); + } + + // 4. Pagination + $mataKuliah = $query->paginate(50)->withQueryString(); + return view('admin.mata-kuliah.index', compact('mataKuliah')); } + public function export(Request $request) + { + $query = \App\Models\MataKuliah::query(); + + if ($request->filled('category')) { + $query->where('kode_mk', 'like', $request->category . '%'); + } + + if ($request->filled('search')) { + $search = $request->search; + $query->where(function($q) use ($search) { + $q->where('nama_mk', 'like', "%{$search}%") + ->orWhere('kode_mk', 'like', "%{$search}%"); + }); + } + + $sortColumn = $request->get('sort', 'kode_mk'); + $sortDirection = $request->get('order', 'asc'); + $allowedSorts = ['kode_mk', 'nama_mk', 'sks', 'semester', 'created_at']; + + if (in_array($sortColumn, $allowedSorts)) { + $query->orderBy($sortColumn, $sortDirection); + } + + return response()->streamDownload(function() use ($query) { + $handle = fopen('php://output', 'w'); + + // BOM for Excel + fputs($handle, "\xEF\xBB\xBF"); + + // Header + fputcsv($handle, ['Kode MK', 'Nama Mata Kuliah', 'SKS', 'Semester', 'Dibuat Pada']); + + $query->chunk(500, function($rows) use ($handle) { + foreach ($rows as $row) { + fputcsv($handle, [ + $row->kode_mk, + $row->nama_mk, + $row->sks, + $row->semester, + $row->created_at, + ]); + } + }); + + fclose($handle); + }, 'data-mata-kuliah-' . date('Y-m-d-H-i') . '.csv'); + } + public function store(Request $request) { $validated = $request->validate([ diff --git a/app/Http/Controllers/Admin/RuanganController.php b/app/Http/Controllers/Admin/RuanganController.php index b1aaf68..41987a2 100644 --- a/app/Http/Controllers/Admin/RuanganController.php +++ b/app/Http/Controllers/Admin/RuanganController.php @@ -8,14 +8,44 @@ class RuanganController extends Controller { - public function index() + public function index(Request $request) { - $ruanganList = Ruangan::orderBy('kode_ruangan')->get(); + // Query for Main Table (Paginated & Sorted) + $query = Ruangan::query(); + + // Search + if ($request->filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('nama_ruangan', 'like', "%{$search}%") + ->orWhere('kode_ruangan', 'like', "%{$search}%") + ->orWhere('gedung', 'like', "%{$search}%"); + }); + } + + // Sorting + $sortColumn = $request->get('sort', 'kode_ruangan'); + $sortDirection = $request->get('order', 'asc'); + $allowedSorts = ['kode_ruangan', 'nama_ruangan', 'kapasitas', 'gedung', 'lantai', 'is_active']; + + if (in_array($sortColumn, $allowedSorts)) { + $query->orderBy($sortColumn, $sortDirection); + } else { + $query->orderBy('kode_ruangan', 'asc'); + } + + $ruanganList = $query->paginate(20)->withQueryString(); - // Group by gedung for stats - $perGedung = $ruanganList->groupBy('gedung'); + // Stats Calculation (Separate from pagination) + // We can optimize this by doing direct aggregates instead of fetching all objects + $stats = [ + 'total' => Ruangan::count(), + 'active' => Ruangan::where('is_active', true)->count(), + 'capacity' => Ruangan::sum('kapasitas'), + 'gedung_count' => Ruangan::distinct('gedung')->count('gedung'), + ]; - return view('admin.ruangan.index', compact('ruanganList', 'perGedung')); + return view('admin.ruangan.index', compact('ruanganList', 'stats')); } public function store(Request $request) diff --git a/app/Http/Controllers/Admin/SkripsiController.php b/app/Http/Controllers/Admin/SkripsiController.php index f4aeddd..f7bf50b 100644 --- a/app/Http/Controllers/Admin/SkripsiController.php +++ b/app/Http/Controllers/Admin/SkripsiController.php @@ -28,7 +28,31 @@ public function index(Request $request) }); } - $skripsiList = $query->orderBy('created_at', 'desc')->paginate(20); + // Sorting + $sortColumn = $request->get('sort', 'created_at'); + $sortDirection = $request->get('order', 'desc'); + + if ($sortColumn === 'mahasiswa_name') { + $query->join('mahasiswa', 'skripsi.mahasiswa_id', '=', 'mahasiswa.id') + ->join('users', 'mahasiswa.user_id', '=', 'users.id') + ->select('skripsi.*') + ->orderBy('users.name', $sortDirection); + } elseif ($sortColumn === 'mahasiswa_nim') { + $query->join('mahasiswa', 'skripsi.mahasiswa_id', '=', 'mahasiswa.id') + ->select('skripsi.*') + ->orderBy('mahasiswa.nim', $sortDirection); + } elseif ($sortColumn === 'pembimbing_name') { + $query->leftJoin('dosen', 'skripsi.pembimbing1_id', '=', 'dosen.id') + ->leftJoin('users', 'dosen.user_id', '=', 'users.id') + ->select('skripsi.*') + ->orderBy('users.name', $sortDirection); + } elseif (in_array($sortColumn, ['judul', 'status', 'created_at'])) { + $query->orderBy($sortColumn, $sortDirection); + } else { + $query->orderBy('created_at', 'desc'); + } + + $skripsiList = $query->paginate(20)->withQueryString(); $dosenList = Dosen::with('user')->get(); $statusList = Skripsi::getStatusList(); diff --git a/app/Http/Controllers/HealthController.php b/app/Http/Controllers/HealthController.php new file mode 100644 index 0000000..e0c144d --- /dev/null +++ b/app/Http/Controllers/HealthController.php @@ -0,0 +1,100 @@ +json([ + 'status' => 'healthy', + 'timestamp' => now()->toIso8601String(), + 'app' => config('app.name'), + 'environment' => config('app.env'), + ]); + } + + /** + * Detailed health check with dependencies + */ + public function detailed(): JsonResponse + { + $checks = [ + 'database' => $this->checkDatabase(), + 'cache' => $this->checkCache(), + 'storage' => $this->checkStorage(), + ]; + + $allHealthy = collect($checks)->every(fn($check) => $check['status'] === 'ok'); + + return response()->json([ + 'status' => $allHealthy ? 'healthy' : 'degraded', + 'timestamp' => now()->toIso8601String(), + 'checks' => $checks, + 'version' => config('app.version', '1.0.0'), + ], $allHealthy ? 200 : 503); + } + + private function checkDatabase(): array + { + try { + $start = microtime(true); + DB::connection()->getPdo(); + $latency = round((microtime(true) - $start) * 1000, 2); + + return [ + 'status' => 'ok', + 'latency_ms' => $latency, + 'connection' => config('database.default'), + ]; + } catch (\Exception $e) { + return [ + 'status' => 'error', + 'message' => 'Database connection failed', + ]; + } + } + + private function checkCache(): array + { + try { + $key = 'health_check_' . uniqid(); + Cache::put($key, 'test', 10); + $value = Cache::get($key); + Cache::forget($key); + + return [ + 'status' => $value === 'test' ? 'ok' : 'error', + 'driver' => config('cache.default'), + ]; + } catch (\Exception $e) { + return [ + 'status' => 'error', + 'message' => 'Cache check failed', + ]; + } + } + + private function checkStorage(): array + { + try { + $writable = is_writable(storage_path()); + return [ + 'status' => $writable ? 'ok' : 'error', + 'path' => storage_path(), + ]; + } catch (\Exception $e) { + return [ + 'status' => 'error', + 'message' => 'Storage check failed', + ]; + } + } +} diff --git a/app/Http/Controllers/Mahasiswa/AiAdvisorController.php b/app/Http/Controllers/Mahasiswa/AiAdvisorController.php new file mode 100644 index 0000000..bfc133d --- /dev/null +++ b/app/Http/Controllers/Mahasiswa/AiAdvisorController.php @@ -0,0 +1,59 @@ +aiService = $aiService; + } + + /** + * Display AI Advisor chat page + */ + public function index() + { + $mahasiswa = Auth::user()->mahasiswa; + + if (!$mahasiswa) { + abort(403, 'Unauthorized'); + } + + $mahasiswa->load(['user', 'prodi']); + + return view('mahasiswa.ai-advisor.index', compact('mahasiswa')); + } + + /** + * Handle chat message + */ + public function chat(Request $request) + { + $request->validate([ + 'message' => 'required|string|max:1000', + 'history' => 'nullable|array', + ]); + + $mahasiswa = Auth::user()->mahasiswa; + + if (!$mahasiswa) { + return response()->json(['success' => false, 'message' => 'Unauthorized'], 403); + } + + $result = $this->aiService->chat( + $mahasiswa, + $request->input('message'), + $request->input('history', []) + ); + + return response()->json($result); + } +} diff --git a/app/Http/Controllers/Mahasiswa/DashboardController.php b/app/Http/Controllers/Mahasiswa/DashboardController.php index bb9507c..aa0bea3 100644 --- a/app/Http/Controllers/Mahasiswa/DashboardController.php +++ b/app/Http/Controllers/Mahasiswa/DashboardController.php @@ -34,12 +34,14 @@ public function index() // Get IPS History for chart $ipsHistory = $this->calculationService->getIPSHistory($mahasiswa); - // Calculate current semester IPS + // Current semester IPS = last semester with actual grades (IPS > 0) $activeTA = TahunAkademik::where('is_active', true)->first(); - $currentIps = $activeTA ? $this->calculationService->calculateIPS($mahasiswa, $activeTA->id) : null; + $semestersWithGrades = $ipsHistory->filter(fn($s) => $s['ips'] > 0); + $lastSemesterWithGrades = $semestersWithGrades->last(); + $currentIps = $lastSemesterWithGrades ? ['ips' => $lastSemesterWithGrades['ips'], 'total_sks' => $lastSemesterWithGrades['total_sks']] : null; - // Max SKS for next semester - $lastIps = $ipsHistory->last()['ips'] ?? 0; + // Max SKS for next semester based on last IPS + $lastIps = $lastSemesterWithGrades['ips'] ?? 0; $maxSks = $this->calculationService->getMaxSKS($lastIps); // SKS per semester for chart diff --git a/app/Http/Controllers/Mahasiswa/KhsController.php b/app/Http/Controllers/Mahasiswa/KhsController.php index d003a92..3bd6a4a 100644 --- a/app/Http/Controllers/Mahasiswa/KhsController.php +++ b/app/Http/Controllers/Mahasiswa/KhsController.php @@ -30,11 +30,11 @@ public function index() abort(403, 'Unauthorized'); } - // Get all semesters where student has KRS + // Get all semesters where student has KRS (oldest first for historical view) $semesterList = Krs::where('mahasiswa_id', $mahasiswa->id) ->where('status', 'approved') ->with('tahunAkademik') - ->orderBy('created_at', 'desc') + ->orderBy('tahun_akademik_id', 'asc') ->get() ->map(function ($krs) use ($mahasiswa) { $ipsData = $this->calculationService->calculateIPS($mahasiswa, $krs->tahun_akademik_id); diff --git a/app/Http/Controllers/Mahasiswa/KrsController.php b/app/Http/Controllers/Mahasiswa/KrsController.php index b27f700..1a7fe01 100644 --- a/app/Http/Controllers/Mahasiswa/KrsController.php +++ b/app/Http/Controllers/Mahasiswa/KrsController.php @@ -23,13 +23,16 @@ public function index() $krs = $this->krsService->getActiveKrsOrNew($mahasiswa); - // Load available classes (that are not yet taken) - // This logic is simple; for production need better filter - $availableKelas = \App\Models\Kelas::with(['mataKuliah', 'dosen']) + // Load available classes (that are not yet taken), grouped by semester + $availableKelas = \App\Models\Kelas::with(['mataKuliah', 'dosen.user', 'krsDetail']) ->whereDoesntHave('krsDetail', function($q) use ($krs) { $q->where('krs_id', $krs->id); }) - ->get(); + ->get() + ->groupBy(fn($k) => 'Semester ' . $k->mataKuliah->semester); + + // Sort by semester number + $availableKelas = $availableKelas->sortKeys(); return view('mahasiswa.krs.index', compact('krs', 'availableKelas')); } diff --git a/app/Http/Controllers/Mahasiswa/PresensiController.php b/app/Http/Controllers/Mahasiswa/PresensiController.php index 522d955..04cfc97 100644 --- a/app/Http/Controllers/Mahasiswa/PresensiController.php +++ b/app/Http/Controllers/Mahasiswa/PresensiController.php @@ -29,23 +29,30 @@ public function index() abort(403, 'Unauthorized'); } - // Get all kelas from approved KRS + // Get all kelas from approved KRS with tahun akademik info $kelasList = Kelas::whereHas('krsDetail', function ($q) use ($mahasiswa) { $q->whereHas('krs', fn($q2) => $q2 ->where('mahasiswa_id', $mahasiswa->id) ->where('status', 'approved') ); - })->with(['mataKuliah', 'dosen.user', 'jadwal'])->get(); + })->with(['mataKuliah', 'dosen.user', 'jadwal', 'krsDetail.krs.tahunAkademik'])->get(); - // Get rekap for each kelas + // Get rekap for each kelas and group by semester $rekapList = $kelasList->map(function ($kelas) use ($mahasiswa) { + $krs = $kelas->krsDetail->first()?->krs; + $ta = $krs?->tahunAkademik; return [ 'kelas' => $kelas, 'rekap' => $this->presensiService->getRekapPresensi($mahasiswa->id, $kelas->id), + 'semester' => $ta ? $ta->tahun . ' ' . ucfirst($ta->semester) : 'Unknown', + 'semester_order' => $ta ? $ta->id : 0, ]; }); - return view('mahasiswa.presensi.index', compact('mahasiswa', 'rekapList')); + // Group by semester - sort by semester_order desc first so latest semester is first + $rekapBySemester = $rekapList->sortByDesc('semester_order')->groupBy('semester'); + + return view('mahasiswa.presensi.index', compact('mahasiswa', 'rekapList', 'rekapBySemester')); } /** diff --git a/app/Http/Controllers/Mahasiswa/TranskripController.php b/app/Http/Controllers/Mahasiswa/TranskripController.php index 0ec9fe0..952729b 100644 --- a/app/Http/Controllers/Mahasiswa/TranskripController.php +++ b/app/Http/Controllers/Mahasiswa/TranskripController.php @@ -27,8 +27,9 @@ public function index() $ipsHistory = $this->calculationService->getIPSHistory($mahasiswa); $gradeDistribution = $this->calculationService->getGradeDistribution($mahasiswa); - // Calculate max SKS for next semester - $lastIps = $ipsHistory->last()['ips'] ?? 0; + // Calculate max SKS for next semester (use last semester WITH grades) + $semestersWithGrades = $ipsHistory->filter(fn($s) => $s['ips'] > 0); + $lastIps = $semestersWithGrades->last()['ips'] ?? 0; $maxSks = $this->calculationService->getMaxSKS($lastIps); return view('mahasiswa.transkrip.index', compact( diff --git a/app/Http/Middleware/RequestLoggingMiddleware.php b/app/Http/Middleware/RequestLoggingMiddleware.php new file mode 100644 index 0000000..445e065 --- /dev/null +++ b/app/Http/Middleware/RequestLoggingMiddleware.php @@ -0,0 +1,50 @@ +logRequest($request, $response, $duration); + } + + return $response; + } + + private function logRequest(Request $request, Response $response, float $duration): void + { + $context = [ + 'method' => $request->method(), + 'url' => $request->fullUrl(), + 'status' => $response->getStatusCode(), + 'duration_ms' => $duration, + 'ip' => $request->ip(), + 'user_id' => $request->user()?->id, + 'user_agent' => substr($request->userAgent() ?? '', 0, 100), + ]; + + // Log slow requests as warning + if ($duration > 1000) { + Log::warning('Slow request detected', $context); + } else { + Log::info('Request completed', $context); + } + } +} diff --git a/app/Http/Middleware/RoleMiddleware.php b/app/Http/Middleware/RoleMiddleware.php index 169ceab..e0ea9d4 100644 --- a/app/Http/Middleware/RoleMiddleware.php +++ b/app/Http/Middleware/RoleMiddleware.php @@ -17,11 +17,17 @@ class RoleMiddleware public function handle(Request $request, Closure $next, string $role): Response { if (!Auth::check()) { - return response()->json(['message' => 'Unauthenticated.'], 401); + if ($request->expectsJson()) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + return redirect()->route('login'); } if (Auth::user()->role !== $role) { - return response()->json(['message' => 'Unauthorized.'], 403); + if ($request->expectsJson()) { + return response()->json(['message' => 'Unauthorized.'], 403); + } + abort(403, 'Anda tidak memiliki akses ke halaman ini.'); } return $next($request); diff --git a/app/Http/Middleware/SecurityHeadersMiddleware.php b/app/Http/Middleware/SecurityHeadersMiddleware.php new file mode 100644 index 0000000..9cde9f7 --- /dev/null +++ b/app/Http/Middleware/SecurityHeadersMiddleware.php @@ -0,0 +1,40 @@ +headers->set('X-Frame-Options', 'SAMEORIGIN'); + + // Prevent MIME type sniffing + $response->headers->set('X-Content-Type-Options', 'nosniff'); + + // Enable XSS filter in browsers + $response->headers->set('X-XSS-Protection', '1; mode=block'); + + // Referrer policy + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + // Permissions policy (formerly Feature-Policy) + $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + + // Only add HSTS in production with HTTPS + if (config('app.env') === 'production' && $request->secure()) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + return $response; + } +} diff --git a/app/Services/AcademicAdvisor/AdvisorContextBuilder.php b/app/Services/AcademicAdvisor/AdvisorContextBuilder.php new file mode 100644 index 0000000..31e8555 --- /dev/null +++ b/app/Services/AcademicAdvisor/AdvisorContextBuilder.php @@ -0,0 +1,384 @@ +calculationService = $calculationService; + $this->presensiService = $presensiService; + } + + /** + * Build comprehensive context for AI Advisor + */ + public function build(Mahasiswa $mahasiswa): array + { + $mahasiswa->load(['user', 'prodi.fakultas', 'dosenPa.user']); + + $prodiKey = $this->getProdiKey($mahasiswa); + $rules = $this->getProdiRules($prodiKey); + + $academicSummary = $this->buildAcademicSummary($mahasiswa); + $courseStatuses = $this->buildCourseStatuses($mahasiswa, $prodiKey); + $curriculum = $this->getCurriculum($prodiKey); + $schedule = $this->getActiveSchedule($mahasiswa); + $attendance = $this->getAttendanceData($mahasiswa); + + return [ + 'student' => [ + 'nama' => $mahasiswa->user->name, + 'nim' => $mahasiswa->nim, + 'prodi' => $mahasiswa->prodi->nama ?? '-', + 'fakultas' => $mahasiswa->prodi->fakultas->nama ?? '-', + 'angkatan' => $mahasiswa->angkatan, + 'semester_aktif' => $this->calculateActiveSemester($mahasiswa), + 'status' => $mahasiswa->status, + 'dosen_pa' => $mahasiswa->dosenPa->user->name ?? '-', + ], + 'prodi_rules' => $rules, + 'academic_summary' => $academicSummary, + 'course_statuses' => $courseStatuses, + 'curriculum' => $curriculum, + 'schedule' => $schedule, + 'attendance' => $attendance, + 'metadata' => [ + 'generated_at' => now()->toIso8601String(), + 'prodi_key' => $prodiKey, + ], + ]; + } + + /** + * Get prodi key for config lookup + */ + protected function getProdiKey(Mahasiswa $mahasiswa): string + { + $prodiName = strtolower($mahasiswa->prodi->nama ?? ''); + + if (str_contains($prodiName, 'sistem informasi')) { + return 'sistem_informasi_unri'; + } + + return 'default'; + } + + /** + * Get rules for specific prodi + */ + protected function getProdiRules(string $prodiKey): array + { + $rules = config("academic_rules.prodi.{$prodiKey}"); + + if (!$rules) { + $rules = config('academic_rules.default'); + } + + return [ + 'graduation_total_sks' => $rules['graduation_total_sks'] ?? 144, + 'thesis_min_sks' => $rules['thesis_min_sks'] ?? 144, + 'internship_sks' => $rules['internship']['sks'] ?? 3, + 'internship_min_sks' => $rules['internship']['min_sks_required'] ?? 90, + ]; + } + + /** + * Build academic summary + */ + protected function buildAcademicSummary(Mahasiswa $mahasiswa): array + { + $ipkData = $this->calculationService->calculateIPK($mahasiswa); + $ipsHistory = $this->calculationService->getIPSHistory($mahasiswa); + + // Get current semester SKS (enrolled but not graded) + $sksSedangDiambil = $this->getEnrolledSks($mahasiswa); + + // Determine max SKS for next semester + $lastIps = $ipsHistory->filter(fn($s) => $s['ips'] > 0)->last()['ips'] ?? 0; + $maxSks = $this->calculationService->getMaxSKS($lastIps); + + return [ + 'total_sks_lulus' => $ipkData['total_sks'], + 'total_sks_sedang_diambil' => $sksSedangDiambil, + 'ipk' => $ipkData['ips'], + 'max_sks_next_semester' => $maxSks, + 'ips_history' => $ipsHistory->map(fn($s) => [ + 'semester' => $s['tahun_akademik'], + 'ips' => $s['ips'], + 'sks' => $s['total_sks'], + ])->values()->toArray(), + ]; + } + + /** + * Get enrolled SKS (current semester, not yet graded) + */ + protected function getEnrolledSks(Mahasiswa $mahasiswa): int + { + $activeKrs = Krs::where('mahasiswa_id', $mahasiswa->id) + ->where('status', 'approved') + ->whereHas('tahunAkademik', fn($q) => $q->where('is_active', true)) + ->with('krsDetail.kelas.mataKuliah') + ->first(); + + if (!$activeKrs) { + return 0; + } + + return $activeKrs->krsDetail->sum(fn($d) => $d->kelas->mataKuliah->sks ?? 0); + } + + /** + * Build course statuses (LULUS, SEDANG_DIAMBIL, TERSEDIA_DI_KURIKULUM) + */ + protected function buildCourseStatuses(Mahasiswa $mahasiswa, string $prodiKey): array + { + $statuses = []; + + // 1. LULUS - from Nilai (graded courses) + $nilaiList = Nilai::where('mahasiswa_id', $mahasiswa->id) + ->with('kelas.mataKuliah') + ->get(); + + foreach ($nilaiList as $nilai) { + $mk = $nilai->kelas->mataKuliah; + if ($mk) { + $statuses[$mk->kode_mk] = [ + 'kode' => $mk->kode_mk, + 'nama' => $mk->nama_mk, + 'sks' => $mk->sks, + 'status' => 'LULUS', + 'nilai' => $nilai->nilai_huruf, + 'semester' => $mk->semester, + ]; + } + } + + // 2. SEDANG_DIAMBIL - from active KRS without nilai + $activeKrsDetails = KrsDetail::whereHas('krs', function ($q) use ($mahasiswa) { + $q->where('mahasiswa_id', $mahasiswa->id) + ->where('status', 'approved') + ->whereHas('tahunAkademik', fn($ta) => $ta->where('is_active', true)); + }) + ->with('kelas.mataKuliah') + ->get(); + + foreach ($activeKrsDetails as $detail) { + $mk = $detail->kelas->mataKuliah; + if ($mk && !isset($statuses[$mk->kode_mk])) { + // Check if grade exists + $hasGrade = Nilai::where('mahasiswa_id', $mahasiswa->id) + ->where('kelas_id', $detail->kelas_id) + ->exists(); + + if (!$hasGrade) { + $statuses[$mk->kode_mk] = [ + 'kode' => $mk->kode_mk, + 'nama' => $mk->nama_mk, + 'sks' => $mk->sks, + 'status' => 'SEDANG_DIAMBIL', + 'nilai' => null, + 'semester' => $mk->semester, + ]; + } + } + } + + // 3. TERSEDIA_DI_KURIKULUM - from curriculum config + $curriculum = config("academic_rules.kurikulum.{$prodiKey}", []); + + foreach ($curriculum as $semester => $courses) { + foreach ($courses as $course) { + if (!isset($statuses[$course['kode']])) { + $statuses[$course['kode']] = [ + 'kode' => $course['kode'], + 'nama' => $course['nama'], + 'sks' => $course['sks'], + 'status' => 'TERSEDIA_DI_KURIKULUM', + 'nilai' => null, + 'semester' => $semester, + ]; + } + } + } + + return array_values($statuses); + } + + /** + * Get curriculum for specific prodi + */ + protected function getCurriculum(string $prodiKey): array + { + $curriculum = config("academic_rules.kurikulum.{$prodiKey}", []); + + $result = []; + foreach ($curriculum as $semester => $courses) { + $result[] = [ + 'semester' => $semester, + 'mata_kuliah' => $courses, + ]; + } + + return $result; + } + + /** + * Get active schedule + */ + protected function getActiveSchedule(Mahasiswa $mahasiswa): array + { + $jadwalList = JadwalKuliah::whereHas('kelas.krsDetail.krs', function ($q) use ($mahasiswa) { + $q->where('mahasiswa_id', $mahasiswa->id) + ->where('status', 'approved') + ->whereHas('tahunAkademik', fn($ta) => $ta->where('is_active', true)); + })->with('kelas.mataKuliah')->get(); + + return $jadwalList->map(fn($j) => [ + 'hari' => $j->hari, + 'jam_mulai' => substr($j->jam_mulai, 0, 5), + 'jam_selesai' => substr($j->jam_selesai, 0, 5), + 'mata_kuliah' => $j->kelas->mataKuliah->nama_mk ?? '-', + 'ruangan' => $j->ruangan ?? '-', + ])->toArray(); + } + + /** + * Get attendance data with availability flag + */ + protected function getAttendanceData(Mahasiswa $mahasiswa): array + { + $kelasList = \App\Models\Kelas::whereHas('krsDetail.krs', function ($q) use ($mahasiswa) { + $q->where('mahasiswa_id', $mahasiswa->id) + ->where('status', 'approved') + ->whereHas('tahunAkademik', fn($ta) => $ta->where('is_active', true)); + })->with('mataKuliah')->get(); + + $details = []; + $hasValidData = false; + $allZeroOrNull = true; + + foreach ($kelasList as $kelas) { + $rekap = $this->presensiService->getRekapPresensi($mahasiswa->id, $kelas->id); + + // Check if there's actual meeting data + $hasPertemuan = $rekap['total_pertemuan'] > 0; + $hasAttendance = ($rekap['hadir'] + $rekap['sakit'] + $rekap['izin'] + $rekap['alpa']) > 0; + + if ($hasPertemuan && $hasAttendance) { + $hasValidData = true; + $allZeroOrNull = false; + } + + $details[] = [ + 'mata_kuliah' => $kelas->mataKuliah->nama_mk ?? '-', + 'total_pertemuan' => $rekap['total_pertemuan'], + 'hadir' => $rekap['hadir'], + 'sakit' => $rekap['sakit'], + 'izin' => $rekap['izin'], + 'alpa' => $rekap['alpa'], + 'persentase' => $rekap['persentase'], + 'data_valid' => $hasPertemuan && $hasAttendance, + ]; + } + + return [ + 'data_available' => $hasValidData, + 'all_zero_or_null' => $allZeroOrNull, + 'warning' => $allZeroOrNull ? 'Data presensi belum diinput atau masih default' : null, + 'details' => $details, + ]; + } + + /** + * Calculate active semester based on angkatan + */ + protected function calculateActiveSemester(Mahasiswa $mahasiswa): int + { + $angkatan = (int) $mahasiswa->angkatan; + $currentYear = (int) date('Y'); + $currentMonth = (int) date('n'); + + // Academic year starts in August + $yearsEnrolled = $currentYear - $angkatan; + $semester = $yearsEnrolled * 2; + + // If before August, we're in the even semester of the previous academic year + if ($currentMonth >= 8) { + $semester += 1; // Odd semester (Ganjil) + } + + return max(1, $semester); + } + + /** + * Find a course by name in the context + */ + public function findCourseByName(array $context, string $courseName): ?array + { + $courseName = strtolower($courseName); + + // Search in course_statuses + foreach ($context['course_statuses'] as $course) { + if (str_contains(strtolower($course['nama']), $courseName)) { + return $course; + } + } + + // Search in curriculum + foreach ($context['curriculum'] as $semesterData) { + foreach ($semesterData['mata_kuliah'] as $course) { + if (str_contains(strtolower($course['nama']), $courseName)) { + return [ + 'kode' => $course['kode'], + 'nama' => $course['nama'], + 'sks' => $course['sks'], + 'status' => 'TERSEDIA_DI_KURIKULUM', + 'nilai' => null, + 'semester' => $semesterData['semester'], + ]; + } + } + } + + return null; + } + + /** + * Calculate graduation progress + */ + public function calculateGraduationProgress(array $context): array + { + $sksLulus = $context['academic_summary']['total_sks_lulus']; + $targetSks = $context['prodi_rules']['graduation_total_sks']; + $sksSedangDiambil = $context['academic_summary']['total_sks_sedang_diambil']; + + $sksRemaining = max(0, $targetSks - $sksLulus); + $progressPercent = $targetSks > 0 ? round(($sksLulus / $targetSks) * 100, 1) : 0; + + return [ + 'sks_lulus' => $sksLulus, + 'sks_target' => $targetSks, + 'sks_remaining' => $sksRemaining, + 'sks_sedang_diambil' => $sksSedangDiambil, + 'progress_percent' => $progressPercent, + 'eligible_thesis' => $sksLulus >= $context['prodi_rules']['thesis_min_sks'], + 'eligible_internship' => $sksLulus >= $context['prodi_rules']['internship_min_sks'], + ]; + } +} diff --git a/app/Services/AcademicAdvisor/AdvisorGuards.php b/app/Services/AcademicAdvisor/AdvisorGuards.php new file mode 100644 index 0000000..49b67a3 --- /dev/null +++ b/app/Services/AcademicAdvisor/AdvisorGuards.php @@ -0,0 +1,303 @@ +forbiddenPhrases = config('academic_rules.forbidden_assumption_phrases', [ + 'biasanya', + 'umumnya', + 'tergantung', + 'pada umumnya', + 'lazimnya', + 'seringkali', + 'mungkin sekitar', + ]); + } + + /** + * Assert that required rules are present in context + * + * @throws InvalidArgumentException + */ + public function assertRulesPresent(array $context): void + { + $requiredRules = [ + 'graduation_total_sks', + 'thesis_min_sks', + ]; + + foreach ($requiredRules as $rule) { + if (!isset($context['prodi_rules'][$rule]) || $context['prodi_rules'][$rule] <= 0) { + throw new InvalidArgumentException( + "Required academic rule '{$rule}' is missing or invalid in context." + ); + } + } + } + + /** + * Validate that context has minimum required data + * + * @throws InvalidArgumentException + */ + public function validateContext(array $context): void + { + $requiredKeys = ['student', 'prodi_rules', 'academic_summary']; + + foreach ($requiredKeys as $key) { + if (!isset($context[$key]) || empty($context[$key])) { + throw new InvalidArgumentException( + "Required context key '{$key}' is missing or empty." + ); + } + } + + if (!isset($context['student']['nim'])) { + throw new InvalidArgumentException( + "Student NIM is required in context." + ); + } + } + + /** + * Check if output contains generic assumption phrases + * + * @return array{status: string, violations: array, sanitized_output: string|null, retry_prompt: string|null} + */ + public function preventGenericAssumptions(string $output): array + { + $violations = []; + $outputLower = strtolower($output); + + foreach ($this->forbiddenPhrases as $phrase) { + if (str_contains($outputLower, strtolower($phrase))) { + $violations[] = $phrase; + } + } + + if (empty($violations)) { + return [ + 'status' => self::GUARD_PASS, + 'violations' => [], + 'sanitized_output' => null, + 'retry_prompt' => null, + ]; + } + + // Generate retry prompt for the model + $retryPrompt = $this->generateRetryPrompt($violations); + + // Also provide a sanitized version as fallback + $sanitizedOutput = $this->sanitizeOutput($output, $violations); + + return [ + 'status' => self::GUARD_RETRY, + 'violations' => $violations, + 'sanitized_output' => $sanitizedOutput, + 'retry_prompt' => $retryPrompt, + ]; + } + + /** + * Check attendance guard - prevent low attendance conclusions when data unavailable + * + * @return array{status: string, issue: string|null, recommended_response: string|null} + */ + public function attendanceGuard(array $context, string $output): array + { + // Check if attendance data is available + $attendanceDataAvailable = $context['attendance']['data_available'] ?? false; + $allZeroOrNull = $context['attendance']['all_zero_or_null'] ?? true; + + // If data is available and valid, pass the guard + if ($attendanceDataAvailable && !$allZeroOrNull) { + return [ + 'status' => self::GUARD_PASS, + 'issue' => null, + 'recommended_response' => null, + ]; + } + + // Check if output mentions low attendance + $lowAttendancePhrases = [ + 'presensi rendah', + 'kehadiran rendah', + 'jarang hadir', + 'sering tidak hadir', + 'tingkat kehadiran rendah', + 'absensi tinggi', + 'banyak alpa', + 'sering alpa', + 'kehadiran kurang', + 'presensi kurang', + ]; + + $outputLower = strtolower($output); + + foreach ($lowAttendancePhrases as $phrase) { + if (str_contains($outputLower, $phrase)) { + return [ + 'status' => self::GUARD_FAIL, + 'issue' => "Output menyebutkan '{$phrase}' tetapi data presensi belum tersedia/valid.", + 'recommended_response' => $this->generateAttendanceUnavailableResponse(), + ]; + } + } + + return [ + 'status' => self::GUARD_PASS, + 'issue' => null, + 'recommended_response' => null, + ]; + } + + /** + * Run all post-output guards + * + * @return array{passed: bool, issues: array, should_retry: bool, retry_prompt: string|null, replacement_output: string|null} + */ + public function runPostGuards(array $context, string $output): array + { + $issues = []; + $shouldRetry = false; + $retryPrompt = null; + $replacementOutput = null; + + // Check assumption guard + $assumptionResult = $this->preventGenericAssumptions($output); + if ($assumptionResult['status'] !== self::GUARD_PASS) { + $issues[] = [ + 'guard' => 'assumption', + 'violations' => $assumptionResult['violations'], + ]; + $shouldRetry = true; + $retryPrompt = $assumptionResult['retry_prompt']; + $replacementOutput = $assumptionResult['sanitized_output']; + } + + // Check attendance guard + $attendanceResult = $this->attendanceGuard($context, $output); + if ($attendanceResult['status'] !== self::GUARD_PASS) { + $issues[] = [ + 'guard' => 'attendance', + 'issue' => $attendanceResult['issue'], + ]; + // Attendance guard failure takes precedence if it fails + if ($replacementOutput === null) { + $replacementOutput = $attendanceResult['recommended_response']; + } + } + + return [ + 'passed' => empty($issues), + 'issues' => $issues, + 'should_retry' => $shouldRetry, + 'retry_prompt' => $retryPrompt, + 'replacement_output' => $replacementOutput, + ]; + } + + /** + * Generate retry prompt for assumption violations + */ + protected function generateRetryPrompt(array $violations): string + { + $violationsList = implode(', ', $violations); + + return <<| # | -Dosen | -NIDN | -Prodi | + + ++ + Dosen + + โฒ + โผ + + + | + + ++ + NIDN + + โฒ + โผ + + + | + + ++ + Prodi + + โฒ + โผ + + + | +Kelas Diampu | Mhs Bimbingan | Aksi |
|---|---|---|---|---|---|---|---|---|---|
| {{ $dosen->firstItem() + $index }} | @@ -79,10 +111,53 @@
{{ $d->nidn }}
+Tidak ada data dosen
+Kelola data fakultas dalam sistem
-| Dosen | Hadir | Izin | Sakit | Tugas | Alpa | % |
|---|---|---|---|---|---|---|
{{ $d->user->name }} {{ $d->nidn }} |
- {{ $rekap->where('status', 'hadir')->first()?->count ?? 0 }} | -{{ $rekap->where('status', 'izin')->first()?->count ?? 0 }} | -{{ $rekap->where('status', 'sakit')->first()?->count ?? 0 }} | -{{ $rekap->where('status', 'tugas')->first()?->count ?? 0 }} | -{{ $rekap->where('status', 'alpa')->first()?->count ?? 0 }} | -{{ $persen }}% | -
| Dosen | Hadir | Izin | Sakit | Tugas | Alpa | % |
|---|---|---|---|---|---|---|
{{ $d->user->name }} {{ $d->nidn }} |
+ {{ $rekap->where('status', 'hadir')->first()?->count ?? 0 }} | +{{ $rekap->where('status', 'izin')->first()?->count ?? 0 }} | +{{ $rekap->where('status', 'sakit')->first()?->count ?? 0 }} | +{{ $rekap->where('status', 'tugas')->first()?->count ?? 0 }} | +{{ $rekap->where('status', 'alpa')->first()?->count ?? 0 }} | +{{ $persen }}% | +
{{ $d->nidn }}
+| Tanggal | Dosen | Mata Kuliah | Jam | Status |
|---|---|---|---|---|
| {{ $k->tanggal->format('d M Y') }} | -{{ $k->dosen->user->name }} | -{{ $k->jadwalKuliah?->kelas?->mataKuliah?->nama ?? '-' }} | -{{ $k->jam_masuk ? substr($k->jam_masuk, 0, 5) : '-' }} - {{ $k->jam_keluar ? substr($k->jam_keluar, 0, 5) : '-' }} | -{{ $k->status_label }} | -
| Belum ada data | ||||
| Tanggal | Dosen | Mata Kuliah | Jam | Status |
|---|---|---|---|---|
| {{ $k->tanggal->format('d M Y') }} | +{{ $k->dosen->user->name }} | +{{ $k->jadwalKuliah?->kelas?->mataKuliah?->nama ?? '-' }} | +{{ $k->jam_masuk ? substr($k->jam_masuk, 0, 5) : '-' }} - {{ $k->jam_keluar ? substr($k->jam_keluar, 0, 5) : '-' }} | +{{ $k->status_label }} | +
| Belum ada data | ||||
Kelola data kelas dan jadwal kuliah dalam sistem
| # | -Kelas | -Mata Kuliah | -Dosen | + + ++ + Kelas + + โฒ + โผ + + + | + + ++ + Mata Kuliah + + โฒ + โผ + + + | + + ++ + Dosen + + โฒ + โผ + + + | +Jadwal | -Kapasitas | + + ++ + Kapasitas + + โฒ + โผ + + + | +Aksi | |||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ $index + 1 }} | +|||||||||||||
| {{ $kelas->firstItem() + $index }} | {{ $k->nama_kelas }} | @@ -75,12 +123,12 @@ 'jam_mulai' => $jadwal ? \Carbon\Carbon::parse($jadwal->jam_mulai)->format('H:i') : null, 'jam_selesai' => $jadwal ? \Carbon\Carbon::parse($jadwal->jam_selesai)->format('H:i') : null, 'ruangan' => $jadwal?->ruangan, - ]) }})" class="p-2 text-siakad-secondary hover:text-siakad-primary hover:bg-siakad-primary/10 rounded-lg transition"> + ]) }})" class="p-2 text-siakad-secondary hover:text-siakad-primary hover:bg-siakad-primary/10 rounded-lg transition" title="Edit"> @@ -89,19 +137,107 @@||||||||||||
|
-
-
+
-
-
- Belum ada data kelas - |
+ Tidak ada data kelas + Reset Filter |
||||||||||||
{{ $k->mataKuliah->kode_mk ?? '' }}
+Dosen Pengampu
+{{ $k->dosen->user->name ?? '-' }}
+Jadwal
+ @if($jadwal) +{{ $jadwal->hari }}
+{{ \Carbon\Carbon::parse($jadwal->jam_mulai)->format('H:i') }} - {{ \Carbon\Carbon::parse($jadwal->jam_selesai)->format('H:i') }}
+ @else + Belum diatur + @endif +Ruangan
+ {{ $jadwal->ruangan }} +Tidak ada data kelas
+ Reset Filter +| Mahasiswa | Perusahaan | Pembimbing | Status | Aksi |
|---|---|---|---|---|
| + + Mahasiswa + + โฒ + โผ + + + | + + ++ + Perusahaan + + โฒ + โผ + + + | + + ++ + Pembimbing + + โฒ + โผ + + + | + + ++ + Status + + โฒ + โผ + + + | + +Aksi | +
{{ $kp->mahasiswa->nim }}
+Perusahaan
+{{ $kp->nama_perusahaan }}
+Pembimbing
+ @if($kp->pembimbing) +{{ $kp->pembimbing->user->name }}
+ @else + Belum ditentukan + @endif +Tidak ada data KP
+| + + | # | -Mahasiswa | -NIM | -Prodi | + + ++ + Mahasiswa + + โฒ + โผ + + + | + + ++ + NIM + + โฒ + โผ + + + | + + ++ + Prodi + + โฒ + โผ + + + | +Total SKS | -Status | + + ++ + Status + + โฒ + โผ + + + | +Aksi |
|---|---|---|---|---|---|---|---|---|---|---|---|
| + + | {{ $index + 1 }} |
@@ -105,4 +189,102 @@
|
{{ $krs->mahasiswa->nim ?? '-' }}
+Tidak ada data KRS
+| # | -Mahasiswa | -NIM | -Prodi | -Angkatan | + + ++ + Mahasiswa + + โฒ + โผ + + + | + + ++ + NIM + + โฒ + โผ + + + | + + ++ + Prodi + + โฒ + โผ + + + | + + ++ + Angkatan + + โฒ + โผ + + + | +IPK | Status | Aksi |
|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ $mahasiswa->firstItem() + $index }} | @@ -148,6 +193,56 @@ function updateProdiOptions() {
{{ $m->nim }}
+Tidak ada data mahasiswa
+Kelola data mata kuliah berdasarkan kategori
-