| 28 | | |
| 29 | | [[Image(5.png)]] |
| 30 | | Продолжени сите останати семестри и приказ на кои предмети се препорачани како и предуслов. Во зависност ако студентот ги има завршено ќе биде достапно, во спротивно нема предуслов. |
| 31 | | |
| 32 | | |
| 33 | | [[Image(6.png)]] |
| 34 | | Невозможни предмети бидејќи студентот не исполнува даден предуслов за предмет. |
| | 81 | **Серверот враќа форма за креирање** |
| | 82 | {{{#!php |
| | 83 | // RoadmapController.php |
| | 84 | public function create() |
| | 85 | { |
| | 86 | $studyPrograms = StudyProgram::all(); |
| | 87 | $careerPaths = CareerPath::all(); |
| | 88 | |
| | 89 | return view('roadmap.create', [ |
| | 90 | 'studyPrograms' => $studyPrograms, |
| | 91 | 'careerPaths' => $careerPaths |
| | 92 | ]); |
| | 93 | } |
| | 94 | }}} |
| | 95 | **Апликацијата прикажува ЧЕКОР 1: Избор програма и кариера** |
| | 96 | {{{#!html |
| | 97 | <!-- resources/views/roadmap/create.blade.php --> |
| | 98 | <form method="POST" action="/roadmap" id="roadmapForm"> |
| | 99 | @csrf |
| | 100 | |
| | 101 | <div class="step-1"> |
| | 102 | <label>Избери студирана програма:</label> |
| | 103 | <select name="study_program_id" id="programSelect" required> |
| | 104 | <option value="">-- Избери програма --</option> |
| | 105 | @foreach($studyPrograms as $program) |
| | 106 | <option value="{{ $program->id }}"> |
| | 107 | {{ $program->name_mk }} ({{ $program->duration_years }} години) |
| | 108 | </option> |
| | 109 | @endforeach |
| | 110 | </select> |
| | 111 | |
| | 112 | <label>Избери каријерна патека (опционално):</label> |
| | 113 | <select name="career_path_id" id="careerPathSelect"> |
| | 114 | <option value="">-- Без патека --</option> |
| | 115 | @foreach($careerPaths as $path) |
| | 116 | <option value="{{ $path->id }}">{{ $path->name }}</option> |
| | 117 | @endforeach |
| | 118 | </select> |
| | 119 | </div> |
| | 120 | </form> |
| | 121 | }}} |
| | 122 | |
| | 123 | **JavaScriptAJAX барање за предмети** |
| | 124 | {{{#!js |
| | 125 | // views/roadmap/create.blade.php |
| | 126 | document.getElementById('programSelect').addEventListener('change', function() { |
| | 127 | const programId = this.value; |
| | 128 | |
| | 129 | if (!programId) return; |
| | 130 | |
| | 131 | // AJAX fetch за да добиeме предмети од оваа програма |
| | 132 | fetch(`/api/study-program/${programId}/subjects`, { |
| | 133 | method: 'GET', |
| | 134 | headers: { |
| | 135 | 'Accept': 'application/json' |
| | 136 | } |
| | 137 | }) |
| | 138 | .then(response => response.json()) |
| | 139 | .then(data => { |
| | 140 | displaySubjectsByYear(data.subjects); |
| | 141 | }) |
| | 142 | .catch(error => console.error('Error:', error)); |
| | 143 | }); |
| | 144 | }}} |
| | 145 | |
| | 146 | |
| | 147 | **Серверот враќа JSON со предмети** |
| | 148 | {{{#!php |
| | 149 | // RoadmapController.php |
| | 150 | public function getSubjectsByProgram($programId) |
| | 151 | { |
| | 152 | $program = StudyProgram::findOrFail($programId); |
| | 153 | |
| | 154 | // Вчитај сите предмети од програмата со pivot info |
| | 155 | $subjects = $program->subjects() |
| | 156 | ->with('prerequisites') |
| | 157 | ->get() |
| | 158 | ->map(function($subject) { |
| | 159 | return [ |
| | 160 | 'id' => $subject->id, |
| | 161 | 'code' => $subject->code, |
| | 162 | 'name_mk' => $subject->name_mk, |
| | 163 | 'name_en' => $subject->name_en, |
| | 164 | 'year' => $subject->pivot->year, |
| | 165 | 'semester_type' => $subject->pivot->semester_type, |
| | 166 | 'type' => $subject->pivot->type, |
| | 167 | 'credits' => $subject->credits, |
| | 168 | 'prerequisites' => $subject->prerequisites->pluck('id') |
| | 169 | ]; |
| | 170 | }); |
| | 171 | |
| | 172 | return response()->json(['subjects' => $subjects]); |
| | 173 | } |
| | 174 | }}} |
| | 175 | \\ |
| | 176 | |
| | 177 | |
| | 178 | **JavaScript приказует ЧЕКОР 2: Избор завршени предмети** |
| | 179 | {{{#!js |
| | 180 | function displaySubjectsByYear(subjects) { |
| | 181 | // Организирај по години |
| | 182 | const subjectsByYear = {}; |
| | 183 | |
| | 184 | subjects.forEach(subject => { |
| | 185 | if (!subjectsByYear[subject.year]) { |
| | 186 | subjectsByYear[subject.year] = []; |
| | 187 | } |
| | 188 | subjectsByYear[subject.year].push(subject); |
| | 189 | }); |
| | 190 | |
| | 191 | // Креирај HTML за секоја година |
| | 192 | let html = '<div class="subjects-grid">'; |
| | 193 | |
| | 194 | Object.keys(subjectsByYear).sort().forEach(year => { |
| | 195 | html += `<div class="year-section"> |
| | 196 | <h3>Година ${year}</h3> |
| | 197 | ${['winter', 'summer'].map(semester => { |
| | 198 | const semesterSubjects = subjectsByYear[year] |
| | 199 | .filter(s => s.semester_type === semester); |
| | 200 | |
| | 201 | return `<div class="semester"> |
| | 202 | <h4>${semester === 'winter' ? 'Зимски' : 'Летен'}</h4> |
| | 203 | ${semesterSubjects.map(subject => ` |
| | 204 | <div class="subject-card"> |
| | 205 | <input type="checkbox" name="completed_subjects[]" |
| | 206 | value="${subject.id}" |
| | 207 | class="subject-checkbox"> |
| | 208 | <strong>${subject.code}</strong> - ${subject.name_mk} |
| | 209 | <span class="credits">(${subject.credits} ECTS)</span> |
| | 210 | <span class="type badge-${subject.type}"> |
| | 211 | ${subject.type === 'mandatory' ? 'Задолжително' : 'Изборно'} |
| | 212 | </span> |
| | 213 | </div> |
| | 214 | `).join('')} |
| | 215 | </div>`; |
| | 216 | }).join('')} |
| | 217 | </div>`; |
| | 218 | }); |
| | 219 | |
| | 220 | html += '</div>'; |
| | 221 | |
| | 222 | document.getElementById('subjectsContainer').innerHTML = html; |
| | 223 | document.getElementById('step2').style.display = 'block'; |
| | 224 | } |
| | 225 | }}} |
| | 226 | |
| | 227 | **Студентот кликне "ГЕНЕРИРАЈ МОЈ ПЛАН"** |
| | 228 | - JavaScript собира податоци од checkbox-ите |
| | 229 | |
| | 230 | |
| | 231 | |
| | 232 | |
| | 233 | \\ |
| | 234 | **JavaScript ја повикува POST барање** |
| | 235 | {{{#!js |
| | 236 | document.getElementById('roadmapForm').addEventListener('submit', function(e) { |
| | 237 | e.preventDefault(); |
| | 238 | |
| | 239 | const formData = new FormData(this); |
| | 240 | |
| | 241 | // Конвертирај во JSON |
| | 242 | const data = { |
| | 243 | study_program_id: formData.get('study_program_id'), |
| | 244 | career_path_id: formData.get('career_path_id'), |
| | 245 | completed_subjects: Array.from(document.querySelectorAll( |
| | 246 | 'input[name="completed_subjects[]"]:checked' |
| | 247 | )).map(cb => cb.value), |
| | 248 | in_progress_subjects: Array.from(document.querySelectorAll( |
| | 249 | 'input[name="in_progress_subjects[]"]:checked' |
| | 250 | )).map(cb => cb.value) |
| | 251 | }; |
| | 252 | |
| | 253 | fetch('/roadmap', { |
| | 254 | method: 'POST', |
| | 255 | headers: { |
| | 256 | 'Content-Type': 'application/json', |
| | 257 | 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content |
| | 258 | }, |
| | 259 | body: JSON.stringify(data) |
| | 260 | }) |
| | 261 | .then(response => response.text()) |
| | 262 | .then(html => { |
| | 263 | document.body.innerHTML = html; |
| | 264 | }) |
| | 265 | .catch(error => console.error('Error:', error)); |
| | 266 | }); |
| | 267 | }}} |
| | 268 | |
| | 269 | **Серверот прима POST барање и генерира план** |
| | 270 | {{{#!php |
| | 271 | // RoadmapController.php |
| | 272 | public function store(Request $request) |
| | 273 | { |
| | 274 | $validated = $request->validate([ |
| | 275 | 'study_program_id' => 'required|exists:study_programs,id', |
| | 276 | 'career_path_id' => 'nullable|exists:career_paths,id', |
| | 277 | 'completed_subjects' => 'array', |
| | 278 | 'in_progress_subjects' => 'array' |
| | 279 | ]); |
| | 280 | |
| | 281 | $user = Auth::user(); |
| | 282 | $studyProgram = StudyProgram::find($validated['study_program_id']); |
| | 283 | |
| | 284 | // Избриши стари записи |
| | 285 | UserProgress::where('user_id', $user->id) |
| | 286 | ->where('study_program_id', $studyProgram->id) |
| | 287 | ->delete(); |
| | 288 | |
| | 289 | // Создај нови за завршени |
| | 290 | foreach ($validated['completed_subjects'] as $subjectId) { |
| | 291 | UserProgress::create([ |
| | 292 | 'user_id' => $user->id, |
| | 293 | 'subject_id' => $subjectId, |
| | 294 | 'study_program_id' => $studyProgram->id, |
| | 295 | 'career_path_id' => $validated['career_path_id'] ?? null, |
| | 296 | 'status' => 'completed', |
| | 297 | 'completed_at' => now() |
| | 298 | ]); |
| | 299 | } |
| | 300 | |
| | 301 | // Создај нови за во-прогрес |
| | 302 | foreach ($validated['in_progress_subjects'] as $subjectId) { |
| | 303 | UserProgress::create([ |
| | 304 | 'user_id' => $user->id, |
| | 305 | 'subject_id' => $subjectId, |
| | 306 | 'study_program_id' => $studyProgram->id, |
| | 307 | 'career_path_id' => $validated['career_path_id'] ?? null, |
| | 308 | 'status' => 'in_progress' |
| | 309 | ]); |
| | 310 | } |
| | 311 | |
| | 312 | // Генерирај три-слојна препорака |
| | 313 | $roadmap = $this->generateRoadmap($user, $studyProgram, $validated); |
| | 314 | $semesterRoadmap = $this->generateSemesterRoadmap($roadmap); |
| | 315 | |
| | 316 | return view('roadmap.show', [ |
| | 317 | 'roadmap' => $roadmap, |
| | 318 | 'semesterRoadmap' => $semesterRoadmap, |
| | 319 | 'studyProgram' => $studyProgram, |
| | 320 | 'careerPath' => $validated['career_path_id'] |
| | 321 | ? CareerPath::find($validated['career_path_id']) |
| | 322 | : null |
| | 323 | ]); |
| | 324 | } |
| | 325 | |
| | 326 | // ФИЛТРИРАЊЕ: Три слоја |
| | 327 | private function generateRoadmap($user, $studyProgram, $validated) |
| | 328 | { |
| | 329 | $roadmap = []; |
| | 330 | $completedIds = collect($validated['completed_subjects'])->map(fn($id) => (int)$id); |
| | 331 | $inProgressIds = collect($validated['in_progress_subjects'])->map(fn($id) => (int)$id); |
| | 332 | |
| | 333 | $allSubjects = $studyProgram->subjects()->with('prerequisites')->get(); |
| | 334 | |
| | 335 | foreach ($allSubjects as $subject) { |
| | 336 | // СЛОЈ 1: Проверка на година |
| | 337 | if ($subject->year > $studyProgram->duration_years) { |
| | 338 | continue; // Прескочи Year 5+ во 4-годишна програма |
| | 339 | } |
| | 340 | |
| | 341 | // СЛОЈ 2: Веќе завршено? |
| | 342 | if ($completedIds->contains($subject->id) || $inProgressIds->contains($subject->id)) { |
| | 343 | continue; // Не препорачувај што вече го взел |
| | 344 | } |
| | 345 | |
| | 346 | // СЛОЈ 3: Каријерна патека? |
| | 347 | if ($validated['career_path_id']) { |
| | 348 | $careerPath = CareerPath::find($validated['career_path_id']); |
| | 349 | $isElective = $subject->pivot->type === 'elective'; |
| | 350 | $isInPath = $careerPath->subjects->contains($subject->id); |
| | 351 | |
| | 352 | if ($isElective && !$isInPath) { |
| | 353 | continue; // Елективи не во патеката не се препорачуваат |
| | 354 | } |
| | 355 | } |
| | 356 | |
| | 357 | // Проверка на предуслови |
| | 358 | $prerequisites = $subject->prerequisites()->get(); |
| | 359 | $ready = true; |
| | 360 | $blockedBy = []; |
| | 361 | |
| | 362 | foreach ($prerequisites as $prereq) { |
| | 363 | if (!$completedIds->contains($prereq->id)) { |
| | 364 | $ready = false; |
| | 365 | $blockedBy[] = $prereq; |
| | 366 | } |
| | 367 | } |
| | 368 | |
| | 369 | // Додај во roadmap |
| | 370 | $roadmap[] = [ |
| | 371 | 'subject' => $subject, |
| | 372 | 'ready' => $ready, |
| | 373 | 'blocked_by' => $blockedBy, |
| | 374 | 'year' => $subject->pivot->year, |
| | 375 | 'semester' => $subject->pivot->semester_type |
| | 376 | ]; |
| | 377 | } |
| | 378 | |
| | 379 | // Сортирај: Ready first, потоа по година, потоа по ред |
| | 380 | usort($roadmap, function($a, $b) { |
| | 381 | if ($a['ready'] !== $b['ready']) { |
| | 382 | return $a['ready'] ? -1 : 1; // Ready прво |
| | 383 | } |
| | 384 | if ($a['year'] !== $b['year']) { |
| | 385 | return $a['year'] - $b['year']; // После по година |
| | 386 | } |
| | 387 | return 0; |
| | 388 | }); |
| | 389 | |
| | 390 | return $roadmap; |
| | 391 | } |
| | 392 | |
| | 393 | private function generateSemesterRoadmap($roadmap) |
| | 394 | { |
| | 395 | $semesterRoadmap = []; |
| | 396 | |
| | 397 | foreach ($roadmap as $item) { |
| | 398 | $year = $item['year']; |
| | 399 | $semester = $item['semester']; |
| | 400 | |
| | 401 | if (!isset($semesterRoadmap[$year])) { |
| | 402 | $semesterRoadmap[$year] = [ |
| | 403 | 'winter' => [], |
| | 404 | 'summer' => [] |
| | 405 | ]; |
| | 406 | } |
| | 407 | |
| | 408 | $semesterRoadmap[$year][$semester][] = $item; |
| | 409 | } |
| | 410 | |
| | 411 | return $semesterRoadmap; |
| | 412 | } |
| | 413 | }}} |
| | 414 | \\ |
| | 415 | |
| | 416 | Студентот може да ги види препораките** |
| | 417 | - Зелени: Готови за запишување оваа година |
| | 418 | - Црвени: Блокирани - мора прво да завршат [Предуслови] |
| | 419 | |
| | 420 | Апликацијата прикажува РЕЗУЛТАТ** |
| | 421 | - Резиме на напредок (X завршени, Y во-прогрес) |
| | 422 | - ECTS прогресна лента |
| | 423 | - Семестар-по-семестар план: |
| | 424 | - Година 1 Зимски: F23L3W001, F23L3W002... |
| | 425 | - Година 1 Летен: F23L3S003... |
| | 426 | - Препорачани следни чекори (зелено: готови, црвено: блокирани) |
| | 427 | |
| | 428 | \\ |
| | 429 | ### Исклучоци: |
| | 430 | - **Нелогиран корисник**: Редирекција кон `/login` |
| | 431 | - **Невалидна програма**: 404 грешка |
| | 432 | - **Нема избрани предмети**: Покажи порака да избере барем еден |
| | 433 | - **Конфликт на години**: Предмет е во повеќе години (обично задолжително) |