В карточке остаётся формулировка из таблицы («комп.класс Linux»), а в фильтре близкие варианты сведены к одной группе: «комп.класс» (комп.класс, комп.класс Linux, компьютерный класс), «кабинет английского» (английский, кабинет английского), «аудитория кафедры», «лекционная», «лекционно-практическая», «аудитория для практических». Добавлен пункт «без типа» — 4 аудитории, где в таблице тип не указан. Бэкенд отдаёт в /rooms-day и /rooms-free дополнительное поле type_group (room_type_group), фронт фильтрует по нему. Тесты: 160.
827 lines
31 KiB
JavaScript
827 lines
31 KiB
JavaScript
const groupSelect = document.getElementById("group-select");
|
||
const entityLabel = document.getElementById("entity-label");
|
||
const themeToggle = document.getElementById("theme-toggle");
|
||
const scheduleEl = document.getElementById("schedule");
|
||
const errorEl = document.getElementById("error");
|
||
const loadingEl = document.getElementById("loading");
|
||
|
||
const RUSSIAN_DAYS = ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
|
||
const DAY_ORDER = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"];
|
||
|
||
const MODES = {
|
||
groups: { label: "Группа", listUrl: "/api/groups", valueKey: "schedule-group" },
|
||
teachers: { label: "Преподаватель", listUrl: "/api/teachers", valueKey: "schedule-teacher" },
|
||
rooms: { label: "Аудитория", listUrl: "/api/rooms", valueKey: "schedule-room" },
|
||
};
|
||
|
||
const PARITY_LABEL = { ч: "Числитель", з: "Знаменатель" };
|
||
const PARITY_CLASS = { ч: "badge badge-ch", з: "badge badge-zn" };
|
||
|
||
let currentParity = null;
|
||
let mode = "groups";
|
||
let requestSeq = 0;
|
||
const FETCH_TIMEOUT_MS = 30000;
|
||
|
||
function todayName() {
|
||
return RUSSIAN_DAYS[new Date().getDay()];
|
||
}
|
||
|
||
function enc(value) {
|
||
return encodeURIComponent(value);
|
||
}
|
||
|
||
async function fetchJSON(url) {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||
let resp;
|
||
try {
|
||
resp = await fetch(url, { signal: controller.signal });
|
||
} catch (err) {
|
||
throw new Error(err.name === "AbortError" ? "Время запроса истекло" : err.message);
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
if (!resp.ok) {
|
||
let detail = resp.statusText;
|
||
try {
|
||
const body = await resp.json();
|
||
if (body.detail) detail = body.detail;
|
||
} catch (_) {}
|
||
throw new Error(detail);
|
||
}
|
||
return resp.json();
|
||
}
|
||
|
||
async function loadWeek() {
|
||
try {
|
||
const data = await fetchJSON("/api/week");
|
||
if (data) currentParity = data.parity;
|
||
} catch (_) {}
|
||
}
|
||
|
||
function formatDT(iso) {
|
||
const d = new Date(iso);
|
||
if (isNaN(d.getTime())) return "";
|
||
const pad = (n) => String(n).padStart(2, "0");
|
||
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
}
|
||
|
||
async function loadStatus() {
|
||
try {
|
||
return await fetchJSON("/api/status");
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/* ---------- навигация по вкладкам ---------- */
|
||
|
||
function setActiveMenu(view) {
|
||
document.querySelectorAll(".side-menu a").forEach((a) => {
|
||
a.classList.toggle("active", a.dataset.view === view);
|
||
});
|
||
}
|
||
|
||
function fillOptions(list) {
|
||
groupSelect.innerHTML = "";
|
||
list.forEach((item) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = item;
|
||
opt.textContent = item;
|
||
groupSelect.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
async function setMode(next) {
|
||
mode = next;
|
||
localStorage.setItem("schedule-mode", next);
|
||
entityLabel.textContent = MODES[next].label;
|
||
setActiveMenu(next);
|
||
if (next === "rooms") {
|
||
entityLabel.textContent = "День";
|
||
groupSelect.classList.remove("hidden");
|
||
fillOptions(FREE_WEEKDAYS);
|
||
const savedDay = localStorage.getItem("schedule-room-day");
|
||
if (savedDay && FREE_WEEKDAYS.includes(savedDay)) groupSelect.value = savedDay;
|
||
await renderRoomsView(groupSelect.value);
|
||
return;
|
||
}
|
||
groupSelect.classList.remove("hidden");
|
||
const list = await fetchJSON(MODES[next].listUrl);
|
||
fillOptions(list);
|
||
if (!list.length) return;
|
||
const saved = localStorage.getItem(MODES[next].valueKey);
|
||
if (saved && list.includes(saved)) groupSelect.value = saved;
|
||
await renderView();
|
||
}
|
||
|
||
async function renderView() {
|
||
const value = groupSelect.value;
|
||
if (!value) return;
|
||
localStorage.setItem(MODES[mode].valueKey, value);
|
||
const seq = ++requestSeq;
|
||
scheduleEl.innerHTML = "";
|
||
errorEl.classList.add("hidden");
|
||
loadingEl.classList.remove("hidden");
|
||
try {
|
||
let data, status;
|
||
if (mode === "groups") {
|
||
[data, status] = await Promise.all([
|
||
fetchJSON(`/api/schedule/${enc(value)}`),
|
||
loadStatus(),
|
||
]);
|
||
if (seq !== requestSeq) return;
|
||
renderGroupSchedule(data, status);
|
||
} else {
|
||
[data, status] = await Promise.all([
|
||
fetchJSON(`/api/teacher/${enc(value)}`),
|
||
loadStatus(),
|
||
]);
|
||
if (seq !== requestSeq) return;
|
||
renderTeacher(data, status);
|
||
}
|
||
} catch (err) {
|
||
if (seq !== requestSeq) return;
|
||
errorEl.textContent = "Ошибка: " + err.message;
|
||
errorEl.classList.remove("hidden");
|
||
} finally {
|
||
if (seq === requestSeq) loadingEl.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
/* ---------- общие элементы ---------- */
|
||
|
||
function statusChip(label, value) {
|
||
const chip = document.createElement("div");
|
||
chip.className = "status-chip";
|
||
const lbl = document.createElement("span");
|
||
lbl.className = "status-label";
|
||
lbl.textContent = label;
|
||
const val = document.createElement("span");
|
||
val.className = "status-value";
|
||
val.textContent = value;
|
||
chip.appendChild(lbl);
|
||
chip.appendChild(val);
|
||
return chip;
|
||
}
|
||
|
||
function statusRowHTML(status) {
|
||
const row = document.createElement("div");
|
||
row.className = "schedule-status";
|
||
if (status) {
|
||
const modified = formatDT(status.table_modified);
|
||
const synced = formatDT(status.last_updated);
|
||
if (modified) row.appendChild(statusChip("Изменение таблицы", modified));
|
||
if (synced) row.appendChild(statusChip("Синхронизация", synced));
|
||
if (status.error) {
|
||
const chip = statusChip("Ошибка", status.error);
|
||
chip.classList.add("error");
|
||
row.appendChild(chip);
|
||
}
|
||
}
|
||
if (currentParity) {
|
||
const chip = document.createElement("div");
|
||
chip.className = "status-chip current-week";
|
||
const lbl = document.createElement("span");
|
||
lbl.className = "status-label";
|
||
lbl.textContent = "Сейчас";
|
||
const badge = document.createElement("span");
|
||
badge.className = "badge active " + (currentParity === "ч" ? "badge-ch" : "badge-zn");
|
||
badge.textContent = PARITY_LABEL[currentParity];
|
||
chip.appendChild(lbl);
|
||
chip.appendChild(badge);
|
||
row.appendChild(chip);
|
||
}
|
||
return row.childNodes.length ? row : null;
|
||
}
|
||
|
||
function makeDayBlock(day) {
|
||
const block = document.createElement("div");
|
||
block.className = "day-block" + (day === todayName() ? " today" : "");
|
||
const head = document.createElement("div");
|
||
head.className = "day-head";
|
||
head.innerHTML = `<span class="day-title">${day}</span>${day === todayName() ? '<span class="today-badge">сегодня</span>' : ""}`;
|
||
block.appendChild(head);
|
||
return block;
|
||
}
|
||
|
||
function parityColHTML(label, bodyHtml) {
|
||
return `<div class="parity-col">${bodyHtml}</div>`;
|
||
}
|
||
|
||
function alwaysColHTML(lessonsHtml) {
|
||
return `<div class="parity-col">${lessonsHtml}</div>`;
|
||
}
|
||
|
||
const OKNO_HTML = '<div class="lesson window">Окно</div>';
|
||
const FREE_HTML = '<div class="lesson window">Свободна</div>';
|
||
|
||
function addPeriodRow(block, num, time, lessonsHTML) {
|
||
const row = document.createElement("div");
|
||
row.className = "period";
|
||
row.innerHTML = `<div><div class="period-num">Пара ${num}</div><div class="period-time">${escapeHTML(time || "")}</div></div>`;
|
||
const col = document.createElement("div");
|
||
col.innerHTML = lessonsHTML;
|
||
row.appendChild(col);
|
||
block.appendChild(row);
|
||
}
|
||
|
||
function entityLessonHTML(parity, entry) {
|
||
const badge = parity
|
||
? `<span class="${PARITY_CLASS[parity]}${parity === currentParity ? " active" : ""}">${PARITY_LABEL[parity]}</span>`
|
||
: "";
|
||
const details = [];
|
||
if (entry.room) details.push(entry.room);
|
||
if (entry.teacher) details.push(entry.teacher);
|
||
if (entry.groups && entry.groups.length) details.push(entry.groups.join(", "));
|
||
return `
|
||
<div class="lesson">
|
||
<div>
|
||
${badge}
|
||
${entry.marker ? `<span class="badge badge-kv">${escapeHTML(entry.marker)}</span>` : ""}
|
||
<span class="subject">${withBreaks(entry.subject)}</span>
|
||
</div>
|
||
${details.length ? `<div class="details">${withBreaks(details.join(" · "))}</div>` : ""}
|
||
${entry.note ? `<div class="lesson-note">${withBreaks(entry.note)}</div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
/** Занятия числителя и знаменателя совпадают: неделя не важна. */
|
||
function sameEntries(chEntries, znEntries) {
|
||
return chEntries.length > 0 && JSON.stringify(chEntries) === JSON.stringify(znEntries);
|
||
}
|
||
|
||
/** Колонки занятий для страниц преподавателя и аудитории. */
|
||
function lessonsColHTML(chEntries, znEntries) {
|
||
if (sameEntries(chEntries, znEntries)) {
|
||
// не зависит от недели — одним списком, без бейджа недели
|
||
return `<div class="parities">${alwaysColHTML(chEntries.map((e) => entityLessonHTML(null, e)).join(""))}</div>`;
|
||
}
|
||
const chHtml = chEntries.map((e) => entityLessonHTML("ч", e)).join("");
|
||
const znHtml = znEntries.map((e) => entityLessonHTML("з", e)).join("");
|
||
return `<div class="parities">${parityColHTML("", chHtml || OKNO_HTML)}${parityColHTML("", znHtml || OKNO_HTML)}</div>`;
|
||
}
|
||
|
||
function singleLessonHTML(badgeKey, lesson, full) {
|
||
const config = {
|
||
always: { label: "", cls: "badge badge-always" },
|
||
ч: { label: "Числитель", cls: "badge badge-ch" },
|
||
з: { label: "Знаменатель", cls: "badge badge-zn" },
|
||
};
|
||
const { label, cls } = config[badgeKey];
|
||
const active = badgeKey !== "always" && badgeKey === currentParity ? " active" : "";
|
||
const badge = label ? `<span class="${cls}${active}">${label}</span>` : "";
|
||
const details = [lesson.teacher, lesson.room].filter(Boolean).join(" · ");
|
||
return `
|
||
<div class="lesson${full ? " full" : ""}">
|
||
<div>
|
||
${badge}
|
||
${lesson.marker ? `<span class="badge badge-kv">${escapeHTML(lesson.marker)}</span>` : ""}
|
||
<span class="subject">${withBreaks(lesson.subject)}</span>
|
||
</div>
|
||
${details ? `<div class="details">${withBreaks(details)}</div>` : ""}
|
||
${lesson.note ? `<div class="lesson-note">${withBreaks(lesson.note)}</div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
/* ---------- рендер расписания группы ---------- */
|
||
|
||
function renderGroupSchedule(data, status) {
|
||
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||
className: "schedule-title",
|
||
textContent: `Расписание группы ${data.group}`,
|
||
}));
|
||
if (data.semester_until) {
|
||
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||
className: "schedule-meta",
|
||
textContent: `Семестр до ${data.semester_until}`,
|
||
}));
|
||
}
|
||
const top = statusRowHTML(status);
|
||
if (top) scheduleEl.appendChild(top);
|
||
|
||
const daysContainer = document.createElement("div");
|
||
scheduleEl.appendChild(daysContainer);
|
||
let hasAnyLessons = false;
|
||
|
||
DAY_ORDER.forEach((day) => {
|
||
const periods = data.days[day];
|
||
if (!periods) return;
|
||
const block = makeDayBlock(day);
|
||
|
||
const rows = Object.entries(periods)
|
||
.sort(([a], [b]) => Number(a) - Number(b))
|
||
.map(([num, p]) => {
|
||
const always = p.ч && p.з && p.ч.raw === p.з.raw;
|
||
return { num, p, always, hasLesson: Boolean(always || p.ч || p.з) };
|
||
});
|
||
|
||
const lastWithLesson = rows.reduce((acc, r, i) => (r.hasLesson ? i : acc), -1);
|
||
if (lastWithLesson === -1) {
|
||
block.appendChild(Object.assign(document.createElement("div"), {
|
||
className: "lesson empty",
|
||
textContent: "Занятий нет",
|
||
}));
|
||
daysContainer.appendChild(block);
|
||
return;
|
||
}
|
||
|
||
rows.slice(0, lastWithLesson + 1).forEach(({ num, p, always }) => {
|
||
let inner;
|
||
if (always) {
|
||
inner = `<div class="parities">${alwaysColHTML(p.ч.lessons.map((l) => singleLessonHTML("always", l)).join(""))}</div>`;
|
||
hasAnyLessons = true;
|
||
} else {
|
||
const chHtml = p.ч ? p.ч.lessons.map((l) => singleLessonHTML("ч", l)).join("") : null;
|
||
const znHtml = p.з ? p.з.lessons.map((l) => singleLessonHTML("з", l)).join("") : null;
|
||
if (!chHtml && !znHtml) {
|
||
inner = `<div class="parities">${parityColHTML("Числитель", OKNO_HTML)}</div>`;
|
||
} else {
|
||
inner = `<div class="parities">${parityColHTML("Числитель", chHtml || OKNO_HTML)}${parityColHTML("Знаменатель", znHtml || OKNO_HTML)}</div>`;
|
||
hasAnyLessons = true;
|
||
}
|
||
}
|
||
addPeriodRow(block, num, p.time, inner);
|
||
});
|
||
|
||
daysContainer.appendChild(block);
|
||
});
|
||
|
||
if (!hasAnyLessons) {
|
||
daysContainer.innerHTML = "";
|
||
daysContainer.appendChild(Object.assign(document.createElement("div"), {
|
||
className: "empty-day",
|
||
textContent: "Расписание пустое",
|
||
}));
|
||
}
|
||
}
|
||
|
||
const TEACHER_DAYS = DAY_ORDER.slice(0, 6);
|
||
|
||
/* ---------- рендер преподавателя ---------- */
|
||
|
||
function renderTeacher(data, status) {
|
||
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||
className: "schedule-title",
|
||
textContent: `Преподаватель: ${data.full_name || data.name}`,
|
||
}));
|
||
const top = statusRowHTML(status);
|
||
if (top) scheduleEl.appendChild(top);
|
||
|
||
const daysContainer = document.createElement("div");
|
||
scheduleEl.appendChild(daysContainer);
|
||
let hasAny = false;
|
||
|
||
TEACHER_DAYS.forEach((day) => {
|
||
const periods = data.days[day];
|
||
const occupied = periods && Object.values(periods).some((s) => (s.ч || []).length || (s.з || []).length);
|
||
const block = makeDayBlock(day);
|
||
if (!occupied) {
|
||
block.appendChild(Object.assign(document.createElement("div"), {
|
||
className: "lesson empty",
|
||
textContent: "Занятий нет",
|
||
}));
|
||
daysContainer.appendChild(block);
|
||
return;
|
||
}
|
||
Object.entries(periods)
|
||
.sort(([a], [b]) => Number(a) - Number(b))
|
||
.forEach(([num, slot]) => {
|
||
const chEntries = slot.ч || [];
|
||
const znEntries = slot.з || [];
|
||
if (!chEntries.length && !znEntries.length) return;
|
||
hasAny = true;
|
||
addPeriodRow(block, num, slot.time, lessonsColHTML(chEntries, znEntries));
|
||
});
|
||
daysContainer.appendChild(block);
|
||
});
|
||
|
||
if (!hasAny) {
|
||
daysContainer.innerHTML = "";
|
||
daysContainer.appendChild(Object.assign(document.createElement("div"), {
|
||
className: "empty-day",
|
||
textContent: "Занятий нет",
|
||
}));
|
||
}
|
||
}
|
||
|
||
/* ---------- аудитории: занятость по дню и свободные на паре ---------- */
|
||
|
||
const FREE_WEEKDAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
|
||
const ROOMS_FILTER_KEY = "rooms-filter";
|
||
// порядок показа оснащения; сами теги берём из данных таблицы, чтобы новые
|
||
// пункты появлялись автоматически
|
||
const EQUIPMENT_ORDER = ["ПК", "Проектор", "Интерактивная панель", "Телевизор", "Маркер", "Меловая доска"];
|
||
const ROOM_MODES = { busy: "Занятость за день", free: "Свободные на паре" };
|
||
const ROOM_PERIODS = [1, 2, 3, 4, 5, 6, 7, 8];
|
||
|
||
let roomsState = { day: null, mode: "busy", period: 1, loaded: null, status: null, filter: loadRoomsFilter() };
|
||
|
||
/** Фильтры аудиторий живут между заходами. */
|
||
function loadRoomsFilter() {
|
||
try {
|
||
const raw = JSON.parse(localStorage.getItem(ROOMS_FILTER_KEY) || "{}");
|
||
return {
|
||
type: typeof raw.type === "string" ? raw.type : "",
|
||
capacity: raw.capacity ? String(raw.capacity) : "",
|
||
equipment: Array.isArray(raw.equipment) ? raw.equipment.filter((t) => typeof t === "string") : [],
|
||
mode: raw.mode === "or" ? "or" : "and",
|
||
};
|
||
} catch (_) {
|
||
return { type: "", capacity: "", equipment: [], mode: "and" };
|
||
}
|
||
}
|
||
|
||
function saveRoomsFilter() {
|
||
localStorage.setItem(ROOMS_FILTER_KEY, JSON.stringify(roomsState.filter));
|
||
}
|
||
|
||
/** Русское склонение существительного при числе. */
|
||
function pluralRu(count, one, few, many) {
|
||
const mod10 = count % 10;
|
||
const mod100 = count % 100;
|
||
if (mod10 === 1 && mod100 !== 11) return one;
|
||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few;
|
||
return many;
|
||
}
|
||
|
||
function roomsAll() {
|
||
return (roomsState.loaded && roomsState.loaded.rooms) || [];
|
||
}
|
||
|
||
/** Теги оснащения: сначала известные по порядку, затем всё остальное из данных. */
|
||
function equipmentTags() {
|
||
const found = new Set();
|
||
roomsAll().forEach((room) => (room.equipment || []).forEach((tag) => found.add(tag)));
|
||
const known = EQUIPMENT_ORDER.filter((tag) => found.has(tag));
|
||
const extra = [...found].filter((tag) => !EQUIPMENT_ORDER.includes(tag)).sort((a, b) => a.localeCompare(b, "ru"));
|
||
return [...known, ...extra];
|
||
}
|
||
|
||
/** Разобранные атрибуты аудитории: «комп.класс · 24 места · ПК, Проектор». */
|
||
function roomAttrsText(room) {
|
||
const parts = [];
|
||
if (room.type) parts.push(room.type);
|
||
if (room.capacity != null) parts.push(`${room.capacity} мест`);
|
||
if ((room.equipment || []).length) parts.push(room.equipment.join(", "));
|
||
return parts.join(" · ");
|
||
}
|
||
|
||
function roomMatchesFilter(room) {
|
||
const filter = roomsState.filter;
|
||
const group = room.type_group || "";
|
||
if (filter.type === "__none__") {
|
||
if (group) return false; // только аудитории без указанного типа
|
||
} else if (filter.type && group !== filter.type) {
|
||
return false;
|
||
}
|
||
const min = parseInt(filter.capacity, 10);
|
||
if (!isNaN(min) && min > 0 && (room.capacity == null || room.capacity < min)) return false;
|
||
const wanted = filter.equipment;
|
||
if (wanted.length) {
|
||
const has = new Set(room.equipment || []);
|
||
const ok = filter.mode === "or" ? wanted.some((t) => has.has(t)) : wanted.every((t) => has.has(t));
|
||
if (!ok) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function roomFreeAt(room, period) {
|
||
const slot = (room.periods || {})[String(period)] || {};
|
||
return !((slot.ч || []).length || (slot.з || []).length);
|
||
}
|
||
|
||
function roomAvailableOn(room) {
|
||
const days = room.available_days;
|
||
return !days || days.includes(roomsState.day);
|
||
}
|
||
|
||
async function renderRoomsView(day) {
|
||
roomsState.day = FREE_WEEKDAYS.includes(day)
|
||
? day
|
||
: (FREE_WEEKDAYS.includes(todayName()) ? todayName() : "понедельник");
|
||
const seq = ++requestSeq;
|
||
scheduleEl.innerHTML = "";
|
||
errorEl.classList.add("hidden");
|
||
loadingEl.classList.remove("hidden");
|
||
try {
|
||
const status = await loadStatus();
|
||
const data = await fetchJSON(`/api/rooms-day?day=${enc(roomsState.day)}`);
|
||
if (seq !== requestSeq) return;
|
||
roomsState.loaded = data;
|
||
roomsState.status = status;
|
||
drawRoomsScreen(status);
|
||
} catch (err) {
|
||
if (seq !== requestSeq) return;
|
||
errorEl.textContent = "Ошибка: " + err.message;
|
||
errorEl.classList.remove("hidden");
|
||
} finally {
|
||
if (seq === requestSeq) loadingEl.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
function drawRoomsScreen(status) {
|
||
scheduleEl.innerHTML = "";
|
||
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||
className: "schedule-title",
|
||
textContent: "Аудитории",
|
||
}));
|
||
const top = statusRowHTML(status);
|
||
if (top) scheduleEl.appendChild(top);
|
||
scheduleEl.appendChild(roomsToolbar());
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "rooms-wrap";
|
||
wrap.appendChild(roomsListBlock());
|
||
scheduleEl.appendChild(wrap);
|
||
updateRoomsSummary(); // панель уже в DOM — можно заполнить счётчик
|
||
}
|
||
|
||
function roomsToolbar() {
|
||
const all = roomsAll();
|
||
// близкие формулировки типов объединены в общий тип (room_type_group)
|
||
const types = [...new Set(all.map((r) => r.type_group).filter(Boolean))].sort((a, b) => a.localeCompare(b, "ru"));
|
||
const filter = roomsState.filter;
|
||
const box = document.createElement("div");
|
||
box.className = "rooms-filters";
|
||
box.innerHTML = `
|
||
<div class="rf-row rf-tabs">
|
||
${Object.entries(ROOM_MODES).map(([key, label]) =>
|
||
`<button type="button" class="rf-tab${roomsState.mode === key ? " active" : ""}" data-mode="${key}">${label}</button>`
|
||
).join("")}
|
||
${roomsState.mode === "free" ? `
|
||
<label class="rf-field rf-narrow"><span>Пара</span>
|
||
<select id="rf-period">${ROOM_PERIODS.map((p) =>
|
||
`<option value="${p}"${roomsState.period === p ? " selected" : ""}>Пара ${p}</option>`).join("")}</select>
|
||
</label>` : ""}
|
||
</div>
|
||
<div class="rf-row">
|
||
<label class="rf-field"><span>Тип аудитории</span>
|
||
<select id="rf-type">
|
||
<option value="">все типы</option>
|
||
${types.map((t) => `<option value="${escapeHTML(t)}"${filter.type === t ? " selected" : ""}>${escapeHTML(t)}</option>`).join("")}
|
||
<option value="__none__"${filter.type === "__none__" ? " selected" : ""}>без типа</option>
|
||
</select>
|
||
</label>
|
||
<label class="rf-field rf-narrow"><span>Мест не меньше</span>
|
||
<input id="rf-capacity" type="number" min="1" step="1" placeholder="например 30" value="${escapeHTML(filter.capacity)}">
|
||
</label>
|
||
<label class="rf-field rf-narrow"><span>Оснащение</span>
|
||
<select id="rf-mode">
|
||
<option value="and"${filter.mode === "and" ? " selected" : ""}>все выбранные (И)</option>
|
||
<option value="or"${filter.mode === "or" ? " selected" : ""}>любое из выбранных (ИЛИ)</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div class="rf-equip">
|
||
${equipmentTags().map((tag) => `
|
||
<label class="rf-check"><input type="checkbox" data-equip="${escapeHTML(tag)}"${filter.equipment.includes(tag) ? " checked" : ""}> ${escapeHTML(tag)}</label>
|
||
`).join("")}
|
||
</div>
|
||
<div class="rf-row rf-actions">
|
||
<button type="button" class="rf-reset" id="rf-reset">Сбросить фильтры</button>
|
||
<div class="rf-summary" id="rf-summary"></div>
|
||
</div>
|
||
<div class="rf-hint hidden" id="rf-hint"></div>
|
||
`;
|
||
|
||
box.querySelectorAll(".rf-tab").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
roomsState.mode = btn.dataset.mode;
|
||
drawRoomsScreen(roomsState.status);
|
||
});
|
||
});
|
||
const periodSel = box.querySelector("#rf-period");
|
||
if (periodSel) {
|
||
periodSel.addEventListener("change", () => {
|
||
roomsState.period = Number(periodSel.value);
|
||
refreshRoomsList();
|
||
});
|
||
}
|
||
box.querySelector("#rf-type").addEventListener("change", (e) => {
|
||
roomsState.filter.type = e.target.value;
|
||
saveRoomsFilter();
|
||
refreshRoomsList();
|
||
});
|
||
box.querySelector("#rf-capacity").addEventListener("input", (e) => {
|
||
roomsState.filter.capacity = e.target.value;
|
||
saveRoomsFilter();
|
||
refreshRoomsList();
|
||
});
|
||
box.querySelector("#rf-mode").addEventListener("change", (e) => {
|
||
roomsState.filter.mode = e.target.value;
|
||
saveRoomsFilter();
|
||
refreshRoomsList();
|
||
});
|
||
box.querySelectorAll("[data-equip]").forEach((input) => {
|
||
input.addEventListener("change", () => {
|
||
const tag = input.dataset.equip;
|
||
const set = new Set(roomsState.filter.equipment);
|
||
if (input.checked) set.add(tag); else set.delete(tag);
|
||
roomsState.filter.equipment = [...set];
|
||
saveRoomsFilter();
|
||
refreshRoomsList();
|
||
});
|
||
});
|
||
box.querySelector("#rf-reset").addEventListener("click", () => {
|
||
roomsState.filter = { type: "", capacity: "", equipment: [], mode: "and" };
|
||
saveRoomsFilter();
|
||
drawRoomsScreen(roomsState.status);
|
||
});
|
||
return box;
|
||
}
|
||
|
||
/** Перерисовать только список аудиторий (фильтры и поля ввода не трогаем). */
|
||
function refreshRoomsList() {
|
||
const old = scheduleEl.querySelector(".rooms-list, .rooms-empty");
|
||
const next = roomsListBlock();
|
||
if (old) old.replaceWith(next); else (scheduleEl.querySelector(".rooms-wrap") || scheduleEl).appendChild(next);
|
||
updateRoomsSummary();
|
||
}
|
||
|
||
function updateRoomsSummary() {
|
||
const summary = scheduleEl.querySelector("#rf-summary");
|
||
const hint = scheduleEl.querySelector("#rf-hint");
|
||
if (!summary) return;
|
||
const all = roomsAll();
|
||
const shown = visibleRooms();
|
||
const pieces = [`Показано ${shown.length} из ${all.length} аудиторий`];
|
||
if (roomsState.mode === "free") pieces.push(`свободны на паре ${roomsState.period}`);
|
||
summary.textContent = pieces.join(" · ");
|
||
|
||
const notes = [];
|
||
const min = parseInt(roomsState.filter.capacity, 10);
|
||
if (!isNaN(min) && min > 0) {
|
||
const unknown = all.filter((r) => r.capacity == null).length;
|
||
if (unknown) {
|
||
notes.push(`скрыто ${unknown} ${pluralRu(unknown, "аудитория", "аудитории", "аудиторий")}: в таблице нет данных о вместимости`);
|
||
}
|
||
}
|
||
if (roomsState.mode !== "free") {
|
||
const restricted = all.filter((r) => !roomAvailableOn(r)).length;
|
||
if (restricted) {
|
||
notes.push(`${pluralRu(restricted, "аудитория отмечена", "аудитории отмечены", "аудиторий отмечено")} как доступные только по отдельным дням`);
|
||
}
|
||
}
|
||
if (hint) {
|
||
hint.textContent = notes.join(". ");
|
||
hint.classList.toggle("hidden", !notes.length);
|
||
}
|
||
}
|
||
|
||
function visibleRooms() {
|
||
const filterFn = (room) => roomMatchesFilter(room);
|
||
if (roomsState.mode !== "free") return roomsAll().filter(filterFn);
|
||
return roomsAll().filter((room) => filterFn(room) && roomAvailableOn(room) && roomFreeAt(room, roomsState.period));
|
||
}
|
||
|
||
function roomsListBlock() {
|
||
const rooms = visibleRooms();
|
||
const list = document.createElement("div");
|
||
list.className = "rooms-list";
|
||
if (!rooms.length) {
|
||
list.className = "rooms-empty";
|
||
list.textContent = "Под фильтры ничего не подошло — ослабьте условия или сбросьте фильтры.";
|
||
return list;
|
||
}
|
||
const free = roomsState.mode === "free";
|
||
if (!free) {
|
||
const periodNums = [];
|
||
roomsAll().forEach((r) => Object.keys(r.periods || {}).forEach((p) => {
|
||
if (!periodNums.includes(p)) periodNums.push(p);
|
||
}));
|
||
periodNums.sort((a, b) => Number(a) - Number(b));
|
||
rooms.forEach((room) => list.appendChild(roomsDayCard(room, periodNums)));
|
||
return list;
|
||
}
|
||
rooms.forEach((room) => list.appendChild(roomsFreeCard(room)));
|
||
return list;
|
||
}
|
||
|
||
function roomsCardHead(room) {
|
||
const head = document.createElement("div");
|
||
head.className = "day-head";
|
||
const attrs = roomAttrsText(room);
|
||
const restricted = room.available_days && !roomAvailableOn(room);
|
||
head.innerHTML = `
|
||
<div class="room-title">
|
||
<span class="day-title">${escapeHTML(room.room)}</span>
|
||
${attrs ? `<div class="room-attrs">${escapeHTML(attrs)}</div>` : ""}
|
||
${room.info ? `<div class="room-info">${escapeHTML(room.info)}</div>` : ""}
|
||
${room.available_days ? `<div class="room-note">в таблице отмечено: только ${escapeHTML(room.available_days.join(", "))}</div>` : ""}
|
||
${restricted ? `<div class="room-note bad">по таблице в этот день занятий в ней нет</div>` : ""}
|
||
</div>`;
|
||
return head;
|
||
}
|
||
|
||
function roomsDayCard(room, periodNums) {
|
||
const card = document.createElement("div");
|
||
card.className = "room-card";
|
||
card.appendChild(roomsCardHead(room));
|
||
const periods = room.periods || {};
|
||
periodNums.forEach((p) => {
|
||
const slot = periods[p] || {};
|
||
const row = document.createElement("div");
|
||
row.className = "room-row";
|
||
const left = document.createElement("div");
|
||
left.className = "room-row-num";
|
||
left.innerHTML = `Пара ${p}${slot.time ? `<div class="period-time">${escapeHTML(slot.time)}</div>` : ""}`;
|
||
row.appendChild(left);
|
||
const body = document.createElement("div");
|
||
body.className = "room-row-body";
|
||
const chEntries = slot.ч || [];
|
||
const znEntries = slot.з || [];
|
||
if (!chEntries.length && !znEntries.length) {
|
||
body.innerHTML = `<div class="parities">${parityColHTML("", OKNO_HTML)}</div>`;
|
||
} else {
|
||
body.innerHTML = lessonsColHTML(chEntries, znEntries);
|
||
}
|
||
row.appendChild(body);
|
||
card.appendChild(row);
|
||
});
|
||
return card;
|
||
}
|
||
|
||
function roomsFreeCard(room) {
|
||
const card = document.createElement("div");
|
||
card.className = "room-card room-card-free";
|
||
card.appendChild(roomsCardHead(room));
|
||
const note = document.createElement("div");
|
||
note.className = "room-free-note";
|
||
note.textContent = `Свободна на паре ${roomsState.period} (${roomsState.day})`;
|
||
card.appendChild(note);
|
||
return card;
|
||
}
|
||
|
||
function escapeHTML(s) {
|
||
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
}
|
||
|
||
/**
|
||
* Экранирует текст и расставляет точки переноса <wbr>.
|
||
* Длинные ссылки (и вообще слова без пробелов) переносятся в любом браузере —
|
||
* не полагаемся на поддержку overflow-wrap: anywhere старыми движками.
|
||
*/
|
||
function withBreaks(text) {
|
||
const WBR = "\u0001"; // служебный маркер, в тексте таблицы не встречается
|
||
if (!text) return "";
|
||
const marked = String(text)
|
||
.replace(/([/?&=._\-])(?=\S)/g, "$1" + WBR) // после служебных символов ссылок
|
||
.replace(/(\S{24})(?=\S)/g, "$1" + WBR); // и не реже, чем каждые 24 символа
|
||
return escapeHTML(marked).split(WBR).join("<wbr>");
|
||
}
|
||
|
||
function applyTheme(theme) {
|
||
document.documentElement.setAttribute("data-theme", theme);
|
||
localStorage.setItem("theme", theme);
|
||
}
|
||
|
||
function initTheme() {
|
||
const saved = localStorage.getItem("theme");
|
||
applyTheme(saved === "dark" ? "dark" : "light");
|
||
}
|
||
|
||
themeToggle.addEventListener("click", () => {
|
||
const current = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
||
applyTheme(current === "dark" ? "light" : "dark");
|
||
});
|
||
|
||
initTheme();
|
||
|
||
document.querySelectorAll(".side-menu a").forEach((link) => {
|
||
link.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
setMode(link.dataset.view).catch((err) => {
|
||
errorEl.textContent = "Ошибка: " + err.message;
|
||
errorEl.classList.remove("hidden");
|
||
});
|
||
});
|
||
});
|
||
|
||
groupSelect.addEventListener("change", () => {
|
||
if (mode === "rooms") {
|
||
localStorage.setItem("schedule-room-day", groupSelect.value);
|
||
renderRoomsView(groupSelect.value).catch((err) => {
|
||
errorEl.textContent = "Ошибка: " + err.message;
|
||
errorEl.classList.remove("hidden");
|
||
});
|
||
return;
|
||
}
|
||
renderView();
|
||
});
|
||
|
||
loadWeek().finally(() => {
|
||
const savedMode = localStorage.getItem("schedule-mode");
|
||
const initial = ["groups", "teachers", "rooms"].includes(savedMode) ? savedMode : "groups";
|
||
setMode(initial).catch((err) => {
|
||
errorEl.textContent = "Ошибка: " + err.message;
|
||
errorEl.classList.remove("hidden");
|
||
});
|
||
});
|
||
|
||
// PWA: ярлык «на главный экран» с иконкой приложения (манифест + service worker)
|
||
if ("serviceWorker" in navigator) {
|
||
window.addEventListener("load", () => {
|
||
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||
});
|
||
}
|