890 lines
41 KiB
HTML
890 lines
41 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Simulasi KRS Dinamis - Kurikulum Informatika 2025</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap');
|
|
body { font-family: 'Inter', sans-serif; background-color: #f8fafc; }
|
|
|
|
/* Chart Sizing */
|
|
.chart-container { position: relative; width: 100%; height: 250px; }
|
|
|
|
/* Card Styles */
|
|
.course-card { transition: all 0.2s ease; cursor: grab; position: relative; overflow: hidden; user-select: none; }
|
|
.course-card:active { cursor: grabbing; }
|
|
.course-card:hover { transform: translateY(-2px); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); z-index: 10; }
|
|
.course-card.dragging { opacity: 0.5; border: 2px dashed #a8a29e; background-color: #f5f5f4; }
|
|
|
|
/* Validation Styles */
|
|
.course-card.violation { border: 2px solid #ef4444; background-color: #fef2f2; }
|
|
.course-card.violation .cat-bar { background-color: #ef4444 !important; }
|
|
.violation-badge { position: absolute; right: 0.5rem; bottom: 0.5rem; font-size: 0.65rem; color: #dc2626; font-weight: bold; display: flex; align-items: center; gap: 2px; }
|
|
|
|
/* Elective Styles */
|
|
.course-card.is-elective { background-color: #fffbeb; border-color: #fcd34d; }
|
|
|
|
/* Grade Badges */
|
|
.grade-badge { position: absolute; right: 0.5rem; top: 0.5rem; font-size: 0.7rem; font-weight: bold; padding: 0.1rem 0.4rem; border-radius: 0.25rem; }
|
|
.grade-A { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
|
|
.grade-B { background: #dbeafe; color: #1e40af; border: 1px solid #bfdbfe; }
|
|
.grade-C { background: #fef9c3; color: #854d0e; border: 1px solid #fde047; }
|
|
.grade-D { background: #ffedd5; color: #9a3412; border: 1px solid #fed7aa; }
|
|
.grade-E { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
|
|
|
|
/* Semester Styles */
|
|
.semester-container { transition: all 0.3s; }
|
|
.semester-container.overload { border-color: #ef4444; background-color: #fff1f2; }
|
|
.semester-list { min-height: 120px; transition: background-color 0.2s; border-radius: 0.5rem; border: 2px dashed #e2e8f0; }
|
|
.semester-list.drag-over { background-color: #f0fdf4; border-color: #4ade80; }
|
|
|
|
/* Sidebar */
|
|
#catalog-sidebar { transition: transform 0.3s ease-in-out; }
|
|
#catalog-sidebar.closed { transform: translateX(-100%); }
|
|
.custom-scroll::-webkit-scrollbar { width: 6px; }
|
|
.custom-scroll::-webkit-scrollbar-track { background: #f1f5f9; }
|
|
.custom-scroll::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
|
|
|
/* Context Menu */
|
|
#context-menu {
|
|
display: none; position: absolute; z-index: 100;
|
|
background: white; border: 1px solid #e2e8f0;
|
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
|
border-radius: 0.5rem; min-width: 180px; overflow: hidden;
|
|
}
|
|
#context-menu button { display: block; width: 100%; text-align: left; padding: 0.6rem 1rem; font-size: 0.875rem; color: #475569; transition: bg 0.1s; }
|
|
#context-menu button:hover { background-color: #f8fafc; color: #0f172a; }
|
|
#context-menu .menu-header { padding: 0.5rem 1rem; font-size: 0.7rem; font-weight: bold; color: #94a3b8; background: #f8fafc; letter-spacing: 0.05em; }
|
|
|
|
/* Retake Button Style */
|
|
#btn-retake { background-color: #eff6ff; color: #1d4ed8; font-weight: 600; border-bottom: 1px solid #dbeafe; }
|
|
#btn-retake:hover { background-color: #dbeafe; }
|
|
|
|
.cat-bar { position: absolute; left: 0; top: 0; bottom: 0; width: 6px; }
|
|
|
|
/* Toast Notification */
|
|
#toast {
|
|
visibility: hidden; min-width: 250px; background-color: #333; color: #fff; text-align: center; border-radius: 8px; padding: 12px;
|
|
position: fixed; z-index: 100; left: 50%; bottom: 30px; transform: translateX(-50%); font-size: 14px; opacity: 0; transition: opacity 0.3s;
|
|
}
|
|
#toast.show { visibility: visible; opacity: 1; }
|
|
|
|
/* Layout Fixes */
|
|
.main-container {
|
|
width: 100%;
|
|
max-width: 1280px;
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
padding-left: 1rem;
|
|
padding-right: 1rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="bg-slate-50 text-slate-800 overflow-x-hidden">
|
|
|
|
<!-- Catalog Sidebar -->
|
|
<div id="catalog-sidebar" class="fixed top-0 left-0 h-full w-80 bg-white shadow-xl z-50 border-r border-slate-200 flex flex-col closed">
|
|
<div class="p-4 border-b border-slate-200 bg-slate-50 flex justify-between items-center">
|
|
<div>
|
|
<h2 class="font-bold text-slate-800">Katalog Mata Kuliah</h2>
|
|
<p class="text-xs text-slate-500">Tarik ke semester untuk mengambil.</p>
|
|
</div>
|
|
<button onclick="toggleCatalog()" class="text-slate-400 hover:text-slate-600"><i class="fas fa-times"></i></button>
|
|
</div>
|
|
<div class="p-2 border-b border-slate-100">
|
|
<input type="text" id="search-catalog" placeholder="Cari mata kuliah..." class="w-full text-sm p-2 border rounded bg-slate-50 focus:outline-none focus:border-blue-400" onkeyup="filterCatalog()">
|
|
</div>
|
|
<div id="catalog-list" class="flex-1 overflow-y-auto p-3 space-y-2 custom-scroll"></div>
|
|
</div>
|
|
|
|
<!-- Toggle Button (Floating Left) -->
|
|
<button onclick="toggleCatalog()" class="fixed left-0 top-24 bg-blue-600 text-white p-3 rounded-r-lg shadow-lg z-40 hover:bg-blue-700 transition-all flex items-center gap-2 font-semibold text-sm group">
|
|
<i class="fas fa-book-open"></i> <span class="hidden group-hover:inline">Katalog</span>
|
|
</button>
|
|
|
|
<!-- Toast -->
|
|
<div id="toast">Notifikasi</div>
|
|
|
|
<!-- Context Menu -->
|
|
<div id="context-menu">
|
|
<div class="menu-header">OPSI AKADEMIK</div>
|
|
<!-- Retake Button (Hidden by default) -->
|
|
<button id="btn-retake" onclick="retakeCourse()" style="display:none;">
|
|
<i class="fas fa-redo-alt mr-2"></i> Ulang di Tahun Depan
|
|
</button>
|
|
|
|
<div class="menu-header mt-1">SET NILAI (SIMULASI)</div>
|
|
<button onclick="setCourseGrade(4)"><span class="w-6 inline-block text-center font-bold text-green-600">A</span> (4.0) - Sangat Baik</button>
|
|
<button onclick="setCourseGrade(3)"><span class="w-6 inline-block text-center font-bold text-blue-600">B</span> (3.0) - Baik</button>
|
|
<button onclick="setCourseGrade(2)"><span class="w-6 inline-block text-center font-bold text-yellow-600">C</span> (2.0) - Cukup</button>
|
|
<button onclick="setCourseGrade(1)"><span class="w-6 inline-block text-center font-bold text-orange-600">D</span> (1.0) - Kurang</button>
|
|
<button onclick="setCourseGrade(0)" class="text-red-600 bg-red-50 hover:bg-red-100"><span class="w-6 inline-block text-center font-bold">E</span> (0.0) - Gagal/Ulang</button>
|
|
<div class="border-t border-slate-100 my-1"></div>
|
|
<button onclick="deleteCourse()" class="text-slate-500 hover:text-red-600"><i class="fas fa-trash w-6 mr-2"></i> Hapus dari KRS</button>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<div class="main-container py-8 transition-all duration-300">
|
|
|
|
<header class="mb-8 text-center">
|
|
<h1 class="text-3xl md:text-4xl font-bold text-slate-900 mb-2">Simulasi Studi Berbasis IPS - Kurikulum Informatika 2025</h1>
|
|
<p class="text-slate-600 max-w-2xl mx-auto text-sm md:text-base">
|
|
Simulasi nyata: <strong>IPS Semester lalu menentukan jatah SKS semester depan.</strong><br>
|
|
Jika IPS rendah, Anda <strong>wajib mengurangi SKS</strong> di semester berikutnya.
|
|
</p>
|
|
|
|
<div class="mt-6 flex flex-wrap justify-center gap-3 text-xs font-semibold opacity-80">
|
|
<div class="flex items-center gap-2"><span class="w-3 h-3 bg-pink-400 rounded-sm"></span> MKWU</div>
|
|
<div class="flex items-center gap-2"><span class="w-3 h-3 bg-green-500 rounded-sm"></span> Science</div>
|
|
<div class="flex items-center gap-2"><span class="w-3 h-3 bg-blue-400 rounded-sm"></span> Fund</div>
|
|
<div class="flex items-center gap-2"><span class="w-3 h-3 bg-orange-400 rounded-sm"></span> Core</div>
|
|
<div class="flex items-center gap-2"><span class="w-3 h-3 bg-purple-400 rounded-sm"></span> Adv</div>
|
|
<div class="flex items-center gap-2"><span class="w-3 h-3 bg-yellow-400 rounded-sm"></span> Pilihan</div>
|
|
</div>
|
|
|
|
<!-- Global Stats -->
|
|
<div class="mt-6 grid grid-cols-2 md:grid-cols-4 gap-4 max-w-4xl mx-auto">
|
|
<div class="bg-white p-4 rounded-xl shadow-sm border border-slate-200">
|
|
<div class="text-3xl font-bold text-slate-800" id="total-sks-passed">0</div>
|
|
<div class="text-[10px] text-slate-500 uppercase tracking-wider mt-1">Total SKS Lulus</div>
|
|
</div>
|
|
<div class="bg-white p-4 rounded-xl shadow-sm border border-slate-200">
|
|
<div class="text-3xl font-bold text-blue-600" id="ipk-global">0.00</div>
|
|
<div class="text-[10px] text-slate-500 uppercase tracking-wider mt-1">IPK Kumulatif</div>
|
|
</div>
|
|
<div class="bg-white p-4 rounded-xl shadow-sm border border-slate-200">
|
|
<div class="text-2xl font-bold text-slate-800" id="predikat-ipk">-</div>
|
|
<div class="text-[10px] text-slate-500 uppercase tracking-wider mt-1">Predikat</div>
|
|
</div>
|
|
<div class="bg-white p-4 rounded-xl shadow-sm border border-slate-200">
|
|
<div class="text-3xl font-bold text-red-500" id="total-fail">0</div>
|
|
<div class="text-[10px] text-slate-500 uppercase tracking-wider mt-1">SKS Gagal (E)</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Track Selector -->
|
|
<div class="flex flex-wrap justify-center mb-10 gap-2">
|
|
<button onclick="switchTrack('general')" class="track-btn active px-4 py-2 rounded-full text-xs font-bold border transition-colors bg-slate-800 text-white" id="btn-general">Umum (Campuran)</button>
|
|
<button onclick="switchTrack('ai')" class="track-btn px-4 py-2 rounded-full text-xs font-bold border transition-colors bg-white hover:bg-slate-50" id="btn-ai">AI & Data</button>
|
|
<button onclick="switchTrack('rpl')" class="track-btn px-4 py-2 rounded-full text-xs font-bold border transition-colors bg-white hover:bg-slate-50" id="btn-rpl">RPL</button>
|
|
<button onclick="switchTrack('net')" class="track-btn px-4 py-2 rounded-full text-xs font-bold border transition-colors bg-white hover:bg-slate-50" id="btn-net">Jaringan</button>
|
|
<button onclick="switchTrack('si')" class="track-btn px-4 py-2 rounded-full text-xs font-bold border transition-colors bg-white hover:bg-slate-50" id="btn-si">SI & GIS</button>
|
|
</div>
|
|
|
|
<!-- Semesters Grid -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8 mb-12" id="semester-grid">
|
|
<!-- Semesters 1-8 will be injected here -->
|
|
</div>
|
|
|
|
<!-- Charts -->
|
|
<section class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-12 bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
|
|
<div>
|
|
<h3 class="text-sm font-bold text-slate-800 mb-4 border-b pb-2">Tren IPS (Indeks Prestasi Semester)</h3>
|
|
<div class="chart-container">
|
|
<canvas id="ipsChart"></canvas>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 class="text-sm font-bold text-slate-800 mb-4 border-b pb-2">Komposisi Bidang Ilmu</h3>
|
|
<div class="chart-container">
|
|
<canvas id="domainChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
// --- UTILS: Fix for HTTP Environments ---
|
|
function generateUUID() {
|
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
return crypto.randomUUID();
|
|
}
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
|
return v.toString(16);
|
|
});
|
|
}
|
|
|
|
// --- Data Definitions ---
|
|
const categories = {
|
|
MKWU: { color: 'bg-pink-400' },
|
|
BSC: { color: 'bg-green-500' },
|
|
FND: { color: 'bg-blue-400' },
|
|
CORE: { color: 'bg-orange-400' },
|
|
ADV: { color: 'bg-purple-400' },
|
|
PROF: { color: 'bg-slate-400' },
|
|
CAP: { color: 'bg-fuchsia-600' },
|
|
EL: { color: 'bg-yellow-400' },
|
|
RES: { color: 'bg-red-500' }
|
|
};
|
|
|
|
const prerequisites = {
|
|
"M14": { reqId: "M7", reqName: "Logika Komputasional" },
|
|
"M19": { reqId: "M14", reqName: "Dasar Pemrograman" },
|
|
"M28": { reqId: "M20", reqName: "Jaringan Komputer" },
|
|
"M31": { reqId: "M28", reqName: "Pemrograman Jaringan" }
|
|
};
|
|
|
|
const baseCurriculum = {
|
|
1: [
|
|
{ id: "M1", name: "Pend. Pancasila", sks: 2, cat: "MKWU" },
|
|
{ id: "M2", name: "B. Inggris", sks: 2, cat: "MKWU" },
|
|
{ id: "M3", name: "Pend. Agama", sks: 3, cat: "MKWU" },
|
|
{ id: "M4", name: "Kalkulus", sks: 3, cat: "BSC" },
|
|
{ id: "M5", name: "Matematika Diskrit", sks: 3, cat: "BSC" },
|
|
{ id: "M6", name: "Pengantar Informatika", sks: 2, cat: "FND" },
|
|
{ id: "M7", name: "Logika Komputasional", sks: 2, cat: "FND" },
|
|
{ id: "M8", name: "Kom. Profesional", sks: 2, cat: "PROF" }
|
|
],
|
|
2: [
|
|
{ id: "M9", name: "Kewarganegaraan", sks: 2, cat: "MKWU" },
|
|
{ id: "M10", name: "B. Indonesia", sks: 2, cat: "MKWU" },
|
|
{ id: "M11", name: "Aljabar Linier", sks: 3, cat: "BSC" },
|
|
{ id: "M12", name: "Sistem Digital", sks: 2, cat: "FND" },
|
|
{ id: "M13", name: "Sistem Operasi", sks: 3, cat: "FND" },
|
|
{ id: "M14", name: "Dasar Pemrograman", sks: 3, cat: "FND" },
|
|
{ id: "M15", name: "Org. Komputer", sks: 3, cat: "FND" },
|
|
{ id: "M16", name: "Literasi Digital", sks: 2, cat: "PROF" }
|
|
],
|
|
3: [
|
|
{ id: "M17", name: "Metode Numerik", sks: 3, cat: "BSC" },
|
|
{ id: "M18", name: "Basis Data", sks: 3, cat: "FND" },
|
|
{ id: "M19", name: "Struktur Data", sks: 3, cat: "FND" },
|
|
{ id: "M20", name: "Jaringan Komputer", sks: 3, cat: "FND" },
|
|
{ id: "M21", name: "Sistem Informasi", sks: 2, cat: "CORE" },
|
|
{ id: "M22", name: "RPL", sks: 3, cat: "CORE" },
|
|
{ id: "M23", name: "Etika Komputasi", sks: 2, cat: "PROF" }
|
|
],
|
|
4: [
|
|
{ id: "M24", name: "Probstat", sks: 3, cat: "BSC" },
|
|
{ id: "M25", name: "PBO", sks: 3, cat: "FND" },
|
|
{ id: "M26", name: "Analsis SI", sks: 3, cat: "CORE" },
|
|
{ id: "M27", name: "Strategi Algoritma", sks: 3, cat: "CORE" },
|
|
{ id: "M28", name: "Pemrog. Jaringan", sks: 3, cat: "CORE" },
|
|
{ id: "M29", name: "Sis. Terdistribusi", sks: 2, cat: "CORE" },
|
|
{ id: "M30", name: "IMK", sks: 2, cat: "CORE" }
|
|
],
|
|
5: [
|
|
{ id: "M31", name: "Manajemen Jaringan", sks: 3, cat: "CORE" },
|
|
{ id: "M32", name: "Pemrog. Web", sks: 3, cat: "CORE" },
|
|
{ id: "M33", name: "Machine Learning", sks: 3, cat: "ADV" },
|
|
{ id: "M34", name: "IoT", sks: 3, cat: "ADV" },
|
|
{ id: "M35", name: "Kecerdasan Artifisial", sks: 3, cat: "ADV" },
|
|
{ id: "M36", name: "Sistem Enterprise", sks: 3, cat: "ADV" },
|
|
{ id: "M37", name: "Metpen", sks: 2, cat: "RES" }
|
|
],
|
|
6: [
|
|
{ id: "M38", name: "Keamanan Info", sks: 3, cat: "CORE" },
|
|
{ id: "M39", name: "Computer Vision", sks: 3, cat: "ADV" },
|
|
{ id: "M40", name: "NLP", sks: 3, cat: "ADV" },
|
|
{ id: "M41", name: "GIS", sks: 3, cat: "ADV" },
|
|
{ id: "M42", name: "Proyek PL", sks: 3, cat: "CAP" },
|
|
{ id: "M43", name: "Kerja Praktek", sks: 2, cat: "RES" }
|
|
],
|
|
7: [
|
|
{ id: "M44", name: "Testing", sks: 3, cat: "ADV" },
|
|
{ id: "M45", name: "Teknopreneur", sks: 3, cat: "PROF" },
|
|
{ id: "M46", name: "PMKM", sks: 2, cat: "PROF" },
|
|
{ id: "M47", name: "Proposal Skripsi", sks: 2, cat: "RES" }
|
|
],
|
|
8: [
|
|
{ id: "M48", name: "Skripsi", sks: 4, cat: "RES" }
|
|
]
|
|
};
|
|
|
|
const electives = {
|
|
general: [
|
|
{ id: "GEN1", name: "Cross-Platform Dev", sks: 3, cat: "EL" },
|
|
{ id: "GEN2", name: "Data Mining", sks: 3, cat: "EL" },
|
|
{ id: "GEN3", name: "Cloud Computing", sks: 3, cat: "EL" },
|
|
{ id: "GEN4", name: "E-Business", sks: 3, cat: "EL" },
|
|
{ id: "GEN5", name: "Blockchain Tech", sks: 3, cat: "EL" }
|
|
],
|
|
ai: [
|
|
{ id: "AI1", name: "Deep Learning", sks: 3, cat: "EL" },
|
|
{ id: "AI2", name: "Robotics", sks: 3, cat: "EL" },
|
|
{ id: "AI3", name: "Big Data", sks: 3, cat: "EL" },
|
|
{ id: "AI4", name: "Info Retrieval", sks: 3, cat: "EL" },
|
|
{ id: "AI5", name: "AI Ethics", sks: 3, cat: "EL" }
|
|
],
|
|
rpl: [
|
|
{ id: "SE1", name: "Backend Dev", sks: 3, cat: "EL" },
|
|
{ id: "SE2", name: "Mobile Prog", sks: 3, cat: "EL" },
|
|
{ id: "SE3", name: "Soft. Arch", sks: 3, cat: "EL" },
|
|
{ id: "SE4", name: "DevOps", sks: 3, cat: "EL" },
|
|
{ id: "SE5", name: "Game Dev", sks: 3, cat: "EL" }
|
|
],
|
|
net: [
|
|
{ id: "NET1", name: "Net Security", sks: 3, cat: "EL" },
|
|
{ id: "NET2", name: "Wireless", sks: 3, cat: "EL" },
|
|
{ id: "NET3", name: "Cloud Comp", sks: 3, cat: "EL" },
|
|
{ id: "NET4", name: "Blockchain", sks: 3, cat: "EL" },
|
|
{ id: "NET5", name: "Cyber Forensics", sks: 3, cat: "EL" }
|
|
],
|
|
si: [
|
|
{ id: "SI1", name: "Business Intel.", sks: 3, cat: "EL" },
|
|
{ id: "SI2", name: "E-Business", sks: 3, cat: "EL" },
|
|
{ id: "SI3", name: "Ent. Architecture", sks: 3, cat: "EL" },
|
|
{ id: "SI4", name: "IT Governance", sks: 3, cat: "EL" },
|
|
{ id: "SI5", name: "Smart City", sks: 3, cat: "EL" }
|
|
]
|
|
};
|
|
|
|
// --- State Management ---
|
|
let currentPlan = {};
|
|
let currentTrackId = 'general';
|
|
let contextTarget = null;
|
|
|
|
// --- Core Logic ---
|
|
|
|
function initPlan(trackId) {
|
|
currentTrackId = trackId;
|
|
currentPlan = {};
|
|
|
|
for(let i=1; i<=8; i++) {
|
|
currentPlan[i] = [];
|
|
if(baseCurriculum[i]) {
|
|
currentPlan[i] = baseCurriculum[i].map(c => ({...c, grade: 4, uuid: generateUUID()}));
|
|
}
|
|
}
|
|
|
|
const tr = electives[trackId] || electives['general'];
|
|
const distribute = [[5,0], [6,1], [7,2], [7,3], [8,4]];
|
|
distribute.forEach(([sem, idx]) => {
|
|
if(tr[idx]) {
|
|
currentPlan[sem].push({
|
|
...tr[idx],
|
|
grade: 4,
|
|
uuid: generateUUID(),
|
|
isElective: true
|
|
});
|
|
}
|
|
});
|
|
|
|
renderCatalog();
|
|
renderAll();
|
|
}
|
|
|
|
function checkPrerequisite(courseId, targetSem) {
|
|
const rule = prerequisites[courseId];
|
|
if (!rule) return { allowed: true };
|
|
|
|
let passed = false;
|
|
for (let i = 1; i < targetSem; i++) {
|
|
if (!currentPlan[i]) continue;
|
|
const found = currentPlan[i].find(c => c.id === rule.reqId && c.grade > 0);
|
|
if (found) {
|
|
passed = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!passed) {
|
|
return { allowed: false, msg: `Syarat: Lulus ${rule.reqName} di semester sebelumnya.` };
|
|
}
|
|
return { allowed: true };
|
|
}
|
|
|
|
// --- Fixed Stat Calculations ---
|
|
function calculateSemStats(semId) {
|
|
const courses = currentPlan[semId] || [];
|
|
let sksAttempted = 0; // Divisor IPS (Termasuk E)
|
|
let sksPassed = 0; // Total SKS Lulus (Grade > 0)
|
|
let totalPoints = 0; // Numerator IPS
|
|
|
|
courses.forEach(c => {
|
|
sksAttempted += c.sks;
|
|
totalPoints += (c.sks * c.grade); // Grade 0 * sks = 0
|
|
if (c.grade > 0) {
|
|
sksPassed += c.sks;
|
|
}
|
|
});
|
|
|
|
// IPS = Total Points / SKS Attempted
|
|
const ips = sksAttempted > 0 ? (totalPoints / sksAttempted) : 0;
|
|
|
|
return { ips, sksAttempted, sksPassed, totalPoints };
|
|
}
|
|
|
|
function getMaxSKS(prevIPS) {
|
|
if (prevIPS >= 3.00) return 24;
|
|
if (prevIPS >= 2.50) return 22;
|
|
if (prevIPS >= 2.00) return 20;
|
|
return 18;
|
|
}
|
|
|
|
function renderAll() {
|
|
const grid = document.getElementById('semester-grid');
|
|
if (!grid) return;
|
|
grid.innerHTML = '';
|
|
|
|
// First Pass: Check Violations
|
|
for(let i=1; i<=8; i++) {
|
|
currentPlan[i].forEach(course => {
|
|
const check = checkPrerequisite(course.id, i);
|
|
course.violation = !check.allowed;
|
|
course.violationMsg = check.msg;
|
|
});
|
|
}
|
|
|
|
for(let i=1; i<=8; i++) {
|
|
let maxSKS = 20;
|
|
let prevIPS = 0;
|
|
|
|
if (i > 1) {
|
|
const prevStats = calculateSemStats(i-1);
|
|
prevIPS = prevStats.ips;
|
|
maxSKS = getMaxSKS(prevIPS);
|
|
}
|
|
|
|
const currentStats = calculateSemStats(i);
|
|
// Use sksAttempted for load display
|
|
const currentLoad = currentStats.sksAttempted;
|
|
const isOverload = currentLoad > maxSKS;
|
|
|
|
const semDiv = document.createElement('div');
|
|
semDiv.className = `semester-container bg-white rounded-xl shadow-sm border ${isOverload ? 'overload' : 'border-slate-200'} overflow-hidden flex flex-col h-full`;
|
|
|
|
let headerContent = `
|
|
<div class="flex justify-between items-center mb-1">
|
|
<h3 class="font-bold text-slate-700">Semester ${i}</h3>
|
|
<span class="text-xs font-bold ${isOverload ? 'text-red-600' : 'text-slate-500'}">
|
|
${currentLoad} / ${maxSKS} SKS
|
|
</span>
|
|
</div>
|
|
`;
|
|
|
|
if (i > 1) {
|
|
headerContent += `
|
|
<div class="flex justify-between text-[10px] text-slate-400 border-t border-slate-100 pt-1 mt-1">
|
|
<span>IPS Lalu: <strong class="${prevIPS < 2 ? 'text-red-500' : 'text-slate-600'}">${prevIPS.toFixed(2)}</strong></span>
|
|
<span>Jatah: <strong>${maxSKS}</strong></span>
|
|
</div>
|
|
`;
|
|
} else {
|
|
headerContent += `
|
|
<div class="flex justify-between text-[10px] text-slate-400 border-t border-slate-100 pt-1 mt-1">
|
|
<span>Mahasiswa Baru</span>
|
|
<span>Paket Awal</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (isOverload) {
|
|
headerContent += `<div class="mt-1 text-[10px] text-red-600 font-bold bg-red-100 px-2 py-0.5 rounded text-center animate-pulse">OVERLOAD! Kurangi mata kuliah.</div>`;
|
|
}
|
|
|
|
semDiv.innerHTML = `
|
|
<div class="bg-slate-50 px-4 py-3 border-b ${isOverload ? 'border-red-200 bg-red-50' : 'border-slate-200'}">
|
|
${headerContent}
|
|
</div>
|
|
<div class="semester-list p-3 space-y-2 flex-grow" ondrop="drop(event, ${i})" ondragover="allowDrop(event)">
|
|
</div>
|
|
<div class="px-4 py-2 bg-slate-50 border-t ${isOverload ? 'border-red-200' : 'border-slate-100'} text-right">
|
|
<span class="text-[10px] uppercase font-bold text-slate-400">IPS: ${currentStats.ips.toFixed(2)}</span>
|
|
</div>
|
|
`;
|
|
|
|
const listContainer = semDiv.querySelector('.semester-list');
|
|
currentPlan[i].forEach((c, idx) => {
|
|
listContainer.appendChild(createCard(c, i, idx));
|
|
});
|
|
|
|
grid.appendChild(semDiv);
|
|
}
|
|
|
|
updateGlobalStats();
|
|
}
|
|
|
|
function createCard(c, semId, index) {
|
|
const card = document.createElement('div');
|
|
const catData = categories[c.cat] || { color: 'bg-gray-400' };
|
|
const isFailed = c.grade === 0;
|
|
const isRetake = c.isRetake === true;
|
|
const isViolation = c.violation === true;
|
|
const isElective = c.isElective === true;
|
|
|
|
card.className = `course-card bg-white p-2 pl-4 rounded shadow-sm border border-slate-200 relative
|
|
${isFailed ? 'border-red-300 bg-red-50' : ''}
|
|
${isViolation ? 'violation' : ''}
|
|
${isElective ? 'is-elective' : ''}`;
|
|
|
|
card.draggable = true;
|
|
card.setAttribute('ondragstart', `dragSemester(event, ${semId}, ${index})`);
|
|
card.oncontextmenu = (e) => { e.preventDefault(); showContextMenu(e, semId, index); };
|
|
|
|
let gradeClass = 'grade-A';
|
|
let gradeLabel = 'A';
|
|
if(c.grade === 3) { gradeClass = 'grade-B'; gradeLabel = 'B'; }
|
|
if(c.grade === 2) { gradeClass = 'grade-C'; gradeLabel = 'C'; }
|
|
if(c.grade === 1) { gradeClass = 'grade-D'; gradeLabel = 'D'; }
|
|
if(c.grade === 0) { gradeClass = 'grade-E'; gradeLabel = 'E'; }
|
|
|
|
card.innerHTML = `
|
|
<div class="cat-bar ${catData.color}"></div>
|
|
<div class="pr-6">
|
|
<div class="text-xs font-bold text-slate-700 leading-tight mb-1 flex items-center">
|
|
${c.name}
|
|
${isRetake ? '<i class="fas fa-redo-alt text-[10px] text-blue-500 ml-2" title="Mengulang"></i>' : ''}
|
|
${isElective ? '<i class="fas fa-star text-[10px] text-yellow-500 ml-2" title="MK Peminatan"></i>' : ''}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-[10px] text-slate-500 bg-slate-100 px-1 rounded border">${c.sks} SKS</span>
|
|
</div>
|
|
</div>
|
|
<div class="grade-badge ${gradeClass}">${gradeLabel}</div>
|
|
${isViolation ? `<div class="violation-badge"><i class="fas fa-exclamation-triangle"></i> Prasyarat Invalid</div>` : ''}
|
|
`;
|
|
return card;
|
|
}
|
|
|
|
// --- Drag & Drop ---
|
|
let dragSource = null;
|
|
|
|
function dragCatalog(ev, id, name, sks, cat) {
|
|
dragSource = { type: 'catalog', course: { id, name, sks: parseInt(sks), cat, grade: 4, uuid: generateUUID() } };
|
|
ev.dataTransfer.effectAllowed = "copy";
|
|
}
|
|
|
|
function dragSemester(ev, semId, index) {
|
|
dragSource = { type: 'semester', semId, index };
|
|
ev.dataTransfer.effectAllowed = "move";
|
|
ev.target.classList.add('dragging');
|
|
}
|
|
|
|
function allowDrop(ev) { ev.preventDefault(); }
|
|
|
|
function drop(ev, targetSem) {
|
|
ev.preventDefault();
|
|
document.querySelectorAll('.course-card').forEach(el => el.classList.remove('dragging'));
|
|
|
|
if(!dragSource) return;
|
|
|
|
let courseToAdd = null;
|
|
if(dragSource.type === 'catalog') {
|
|
courseToAdd = dragSource.course;
|
|
} else {
|
|
courseToAdd = currentPlan[dragSource.semId][dragSource.index];
|
|
}
|
|
|
|
// --- 1. Prerequisite Check ---
|
|
const check = checkPrerequisite(courseToAdd.id, targetSem);
|
|
if (!check.allowed) {
|
|
showToast("❌ " + check.msg);
|
|
return;
|
|
}
|
|
|
|
// --- 2. SKS Limit Check (NEW) ---
|
|
let maxSKS = 20; // Default for Sem 1
|
|
let prevIPS = 0;
|
|
|
|
if (targetSem > 1) {
|
|
// Get IPS from previous semester
|
|
const prevStats = calculateSemStats(targetSem - 1);
|
|
prevIPS = prevStats.ips;
|
|
maxSKS = getMaxSKS(prevIPS);
|
|
}
|
|
|
|
// Current load in target semester
|
|
const currentStats = calculateSemStats(targetSem);
|
|
const currentLoad = currentStats.sksAttempted;
|
|
|
|
// Check if adding this course exceeds limit
|
|
if (currentLoad + courseToAdd.sks > maxSKS) {
|
|
let msg = `⚠️ Gagal! Maksimal ${maxSKS} SKS.`;
|
|
if(targetSem > 1) {
|
|
msg += ` (IPS Sem ${targetSem-1}: ${prevIPS.toFixed(2)})`;
|
|
}
|
|
showToast(msg);
|
|
return;
|
|
}
|
|
|
|
if(dragSource.type === 'catalog') {
|
|
currentPlan[targetSem].push(dragSource.course);
|
|
} else {
|
|
const { semId, index } = dragSource;
|
|
if(semId === targetSem) return;
|
|
const item = currentPlan[semId][index];
|
|
currentPlan[semId].splice(index, 1);
|
|
currentPlan[targetSem].push(item);
|
|
}
|
|
|
|
renderAll();
|
|
dragSource = null;
|
|
}
|
|
|
|
// --- Context Menu Logic ---
|
|
function showContextMenu(e, semId, index) {
|
|
contextTarget = { semId, index };
|
|
const menu = document.getElementById('context-menu');
|
|
const course = currentPlan[semId][index];
|
|
const btnRetake = document.getElementById('btn-retake');
|
|
|
|
if (course.grade === 0) {
|
|
btnRetake.style.display = 'block';
|
|
} else {
|
|
btnRetake.style.display = 'none';
|
|
}
|
|
|
|
menu.style.display = 'block';
|
|
let x = e.pageX;
|
|
let y = e.pageY;
|
|
if(x + 200 > window.innerWidth) x = window.innerWidth - 210;
|
|
menu.style.left = `${x}px`;
|
|
menu.style.top = `${y}px`;
|
|
}
|
|
|
|
function hideContextMenu() {
|
|
document.getElementById('context-menu').style.display = 'none';
|
|
}
|
|
|
|
function setCourseGrade(grade) {
|
|
if(contextTarget) {
|
|
const { semId, index } = contextTarget;
|
|
currentPlan[semId][index].grade = grade;
|
|
|
|
if(grade === 0) {
|
|
showToast("Mata kuliah Gagal (E). Klik kanan > 'Ulang di Tahun Depan' untuk memperbaiki.");
|
|
}
|
|
|
|
renderAll();
|
|
}
|
|
hideContextMenu();
|
|
}
|
|
|
|
function retakeCourse() {
|
|
if(contextTarget) {
|
|
const { semId, index } = contextTarget;
|
|
const course = currentPlan[semId][index];
|
|
|
|
const targetSem = parseInt(semId) + 2;
|
|
|
|
if (targetSem > 8) {
|
|
showToast("Maaf, semester tujuan ("+targetSem+") di luar jangkauan simulasi (Max Sem 8).");
|
|
} else {
|
|
const exists = currentPlan[targetSem].some(c => c.id === course.id);
|
|
if (exists) {
|
|
showToast("Mata kuliah ini sudah ada di Semester " + targetSem + ".");
|
|
} else {
|
|
const check = checkPrerequisite(course.id, targetSem);
|
|
if (!check.allowed) {
|
|
showToast("❌ Tidak bisa mengulang: " + check.msg);
|
|
} else {
|
|
const newCourse = {
|
|
...course,
|
|
grade: 4,
|
|
uuid: generateUUID(),
|
|
isRetake: true
|
|
};
|
|
currentPlan[targetSem].push(newCourse);
|
|
showToast(`Mata kuliah ditambahkan ke Semester ${targetSem}.`);
|
|
renderAll();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
hideContextMenu();
|
|
}
|
|
|
|
function deleteCourse() {
|
|
if(contextTarget) {
|
|
const { semId, index } = contextTarget;
|
|
currentPlan[semId].splice(index, 1);
|
|
renderAll();
|
|
}
|
|
hideContextMenu();
|
|
}
|
|
|
|
function showToast(msg) {
|
|
const el = document.getElementById("toast");
|
|
el.innerText = msg;
|
|
el.className = "show";
|
|
setTimeout(function(){ el.className = el.className.replace("show", ""); }, 4000);
|
|
}
|
|
|
|
document.addEventListener('click', (e) => {
|
|
if(!e.target.closest('#context-menu') && !e.target.closest('.course-card')) {
|
|
hideContextMenu();
|
|
}
|
|
});
|
|
|
|
// --- Sidebar & Catalog ---
|
|
function toggleCatalog() { document.getElementById('catalog-sidebar').classList.toggle('closed'); }
|
|
|
|
function renderCatalog() {
|
|
const list = document.getElementById('catalog-list');
|
|
list.innerHTML = '';
|
|
|
|
let allCourses = [];
|
|
// Base Courses
|
|
for(let i=1; i<=8; i++) if(baseCurriculum[i]) allCourses.push(...baseCurriculum[i]);
|
|
|
|
// Logic Baru: Jika Track == 'general', TAMPILKAN SEMUA PILIHAN
|
|
if(currentTrackId === 'general') {
|
|
allCourses.push(...electives['ai']);
|
|
allCourses.push(...electives['rpl']);
|
|
allCourses.push(...electives['net']);
|
|
allCourses.push(...electives['si']);
|
|
} else {
|
|
allCourses.push(...(electives[currentTrackId] || []));
|
|
}
|
|
|
|
const unique = [];
|
|
const seen = new Set();
|
|
allCourses.forEach(c => { if(!seen.has(c.id)){ seen.add(c.id); unique.push(c); } });
|
|
|
|
unique.forEach(c => {
|
|
const el = document.createElement('div');
|
|
const catData = categories[c.cat] || {color: 'bg-gray-400'};
|
|
el.className = 'bg-white border border-slate-200 p-2 rounded cursor-grab hover:bg-slate-50 flex items-center justify-between text-sm shadow-sm transition-transform hover:scale-[1.02]';
|
|
el.draggable = true;
|
|
el.setAttribute('ondragstart', `dragCatalog(event, '${c.id}', '${c.name}', ${c.sks}, '${c.cat}')`);
|
|
el.innerHTML = `
|
|
<div class="flex items-center gap-2">
|
|
<div class="w-1.5 h-8 ${catData.color} rounded-sm"></div>
|
|
<div>
|
|
<div class="font-bold text-slate-700 text-xs">${c.name}</div>
|
|
<div class="text-[10px] text-slate-400">${c.cat} | ${c.sks} SKS</div>
|
|
</div>
|
|
</div>
|
|
<i class="fas fa-plus text-slate-300"></i>
|
|
`;
|
|
list.appendChild(el);
|
|
});
|
|
}
|
|
|
|
function filterCatalog() {
|
|
const term = document.getElementById('search-catalog').value.toLowerCase();
|
|
document.querySelectorAll('#catalog-list > div').forEach(item => {
|
|
item.style.display = item.innerText.toLowerCase().includes(term) ? 'flex' : 'none';
|
|
});
|
|
}
|
|
|
|
function switchTrack(trackId) {
|
|
document.querySelectorAll('.track-btn').forEach(b => {
|
|
b.classList.remove('bg-slate-800', 'text-white');
|
|
b.classList.add('bg-white', 'text-slate-800');
|
|
});
|
|
const btn = document.getElementById(`btn-${trackId}`);
|
|
btn.classList.remove('bg-white', 'text-slate-800');
|
|
btn.classList.add('bg-slate-800', 'text-white');
|
|
|
|
// Notification
|
|
let trackName = "Umum";
|
|
if(trackId === 'ai') trackName = "AI & Data";
|
|
if(trackId === 'rpl') trackName = "RPL";
|
|
if(trackId === 'net') trackName = "Jaringan";
|
|
if(trackId === 'si') trackName = "SI & GIS";
|
|
showToast(`Jalur diubah ke ${trackName}.`);
|
|
|
|
initPlan(trackId);
|
|
}
|
|
|
|
// --- Analytics ---
|
|
let ipsChart = null;
|
|
let domainChart = null;
|
|
|
|
function updateGlobalStats() {
|
|
let globalAttempted = 0;
|
|
let globalPassed = 0;
|
|
let globalPoints = 0;
|
|
let globalFailedSKS = 0;
|
|
|
|
let semIPS = [];
|
|
let catCounts = { MKWU:0, BSC:0, FND:0, CORE:0, ADV:0, PROF:0, EL:0 };
|
|
|
|
for(let i=1; i<=8; i++) {
|
|
const stats = calculateSemStats(i);
|
|
semIPS.push(stats.ips);
|
|
|
|
globalAttempted += stats.sksAttempted;
|
|
globalPassed += stats.sksPassed;
|
|
globalPoints += stats.totalPoints;
|
|
|
|
currentPlan[i].forEach(c => {
|
|
if (c.grade === 0) globalFailedSKS += c.sks;
|
|
// Count categories for passed/active courses
|
|
if (c.grade > 0) {
|
|
const cat = c.cat === 'CAP' || c.cat === 'RES' ? 'PROF' : c.cat;
|
|
if(catCounts[cat] !== undefined) catCounts[cat] += c.sks;
|
|
}
|
|
});
|
|
}
|
|
|
|
const ipk = globalAttempted > 0 ? (globalPoints / globalAttempted) : 0;
|
|
|
|
document.getElementById('total-sks-passed').innerText = globalPassed;
|
|
document.getElementById('ipk-global').innerText = ipk.toFixed(2);
|
|
document.getElementById('total-fail').innerText = globalFailedSKS;
|
|
|
|
let predikat = "Memuaskan";
|
|
const adaNilaiE = globalFailedSKS > 0;
|
|
|
|
if (ipk >= 3.51 && !adaNilaiE) {
|
|
predikat = "Cum Laude";
|
|
} else if (ipk >= 3.01) {
|
|
predikat = "Sangat Memuaskan";
|
|
} else if (ipk < 2.00) {
|
|
predikat = "Nasakom";
|
|
}
|
|
|
|
document.getElementById('predikat-ipk').innerText = predikat;
|
|
|
|
// Chart 1: IPS Trend
|
|
const ctx1 = document.getElementById('ipsChart').getContext('2d');
|
|
if(ipsChart) ipsChart.destroy();
|
|
ipsChart = new Chart(ctx1, {
|
|
type: 'line',
|
|
data: {
|
|
labels: ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7', 'S8'],
|
|
datasets: [{
|
|
label: 'IPS',
|
|
data: semIPS,
|
|
borderColor: '#2563eb',
|
|
backgroundColor: 'rgba(37, 99, 235, 0.1)',
|
|
fill: true,
|
|
tension: 0.3
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: { y: { min: 0, max: 4 } },
|
|
plugins: { legend: { display: false } }
|
|
}
|
|
});
|
|
|
|
// Chart 2: Domain
|
|
const ctx2 = document.getElementById('domainChart').getContext('2d');
|
|
if(domainChart) domainChart.destroy();
|
|
domainChart = new Chart(ctx2, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: Object.keys(catCounts),
|
|
datasets: [{
|
|
data: Object.values(catCounts),
|
|
backgroundColor: ['#f472b6', '#22c55e', '#60a5fa', '#fb923c', '#c084fc', '#94a3b8', '#facc15'],
|
|
borderWidth: 0
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { position: 'right', labels: { boxWidth: 10, font: {size: 9} } } }
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initPlan('general');
|
|
});
|
|
|
|
</script>
|
|
</body>
|
|
</html> |