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

Бэкенд:
- 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

@@ -264,10 +264,14 @@ def create_app(
room_info = cache.room_info
free = []
for label in parsing.sort_rooms(set(rooms_map) | set(room_info)):
info = room_info.get(label, {})
# аудитория может быть доступна только по отдельным дням («402 четверг пятница»)
if info.get("available_days") and normalized not in info["available_days"]:
continue
occupied = rooms_map.get(label, {}).get(normalized, {}).get(str(period), {})
busy = bool((occupied.get("ч") or []) or (occupied.get("з") or []))
if not busy:
free.append({"room": label, "info": room_info.get(label, {}).get("info", "")})
free.append(parsing.room_payload(label, info))
return free
@app.get("/rooms-day")
@@ -278,7 +282,7 @@ def create_app(
day_times = cache.get_day_times().get(normalized, {})
rooms_out = []
for label in parsing.sort_rooms(set(rooms_map) | set(room_info)):
info = room_info.get(label, {}).get("info", "")
info = room_info.get(label, {})
occupied_day = rooms_map.get(label, {}).get(normalized, {})
periods = {}
for period in sorted(set(occupied_day) | set(day_times), key=int):
@@ -288,7 +292,9 @@ def create_app(
"ч": slot.get("ч") or [],
"з": slot.get("з") or [],
}
rooms_out.append({"room": label, "info": info, "periods": periods})
room = parsing.room_payload(label, info)
room["periods"] = periods
rooms_out.append(room)
return {"day": normalized, "rooms": rooms_out}
@app.get("/week")

View File

@@ -344,8 +344,95 @@ def build_room_schedule(rooms: dict, label: str, day_times: dict | None = None)
# --- вспомогательные листы ------------------------------------------------------
# --- атрибуты аудиторий из строки описания ------------------------------------
# В листе «аудитории» под номером лежит описание вида
# «комп.класс МО и СТ; без проектора; маркер; 12 мест» (части через «;», но
# встречаются и запятые). Разбираем его в тип, вместимость, оснащение и дни.
# Тип берём фразой как в таблице (порядок — от специфичных к общим).
_ROOM_TYPE_PATTERNS = [
re.compile(r"комп\.?\s*класс(?:\s+(?:Linux|Windows|OS))?", re.I),
re.compile(r"компьютерн\w*\s+класс(?:\s+(?:Linux|Windows|OS))?", re.I),
re.compile(r"кабинет\s+англ\w*", re.I),
re.compile(r"лекционно-практическая", re.I),
re.compile(r"лекционная", re.I),
re.compile(r"аудитория\s+для\s+практических", re.I),
re.compile(r"\bангл\w*", re.I),
re.compile(r"ауд\.?\s*каф\.?\s*[А-ЯA-Z]{0,4}\.?", re.I),
]
# Оснащение: тег -> как искать в тексте.
_ROOM_EQUIPMENT = [
("ПК", re.compile(r"\bпк\b|комп\.?\s*класс|компьютерн", re.I)),
("Проектор", re.compile(r"проектор", re.I)),
("Интерактивная панель", re.compile(r"интерактивн", re.I)),
("Телевизор", re.compile(r"телевизор", re.I)),
("Маркер", re.compile(r"маркер", re.I)),
("Меловая доска", re.compile(r"мелов", re.I)), # «меловая доска», «мелова доска»
]
# Корни дней недели: «четверг», «по пятницам» и т.п.
_WEEKDAY_STEMS = [
("понедельник", "понедельник"),
("вторник", "вторник"),
("среда", "сред"),
("четверг", "четверг"),
("пятница", "пятниц"),
("суббота", "суббот"),
("воскресенье", "воскресен"),
]
_ROOM_PART_SPLIT = re.compile(r"[;,]")
_NEGATION_RE = re.compile(r"\bбез\b\s*(.*)", re.I)
def room_capacity(text: str) -> int | None:
"""Вместимость: «N мест», «N раб.» (рабочие места в комп.классе)."""
m = re.search(r"(\d+)\s*мест", text, re.I)
if m:
return int(m.group(1))
m = re.search(r"(\d+)\s*раб", text, re.I) # «12 раб. + 1 осн.» — считаем рабочие
if m:
return int(m.group(1))
return None
def room_type(text: str) -> str:
"""Тип аудитории фразой из таблицы; пустая строка, если типа нет."""
for pattern in _ROOM_TYPE_PATTERNS:
m = pattern.search(text)
if m:
return re.sub(r"\s{2,}", " ", m.group(0).strip(" .,"))
return ""
def room_equipment(text: str) -> list[str]:
"""Оснащение тегами; «без проектора» означает, что проектора нет."""
negated: set[str] = set()
for part in _ROOM_PART_SPLIT.split(text):
m = _NEGATION_RE.search(part)
if not m:
continue
for tag, pattern in _ROOM_EQUIPMENT:
if pattern.search(m.group(1)):
negated.add(tag)
return [
tag
for tag, pattern in _ROOM_EQUIPMENT
if tag not in negated and pattern.search(text)
]
def room_weekdays(text: str) -> list[str] | None:
"""Дни, если в описании перечислены («402 четверг пятница») — иначе None."""
low = text.lower()
found = [name for name, stem in _WEEKDAY_STEMS if stem in low]
return found or None
def parse_rooms_info(sheets: dict[str, Worksheet]) -> dict:
"""Лист «аудитории»: 'ауд. N' -> {'room', 'info'} (оснащение, вместимость)."""
"""Лист «аудитории»: 'ауд. N' -> атрибуты аудитории.
{'room', 'info' (как в таблице), 'type', 'capacity', 'equipment', 'available_days'}
"""
sheet = sheets.get(ROOMS_SHEET)
if sheet is None or len(sheet.rows) < 2:
return {}
@@ -356,14 +443,34 @@ def parse_rooms_info(sheets: dict[str, Worksheet]) -> dict:
if not m:
continue
number = m.group(1)
text = " ".join(
ln.strip() for ln in str(desc[col]).split("\n") if ln.strip()
text = _strip_marks(
" ".join(ln.strip() for ln in str(desc[col]).split("\n") if ln.strip())
)
label = f"ауд. {number}"
info[label] = {"room": label, "info": _strip_marks(text)}
info[label] = {
"room": label,
"info": text,
"type": room_type(text),
"capacity": room_capacity(text),
"equipment": room_equipment(text),
"available_days": room_weekdays(text),
}
return info
def room_payload(label: str, info: dict | None = None) -> dict:
"""Аудитория для API: номер, описание как в таблице и разобранные атрибуты."""
info = info or {}
return {
"room": label,
"info": info.get("info", ""),
"type": info.get("type", ""),
"capacity": info.get("capacity"),
"equipment": list(info.get("equipment") or []),
"available_days": info.get("available_days"),
}
def _initials_from_parts(first: str, patronymic: str) -> str:
initials = ""
if first:

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]));
}

View File

@@ -651,6 +651,75 @@ main {
/* ---- аудитории: список карточками (мобильная версия) ---- */
.rooms-list { display: flex; flex-direction: column; }
/* ---- фильтры аудиторий ---- */
.rooms-filters {
background: var(--surface);
border: 1px solid var(--border);
padding: 12px 14px;
margin-bottom: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
.rf-tabs { align-items: center; }
.rf-tab {
font-size: 14px;
font-weight: 600;
padding: 7px 12px;
cursor: pointer;
border: 1px solid var(--border-2);
background: var(--surface-2);
color: var(--text);
}
.rf-tab.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.rf-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 0 1 260px; }
.rf-field > span { font-size: 12px; color: var(--muted); }
.rf-field select,
.rf-field input {
font-size: 14px;
padding: 7px 9px;
border: 1px solid var(--border-2);
background: var(--surface-2);
color: var(--text);
width: 100%;
min-width: 0;
}
.rf-narrow { flex: 0 1 170px; }
.rf-equip { display: flex; flex-wrap: wrap; gap: 8px 16px; padding-top: 2px; }
.rf-check { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text); cursor: pointer; }
.rf-actions { justify-content: space-between; align-items: center; }
.rf-reset {
font-size: 13px;
padding: 7px 10px;
cursor: pointer;
border: 1px solid var(--border-2);
background: var(--surface-2);
color: var(--text);
}
.rf-summary { font-size: 13px; color: var(--muted); }
.rf-hint { font-size: 12px; color: #b8860b; }
.rooms-empty {
background: var(--surface);
border: 1px dashed var(--border-2);
padding: 18px;
color: var(--muted);
font-size: 14px;
}
.room-card-free { padding-bottom: 12px; }
.room-attrs { font-size: 13px; font-weight: 600; color: var(--text); margin-top: 3px; }
.room-note { font-size: 12px; color: var(--muted); margin-top: 2px; }
.room-note.bad { color: var(--accent-red-bright); font-weight: 600; }
.room-free-note { font-size: 13px; color: var(--muted); margin-top: 10px; }
.room-card {
background: var(--surface);
border: 1px solid var(--border);