Аудитории: фильтры по типу, вместимости и оснащению + режим «свободные на паре»
Бэкенд: - 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:
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user