Files
magistr/frontend/student/app.js
2026-08-20 00:55:27 +03:00

286 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
clearAuthState,
fetchWithAuth,
logoutSession,
restoreSession
} from '../auth-session.js';
import { AsyncCombobox } from '../admin/js/async-combobox.js';
import { readUrlState, writeUrlState } from '../admin/js/url-state.js';
import { renderScheduleOverview, renderScheduleOverviewState } from '../schedule-overview.js';
import { trackUxEvent } from '../telemetry.js';
(async () => {
const session = await restoreSession();
if (!session || session.role !== 'STUDENT') {
window.location.replace('/');
return;
}
const groupSelect = document.getElementById('group-select');
const grid = document.getElementById('schedule-grid');
const statusLine = document.getElementById('status-line');
const weekLabel = document.getElementById('week-label');
const datePicker = document.getElementById('date-picker');
const scheduleOverview = document.getElementById('schedule-overview');
const pageState = readUrlState({ studentGroup: '', studentDate: '' });
let weekStart = startOfWeek(pageState.studentDate ? parseIsoDate(pageState.studentDate) : new Date());
let groupCombobox = null;
document.getElementById('logout-link').addEventListener('click', async (event) => {
event.preventDefault();
await logoutSession();
window.location.replace('/');
});
document.getElementById('theme-toggle-local').addEventListener('click', () => {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
});
document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light';
document.getElementById('prev-week').addEventListener('click', () => moveWeek(-1));
document.getElementById('today-week').addEventListener('click', goToCurrentWeek);
document.getElementById('next-week').addEventListener('click', () => moveWeek(1));
datePicker.addEventListener('change', () => {
if (datePicker.value) {
weekStart = startOfWeek(parseIsoDate(datePicker.value));
loadSchedule();
}
});
groupSelect.addEventListener('change', () => {
localStorage.setItem('studentGroupId', groupSelect.value);
writeUrlState({ studentGroup: groupSelect.value });
loadSchedule();
loadScheduleOverview();
trackUxEvent('student_group_selected', { groupId: groupSelect.value });
});
init();
async function init() {
await loadGroups();
await Promise.all([loadSchedule(), loadScheduleOverview()]);
}
async function loadGroups() {
try {
const savedGroupId = pageState.studentGroup || localStorage.getItem('studentGroupId') || '';
const groups = await fetchGroupOptions('', savedGroupId);
if (!groups.length) {
groupSelect.innerHTML = '<option value="">Группы не найдены</option>';
renderScheduleOverviewState(scheduleOverview, { kind: 'empty', message: 'Нет доступных групп' });
return;
}
const selected = groups.find(group => String(group.id) === String(savedGroupId)) || groups[0];
groupSelect.innerHTML = `<option value="${escapeHtml(selected.id)}" selected>${escapeHtml(selected.name)}</option>`;
groupSelect.value = String(selected.id);
localStorage.setItem('studentGroupId', groupSelect.value);
writeUrlState({ studentGroup: groupSelect.value });
groupCombobox = new AsyncCombobox(groupSelect, {
allowEmpty: false,
placeholder: 'Найдите группу по названию или специальности',
emptyLabel: 'Подходящие группы не найдены',
load: fetchGroupOptions,
optionDescription: group => [
group.specialtyCode,
group.specialtyProfileName,
group.course ? `${group.course} курс` : ''
].filter(Boolean).join(' · ')
});
} catch (error) {
groupSelect.innerHTML = '<option value="">Ошибка загрузки групп</option>';
showStatus(error.message, true);
renderScheduleOverviewState(scheduleOverview, { kind: 'error', message: error.message, onRetry: init });
}
}
function fetchGroupOptions(query = '', selectedId = '') {
const params = new URLSearchParams({ query, limit: '20' });
if (selectedId) params.set('selectedId', selectedId);
return fetchJson(`/api/groups/options?${params}`);
}
function goToCurrentWeek() {
weekStart = startOfWeek(new Date());
loadSchedule();
}
async function loadScheduleOverview() {
const groupId = groupSelect.value;
if (!groupId) {
renderScheduleOverviewState(scheduleOverview, { kind: 'empty', message: 'Выберите группу, чтобы увидеть ближайшие занятия' });
return;
}
renderScheduleOverviewState(scheduleOverview, { kind: 'loading' });
const now = new Date();
const startDate = toIsoDate(now);
const endDate = toIsoDate(addDays(now, 14));
try {
const lessons = await fetchJson(`/api/schedule?groupId=${encodeURIComponent(groupId)}&startDate=${startDate}&endDate=${endDate}`);
const overview = renderScheduleOverview(scheduleOverview, lessons, {
role: 'student',
now,
onGoToday: goToCurrentWeek
});
trackUxEvent('student_schedule_overview_loaded', { ...overview, groupId });
} catch (error) {
renderScheduleOverviewState(scheduleOverview, {
kind: 'error',
message: error.message,
onRetry: loadScheduleOverview
});
}
}
async function loadSchedule() {
const groupId = groupSelect.value;
const startDate = toIsoDate(weekStart);
const endDate = toIsoDate(addDays(weekStart, 6));
datePicker.value = startDate;
weekLabel.textContent = `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`;
writeUrlState({ studentDate: startDate });
renderEmptyWeek('Загрузка...');
if (!groupId) {
showStatus('Выберите группу', false);
renderEmptyWeek('Нет выбранной группы');
return;
}
try {
const lessons = await fetchJson(`/api/schedule?groupId=${encodeURIComponent(groupId)}&startDate=${startDate}&endDate=${endDate}`);
renderWeek(lessons);
showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false);
trackUxEvent('student_schedule_loaded', { lessons: lessons.length, startDate, groupId });
} catch (error) {
renderEmptyWeek('Ошибка загрузки');
showStatus(error.message, true);
}
}
function renderWeek(lessons) {
const lessonsByDate = new Map();
lessons.forEach(lesson => {
if (!lessonsByDate.has(lesson.date)) lessonsByDate.set(lesson.date, []);
lessonsByDate.get(lesson.date).push(lesson);
});
grid.innerHTML = daysOfWeek().map(date => {
const key = toIsoDate(date);
const dayLessons = lessonsByDate.get(key) || [];
return renderDay(date, dayLessons);
}).join('');
}
function renderEmptyWeek(message) {
grid.innerHTML = daysOfWeek().map(date => renderDay(date, [], message)).join('');
}
function renderDay(date, lessons, emptyMessage = 'Занятий нет') {
return `
<article class="day">
<div class="day-head">
<div class="day-name">${escapeHtml(dayName(date))}</div>
<div class="day-date">${escapeHtml(formatDate(date))}</div>
</div>
<div class="lesson-list">
${lessons.length ? lessons.map(renderLesson).join('') : `<div class="empty">${escapeHtml(emptyMessage)}</div>`}
</div>
</article>
`;
}
function renderLesson(lesson) {
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
? lesson.subgroupNames.join(', ')
: lesson.subgroupName;
return `
<div class="lesson">
<div class="lesson-time">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</div>
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
<div class="lesson-meta">
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
${subgroups ? `<span class="chip">${escapeHtml(subgroups)}</span>` : ''}
<span class="chip">${escapeHtml(lesson.lessonFormat)}</span>
<span class="chip">${escapeHtml(lesson.teacherName)}</span>
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
</div>
</div>
`;
}
function moveWeek(delta) {
weekStart = addDays(weekStart, delta * 7);
loadSchedule();
}
function daysOfWeek() {
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
}
async function fetchJson(url) {
const response = await fetchWithAuth(url);
const data = await response.json().catch(() => null);
if (response.status === 401) {
clearAuthState();
window.location.replace('/');
throw new Error('Требуется повторный вход в систему');
}
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
}
return data;
}
function showStatus(message, isError) {
statusLine.textContent = message;
statusLine.classList.toggle('error', isError);
}
function startOfWeek(date) {
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const day = copy.getDay() || 7;
copy.setDate(copy.getDate() - day + 1);
return copy;
}
function parseIsoDate(value) {
const [year, month, day] = String(value || '').split('-').map(Number);
return year && month && day ? new Date(year, month - 1, day) : new Date();
}
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function toIsoDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatDate(date) {
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function dayName(date) {
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
return value.charAt(0).toUpperCase() + value.slice(1);
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
})();