Аудитории: фильтры по типу, вместимости и оснащению + режим «свободные на паре»

Бэкенд:
- parse_rooms_info разбирает описание аудитории (формат «тип; оснащение; N мест»)
  в атрибуты: type (фразой как в таблице), capacity (места, а для компьютерных
  классов — рабочие места; null, если данных нет), equipment (ПК, Проектор,
  Интерактивная панель, Телевизор, Маркер, Меловая доска) и available_days
  («402 четверг пятница»); «без проектора» трактуется как отсутствие;
- /rooms-day и /rooms-free отдают атрибуты (room_payload); аудитории с
  ограничением по дням не предлагаются в /rooms-free в остальные дни.

Фронтенд (вкладка «Аудитории»):
- панель фильтров: тип (значения из данных таблицы), «мест не меньше», оснащение
  чекбоксами с выбором условия «все (И)» / «любое (ИЛИ)»;
- счётчик «Показано N из M» и пояснения: сколько аудиторий скрыто из-за
  отсутствия данных о вместимости, сколько доступны только по отдельным дням;
- переключатель «Занятость за день» / «Свободные на паре» (с выбором пары) —
  вместе с фильтрами это «свободная аудитория на 30 человек с проектором в
  3-ю пару»; выбранные фильтры запоминаются в браузере;
- в карточке аудитории видны разобранные атрибуты и пометки из таблицы.

Тесты: разбор всех форматов описаний (включая отрицания и меловую доску),
API атрибутов и учёта доступных дней. Всего 159.
This commit is contained in:
2026-10-10 12:25:16 +03:00
parent 3d5bfe3023
commit 6f16736359
7 changed files with 626 additions and 68 deletions

View File

@@ -405,31 +405,107 @@ function renderTeacher(data, status) {
}
}
/* ---------- аудитории: занятость по выбранному дню ---------- */
/* ---------- аудитории: занятость по дню и свободные на паре ---------- */
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;
if (filter.type && (room.type || "") !== 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) {
const chosenDay = FREE_WEEKDAYS.includes(day) ? day : (FREE_WEEKDAYS.includes(todayName()) ? todayName() : "понедельник");
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(chosenDay)}`);
const data = await fetchJSON(`/api/rooms-day?day=${enc(roomsState.day)}`);
if (seq !== requestSeq) return;
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
className: "schedule-title",
textContent: "Занятость аудиторий",
}));
const top = statusRowHTML(status);
if (top) scheduleEl.appendChild(top);
const wrap = document.createElement("div");
wrap.className = "rooms-wrap";
scheduleEl.appendChild(wrap);
wrap.appendChild(roomsDayTable(data));
roomsState.loaded = data;
roomsState.status = status;
drawRoomsScreen(status);
} catch (err) {
if (seq !== requestSeq) return;
errorEl.textContent = "Ошибка: " + err.message;
@@ -439,53 +515,236 @@ async function renderRoomsView(day) {
}
}
function roomsDayTable(data) {
const periodNums = [];
data.rooms.forEach((r) => {
Object.keys(r.periods || {}).forEach((p) => {
if (!periodNums.includes(p)) periodNums.push(p);
});
});
periodNums.sort((a, b) => Number(a) - Number(b));
return buildRoomsList(data, periodNums);
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 buildRoomsList(data, periodNums) {
function roomsToolbar() {
const all = roomsAll();
const types = [...new Set(all.map((r) => r.type).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("")}
</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";
data.rooms.forEach((room) => {
const card = document.createElement("div");
card.className = "room-card";
const head = document.createElement("div");
head.className = "day-head";
head.innerHTML = `<div class="room-title"><span class="day-title">${escapeHTML(room.room)}</span>${room.info ? `<div class="room-info">${escapeHTML(room.info)}</div>` : ""}</div>`;
card.appendChild(head);
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);
});
list.appendChild(card);
});
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}