From 0ca802adb4737df1092a421643688ac7cffa4467 Mon Sep 17 00:00:00 2001 From: Ryanda Valents Anakri Date: Sat, 13 Dec 2025 10:47:42 +0700 Subject: [PATCH 1/5] production bismillah --- .env.example | 65 ++++- .github/workflows/ci.yml | 82 ++++++ .../Controllers/Admin/DosenController.php | 18 +- .../Controllers/Admin/KelasController.php | 35 ++- app/Http/Controllers/Admin/KpController.php | 26 +- .../Admin/KrsApprovalController.php | 29 +- .../Controllers/Admin/MahasiswaController.php | 72 ++++- .../Admin/MataKuliahController.php | 84 +++++- .../Controllers/Admin/RuanganController.php | 40 ++- .../Controllers/Admin/SkripsiController.php | 26 +- app/Http/Controllers/HealthController.php | 100 +++++++ .../Middleware/RequestLoggingMiddleware.php | 50 ++++ app/Http/Middleware/RoleMiddleware.php | 10 +- .../Middleware/SecurityHeadersMiddleware.php | 40 +++ app/Services/AkademikService.php | 69 ++++- app/Services/KrsService.php | 82 +++--- app/Services/PenilaianService.php | 17 +- bootstrap/app.php | 23 ++ config/logging.php | 2 +- database/factories/DosenFactory.php | 25 ++ database/factories/FakultasFactory.php | 27 ++ database/factories/MahasiswaFactory.php | 27 ++ database/factories/ProdiFactory.php | 29 ++ database/factories/TahunAkademikFactory.php | 24 ++ resources/views/admin/dosen/index.blade.php | 95 ++++++- .../views/admin/fakultas/index.blade.php | 66 ++++- .../admin/kehadiran-dosen/index.blade.php | 184 ++++++++++--- resources/views/admin/kelas/index.blade.php | 182 +++++++++++-- resources/views/admin/kp/index.blade.php | 102 ++++++- .../views/admin/krs-approval/index.blade.php | 224 ++++++++++++++-- .../views/admin/mahasiswa/index.blade.php | 117 +++++++- .../views/admin/mata-kuliah/index.blade.php | 253 ++++++++++++------ resources/views/admin/prodi/index.blade.php | 67 ++++- resources/views/admin/ruangan/index.blade.php | 164 ++++++++++-- resources/views/admin/skripsi/index.blade.php | 111 +++++++- .../views/dosen/bimbingan/index.blade.php | 8 +- .../dosen/bimbingan/krs-approval.blade.php | 8 +- .../views/dosen/dashboard/index.blade.php | 97 ++++--- .../views/dosen/kehadiran/index.blade.php | 122 +++++---- .../views/dosen/penilaian/_cards.blade.php | 20 +- .../views/dosen/penilaian/index.blade.php | 8 +- .../views/dosen/penilaian/show.blade.php | 8 +- .../views/dosen/presensi/index.blade.php | 61 ++++- resources/views/dosen/skripsi/index.blade.php | 2 +- resources/views/errors/403.blade.php | 20 ++ resources/views/errors/404.blade.php | 20 ++ resources/views/errors/500.blade.php | 29 ++ resources/views/errors/503.blade.php | 19 ++ resources/views/layouts/app.blade.php | 184 +++++++++++-- .../views/mahasiswa/dashboard/index.blade.php | 4 +- .../views/mahasiswa/jadwal/index.blade.php | 2 +- resources/views/mahasiswa/khs/index.blade.php | 4 +- resources/views/mahasiswa/khs/show.blade.php | 40 +-- resources/views/mahasiswa/krs/index.blade.php | 9 +- .../views/mahasiswa/presensi/index.blade.php | 70 ++--- .../views/mahasiswa/transkrip/index.blade.php | 12 +- routes/web.php | 31 ++- tests/Feature/Admin/AdminCrudTest.php | 59 ++++ tests/Feature/Dosen/DosenFlowTest.php | 53 ++++ tests/Feature/HealthAndAuthTest.php | 50 ++++ tests/Feature/Krs/KrsFlowTest.php | 47 ++++ tests/Feature/Mahasiswa/MahasiswaFlowTest.php | 64 +++++ tests/Feature/Penilaian/PenilaianFlowTest.php | 43 +++ 63 files changed, 3119 insertions(+), 542 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 app/Http/Controllers/HealthController.php create mode 100644 app/Http/Middleware/RequestLoggingMiddleware.php create mode 100644 app/Http/Middleware/SecurityHeadersMiddleware.php create mode 100644 database/factories/DosenFactory.php create mode 100644 database/factories/FakultasFactory.php create mode 100644 database/factories/MahasiswaFactory.php create mode 100644 database/factories/ProdiFactory.php create mode 100644 database/factories/TahunAkademikFactory.php create mode 100644 resources/views/errors/403.blade.php create mode 100644 resources/views/errors/404.blade.php create mode 100644 resources/views/errors/500.blade.php create mode 100644 resources/views/errors/503.blade.php create mode 100644 tests/Feature/Admin/AdminCrudTest.php create mode 100644 tests/Feature/Dosen/DosenFlowTest.php create mode 100644 tests/Feature/HealthAndAuthTest.php create mode 100644 tests/Feature/Krs/KrsFlowTest.php create mode 100644 tests/Feature/Mahasiswa/MahasiswaFlowTest.php create mode 100644 tests/Feature/Penilaian/PenilaianFlowTest.php diff --git a/.env.example b/.env.example index c0660ea..02e10b7 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,89 @@ 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}" + 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/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/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/AkademikService.php b/app/Services/AkademikService.php index d25364b..aca954a 100644 --- a/app/Services/AkademikService.php +++ b/app/Services/AkademikService.php @@ -7,27 +7,69 @@ use App\Models\MataKuliah; use App\Models\Kelas; use App\Models\TahunAkademik; +use Illuminate\Support\Facades\Cache; class AkademikService { + // Cache TTL in seconds (1 hour) + protected const CACHE_TTL = 3600; + // --- Fakultas --- - public function getAllFakultas() { return Fakultas::all(); } - public function createFakultas($data) { return Fakultas::create($data); } + public function getAllFakultas() + { + return Cache::remember('master.fakultas', self::CACHE_TTL, function () { + return Fakultas::all(); + }); + } + + public function createFakultas($data) + { + Cache::forget('master.fakultas'); + return Fakultas::create($data); + } // --- Prodi --- - public function getAllProdi() { return Prodi::with('fakultas')->get(); } - public function createProdi($data) { return Prodi::create($data); } + public function getAllProdi() + { + return Cache::remember('master.prodi', self::CACHE_TTL, function () { + return Prodi::with('fakultas')->get(); + }); + } + + public function createProdi($data) + { + Cache::forget('master.prodi'); + return Prodi::create($data); + } // --- Mata Kuliah --- - public function getAllMataKuliah() { return MataKuliah::all(); } - public function createMataKuliah($data) { return MataKuliah::create($data); } + public function getAllMataKuliah() + { + return Cache::remember('master.mata_kuliah', self::CACHE_TTL, function () { + return MataKuliah::all(); + }); + } + + public function createMataKuliah($data) + { + Cache::forget('master.mata_kuliah'); + return MataKuliah::create($data); + } // --- Tahun Akademik --- - public function getActiveTahun() { return TahunAkademik::where('is_active', true)->first(); } + public function getActiveTahun() + { + return Cache::remember('master.tahun_aktif', self::CACHE_TTL, function () { + return TahunAkademik::where('is_active', true)->first(); + }); + } + public function activateTahun($id) { TahunAkademik::query()->update(['is_active' => false]); // Deactivate all - return TahunAkademik::where('id', $id)->update(['is_active' => true]); + $result = TahunAkademik::where('id', $id)->update(['is_active' => true]); + Cache::forget('master.tahun_aktif'); + return $result; } // --- Kelas --- @@ -35,4 +77,15 @@ public function createKelas($data) { $data['kapasitas'] = $data['kapasitas'] ?? config('siakad.kelas_kapasitas_default'); return Kelas::create($data); } + + /** + * Clear all master data caches + */ + public function clearAllCache(): void + { + Cache::forget('master.fakultas'); + Cache::forget('master.prodi'); + Cache::forget('master.mata_kuliah'); + Cache::forget('master.tahun_aktif'); + } } diff --git a/app/Services/KrsService.php b/app/Services/KrsService.php index 617925e..79f77f4 100644 --- a/app/Services/KrsService.php +++ b/app/Services/KrsService.php @@ -30,46 +30,48 @@ public function getActiveKrsOrNew(Mahasiswa $mahasiswa) public function addKelas(Krs $krs, $kelasId) { - if ($krs->status !== 'draft') { - throw new Exception('KRS sudah disubmit/final. Tidak bisa ubah.'); - } - - $kelas = Kelas::with('mataKuliah')->findOrFail($kelasId); - - // 1. Cek Kapasitas - $terisi = KrsDetail::where('kelas_id', $kelasId)->count(); - if ($terisi >= $kelas->kapasitas) { - throw new Exception("Kelas penuh! Kapasitas: {$kelas->kapasitas}"); - } - - // 2. Cek apakah mata kuliah sudah diambil di KRS ini (beda kelas) - $mkTaken = $krs->krsDetail()->whereHas('kelas', function($q) use ($kelas) { - $q->where('mata_kuliah_id', $kelas->mata_kuliah_id); - })->exists(); - - if ($mkTaken) { - throw new Exception("Mata kuliah {$kelas->mataKuliah->nama_mk} sudah diambil."); - } - - // 3. Cek Batas SKS - $sksSaatIni = $krs->krsDetail->sum(fn($detail) => $detail->kelas->mataKuliah->sks); - $sksBaru = $kelas->mataKuliah->sks; - - // Hitung jatah SKS (Logic IPS Semester Lalu) - // Untuk sederhananya kita ambil default atau logic real - // Disini kita ambil max sks dari config 'default' dulu jika IPS tidak ada - // TODO: Implement calculation based on IPS logic - $maxSks = config('siakad.maks_sks.default', 24); - - if (($sksSaatIni + $sksBaru) > $maxSks) { - throw new Exception("Melebihi batas SKS ({$maxSks}). Total SKS akan menjadi: " . ($sksSaatIni + $sksBaru)); - } - - // Add - return KrsDetail::create([ - 'krs_id' => $krs->id, - 'kelas_id' => $kelasId - ]); + return DB::transaction(function () use ($krs, $kelasId) { + if ($krs->status !== 'draft') { + throw new Exception('KRS sudah disubmit/final. Tidak bisa ubah.'); + } + + $kelas = Kelas::with('mataKuliah')->findOrFail($kelasId); + + // 1. Cek Kapasitas + $terisi = KrsDetail::where('kelas_id', $kelasId)->count(); + if ($terisi >= $kelas->kapasitas) { + throw new Exception("Kelas penuh! Kapasitas: {$kelas->kapasitas}"); + } + + // 2. Cek apakah mata kuliah sudah diambil di KRS ini (beda kelas) + $mkTaken = $krs->krsDetail()->whereHas('kelas', function($q) use ($kelas) { + $q->where('mata_kuliah_id', $kelas->mata_kuliah_id); + })->exists(); + + if ($mkTaken) { + throw new Exception("Mata kuliah {$kelas->mataKuliah->nama_mk} sudah diambil."); + } + + // 3. Cek Batas SKS + $sksSaatIni = $krs->krsDetail->sum(fn($detail) => $detail->kelas->mataKuliah->sks); + $sksBaru = $kelas->mataKuliah->sks; + + // Hitung jatah SKS (Logic IPS Semester Lalu) + // Untuk sederhananya kita ambil default atau logic real + // Disini kita ambil max sks dari config 'default' dulu jika IPS tidak ada + // TODO: Implement calculation based on IPS logic + $maxSks = config('siakad.maks_sks.default', 24); + + if (($sksSaatIni + $sksBaru) > $maxSks) { + throw new Exception("Melebihi batas SKS ({$maxSks}). Total SKS akan menjadi: " . ($sksSaatIni + $sksBaru)); + } + + // Add + return KrsDetail::create([ + 'krs_id' => $krs->id, + 'kelas_id' => $kelasId + ]); + }); } public function removeKelas(Krs $krs, $detailId) diff --git a/app/Services/PenilaianService.php b/app/Services/PenilaianService.php index c53e80a..cd5cb2f 100644 --- a/app/Services/PenilaianService.php +++ b/app/Services/PenilaianService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Nilai; +use Illuminate\Support\Facades\DB; use Exception; class PenilaianService @@ -37,12 +38,18 @@ private function getNilaiHuruf($angka) // Fallback default E return ['huruf' => 'E', 'bobot' => 0]; } + public function bulkInputNilai($kelasId, array $dataNilai) { - foreach ($dataNilai as $mahasiswaId => $nilaiAngka) { - if (is_null($nilaiAngka)) continue; - - $this->inputNilai($mahasiswaId, $kelasId, $nilaiAngka); - } + return DB::transaction(function () use ($kelasId, $dataNilai) { + $updated = 0; + foreach ($dataNilai as $mahasiswaId => $nilaiAngka) { + if (is_null($nilaiAngka)) continue; + + $this->inputNilai($mahasiswaId, $kelasId, $nilaiAngka); + $updated++; + } + return $updated; + }); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 20de776..4c14330 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,18 +3,41 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', health: '/up', + then: function () { + // Configure rate limiters + RateLimiter::for('krs', function (Request $request) { + return Limit::perMinute(10)->by($request->user()?->id ?: $request->ip()); + }); + + RateLimiter::for('penilaian', function (Request $request) { + return Limit::perMinute(20)->by($request->user()?->id ?: $request->ip()); + }); + + RateLimiter::for('sensitive', function (Request $request) { + return Limit::perMinute(30)->by($request->user()?->id ?: $request->ip()); + }); + }, ) ->withMiddleware(function (Middleware $middleware): void { + // Global middleware - applies to all requests + $middleware->append(\App\Http\Middleware\SecurityHeadersMiddleware::class); + + // Middleware aliases $middleware->alias([ 'role' => \App\Http\Middleware\RoleMiddleware::class, + 'log.requests' => \App\Http\Middleware\RequestLoggingMiddleware::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { // })->create(); + diff --git a/config/logging.php b/config/logging.php index 9e998a4..0d7ac26 100644 --- a/config/logging.php +++ b/config/logging.php @@ -18,7 +18,7 @@ | */ - 'default' => env('LOG_CHANNEL', 'stack'), + 'default' => env('LOG_CHANNEL', 'daily'), /* |-------------------------------------------------------------------------- diff --git a/database/factories/DosenFactory.php b/database/factories/DosenFactory.php new file mode 100644 index 0000000..3002c1c --- /dev/null +++ b/database/factories/DosenFactory.php @@ -0,0 +1,25 @@ + + */ +class DosenFactory extends Factory +{ + protected $model = Dosen::class; + + public function definition(): array + { + return [ + 'user_id' => User::factory()->state(['role' => 'dosen']), + 'nidn' => fake()->unique()->numerify('##########'), + 'prodi_id' => Prodi::factory(), + ]; + } +} diff --git a/database/factories/FakultasFactory.php b/database/factories/FakultasFactory.php new file mode 100644 index 0000000..213581c --- /dev/null +++ b/database/factories/FakultasFactory.php @@ -0,0 +1,27 @@ + + */ +class FakultasFactory extends Factory +{ + protected $model = Fakultas::class; + + public function definition(): array + { + return [ + 'nama' => fake()->randomElement([ + 'Fakultas Teknik', + 'Fakultas Ekonomi', + 'Fakultas Ilmu Komputer', + 'Fakultas Hukum', + 'Fakultas Kedokteran', + ]) . ' ' . fake()->randomNumber(2), + ]; + } +} diff --git a/database/factories/MahasiswaFactory.php b/database/factories/MahasiswaFactory.php new file mode 100644 index 0000000..916fa1e --- /dev/null +++ b/database/factories/MahasiswaFactory.php @@ -0,0 +1,27 @@ + + */ +class MahasiswaFactory extends Factory +{ + protected $model = Mahasiswa::class; + + public function definition(): array + { + return [ + 'user_id' => User::factory()->state(['role' => 'mahasiswa']), + 'prodi_id' => Prodi::factory(), + 'nim' => fake()->unique()->numerify('##########'), + 'angkatan' => fake()->numberBetween(2020, 2024), + 'status' => 'aktif', + ]; + } +} diff --git a/database/factories/ProdiFactory.php b/database/factories/ProdiFactory.php new file mode 100644 index 0000000..7bd800b --- /dev/null +++ b/database/factories/ProdiFactory.php @@ -0,0 +1,29 @@ + + */ +class ProdiFactory extends Factory +{ + protected $model = Prodi::class; + + public function definition(): array + { + return [ + 'fakultas_id' => Fakultas::factory(), + 'nama' => fake()->randomElement([ + 'Teknik Informatika', + 'Sistem Informasi', + 'Teknik Elektro', + 'Manajemen', + 'Akuntansi', + ]) . ' ' . fake()->randomNumber(2), + ]; + } +} diff --git a/database/factories/TahunAkademikFactory.php b/database/factories/TahunAkademikFactory.php new file mode 100644 index 0000000..8cbbcee --- /dev/null +++ b/database/factories/TahunAkademikFactory.php @@ -0,0 +1,24 @@ + + */ +class TahunAkademikFactory extends Factory +{ + protected $model = TahunAkademik::class; + + public function definition(): array + { + $tahun = fake()->numberBetween(2020, 2024); + return [ + 'tahun' => "{$tahun}/" . ($tahun + 1), + 'semester' => fake()->randomElement(['Ganjil', 'Genap']), + 'is_active' => false, + ]; + } +} diff --git a/resources/views/admin/dosen/index.blade.php b/resources/views/admin/dosen/index.blade.php index f5de5f8..7bb8d1c 100644 --- a/resources/views/admin/dosen/index.blade.php +++ b/resources/views/admin/dosen/index.blade.php @@ -20,21 +20,53 @@ -
+ +