Загрузка данных
/**
* ReportsPage инкапсулирует всю логику курсовой пагинации:
* - хранит курсоры "новые"/"старые"
* - рисует строки таблицы
* - управляет кнопками и сообщениями об ошибках
*/
/** Статусы прогонов и тест-кейсов. */
const TEST_STATUS = {
PASSED: "passed",
FAILED: "failed",
BROKEN: "broken",
SKIPPED: "skipped",
NO_DATA: "no_data",
PENDING: "pending",
LEGACY_FAIL: "fail",
};
class ReportsPage {
constructor({ dataUrl, limit }) {
// REST endpoint и лимит записей берутся из data-атрибутов шаблона
this.dataUrl = dataUrl;
this.limit = limit;
// Состояние курсоров для пагинации
this.state = {
nextCursor: null,
prevCursor: null,
};
// Конфигурация фильтров: какое имя параметра в query и какой ключ в ответе содержит значения.
this.filterConfig = {
stand: { param: "stand", responseKey: "stands" },
};
// Выбранные пользователем значения фильтров
this.filters = {
stand: [],
};
// Доступные значения фильтров, приходящие с сервера
this.availableFilters = {
stand: [],
};
// Фильтры по дате
this.dateFilters = {
from: null,
to: null,
};
this.tableBody = document.getElementById("reports-body");
this.message = document.getElementById("reports-message");
this.loadingIndicator = document.getElementById("reports-loading");
this.prevButton = document.getElementById("reports-prev");
this.nextButton = document.getElementById("reports-next");
this.tableWrapper = document.querySelector("[data-reports-table-wrapper]");
// Кэш исходной высоты таблицы (чтобы таблица не прыгала при коротких страницах)
this.defaultTableHeight = null;
// Элементы фильтра по дате
this.dateFromInput = document.getElementById("date-from");
this.dateToInput = document.getElementById("date-to");
this.dateApplyButton = document.getElementById("date-filter-apply");
this.dateResetButton = document.getElementById("date-filter-reset");
this.dateError = document.getElementById("date-error");
this.dateTrigger = document.getElementById("date-filter-trigger");
this.datePanel = document.getElementById("date-filter-panel");
this.filterControls = this.initFilterControls();
this.dateCounter = document.getElementById("date-filter-counter");
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.bindEvents();
// Загружаем первую страницу (последние записи)
this.loadPage();
}
/**
* Находит и собирает контролы управления фильтрами из DOM.
*
* Возвращает объект вида:
* {
* <key>: {
* container, toggle, panel, options, apply, counter, reset
* }
* }
*
* @returns {Object<string, Object>} controls - Собранные узлы управления фильтрами.
*/
initFilterControls() {
const controls = {};
Array.from(document.querySelectorAll("[data-filter-key]")).forEach((node) => {
const key = node.dataset.filterKey;
if (!key) {
return;
}
controls[key] = {
container: node,
toggle: node.querySelector("[data-filter-trigger]"),
panel: node.querySelector("[data-filter-panel]"),
options: node.querySelector("[data-filter-options]"),
apply: node.querySelector("[data-filter-apply]"),
counter: node.querySelector("[data-filter-counter]"),
reset: node.querySelector("[data-filter-reset]"),
};
// Отменяем всплытие внутри панели, чтобы клик по ней не закрывал панель
if (controls[key].panel) {
controls[key].panel.addEventListener("click", (event) => {
event.stopPropagation();
});
}
});
return controls;
}
/**
* Привязывает обработчики событий к элементам страницы:
* - кнопки пагинации
* - кнопки управления фильтрами
* - глобальный клик для закрытия панелей фильтров
*
* @returns {void}
*/
bindEvents() {
// "▲" — листаем к более новым записям
if (this.prevButton) {
this.prevButton.addEventListener("click", () => {
if (this.state.prevCursor) {
this.loadPage({ cursor: this.state.prevCursor, direction: "prev" });
}
});
}
// "▼" — листаем к более старым записям
if (this.nextButton) {
this.nextButton.addEventListener("click", () => {
if (this.state.nextCursor) {
this.loadPage({ cursor: this.state.nextCursor, direction: "next" });
}
});
}
Object.keys(this.filterControls).forEach((key) => {
const control = this.filterControls[key];
if (!control) {
return;
}
if (control.toggle) {
control.toggle.addEventListener("click", (event) => {
event.stopPropagation();
this.toggleFilterPanel(key);
});
}
if (control.apply) {
control.apply.addEventListener("click", () => {
this.handleFilterApply(key);
});
}
if (control.reset) {
control.reset.addEventListener("click", () => {
this.handleFilterReset(key);
});
}
});
// Закрываем открытые панели при клике вне панелей
document.addEventListener("click", this.handleDocumentClick);
// Обработчики для фильтров по дате
if (this.dateTrigger && this.datePanel) {
this.dateTrigger.addEventListener("click", (event) => {
event.stopPropagation();
this.toggleDatePanel();
});
// Авто-подстановка сегодняшней даты в "С" при первом открытии
this.dateTrigger.addEventListener("click", () => {
if (this.dateFromInput && !this.dateFromInput.value) {
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, "0");
const dd = String(today.getDate()).padStart(2, "0");
this.dateFromInput.value = `${yyyy}-${mm}-${dd}`;
}
});
}
if (this.dateApplyButton) {
this.dateApplyButton.addEventListener("click", () => {
this.handleDateFilterApply();
});
}
if (this.dateResetButton) {
this.dateResetButton.addEventListener("click", () => {
this.handleDateFilterReset();
});
}
// При нажатии Enter в полях даты — применить фильтр
if (this.dateFromInput) {
this.dateFromInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
this.handleDateFilterApply();
}
});
}
if (this.dateToInput) {
this.dateToInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
this.handleDateFilterApply();
}
});
}
}
/**
* Валидирует и применяет фильтр по дате.
*/
handleDateFilterApply() {
const fromValue = this.dateFromInput ? this.dateFromInput.value : null;
const toValue = this.dateToInput ? this.dateToInput.value : null;
// Скрываем предыдущую ошибку
this.showDateError("");
// Валидация: дата "с" должна быть <= дате "до"
if (fromValue && toValue && fromValue > toValue) {
this.showDateError("Дата 'С' должна быть меньше или равна дате 'По'");
return;
}
this.dateFilters.from = fromValue || null;
this.dateFilters.to = toValue || null;
this.closeDatePanel();
this.loadPage();
this.updateDateCounter();
}
/**
* Сбрасывает фильтр по дате.
*/
handleDateFilterReset() {
if (this.dateFromInput) {
this.dateFromInput.value = "";
}
if (this.dateToInput) {
this.dateToInput.value = "";
}
this.showDateError("");
const hadDateFilter = this.dateFilters.from || this.dateFilters.to;
this.dateFilters.from = null;
this.dateFilters.to = null;
if (hadDateFilter) {
this.closeDatePanel();
this.loadPage();
this.updateDateCounter();
} else {
this.closeDatePanel();
}
}
/**
* Показывает/скрывает сообщение об ошибке даты.
*/
showDateError(text) {
if (!this.dateError) {
return;
}
if (!text) {
this.dateError.classList.add("hidden");
this.dateError.textContent = "";
return;
}
this.dateError.textContent = text;
this.dateError.classList.remove("hidden");
}
handleDocumentClick(event) {
// Закрыть панель дат, если клик вне
if (this.datePanel && this.dateTrigger) {
if (!this.datePanel.contains(event.target) && !this.dateTrigger.contains(event.target)) {
this.closeDatePanel();
}
}
Object.keys(this.filterControls).forEach((key) => {
const control = this.filterControls[key];
if (!control || !control.container) {
return;
}
if (!control.container.contains(event.target)) {
this.closeFilterPanel(key);
}
});
}
toggleDatePanel() {
if (!this.datePanel) return;
const isOpen = !this.datePanel.classList.contains("hidden");
if (isOpen) {
this.closeDatePanel();
} else {
// закрываем остальные фильтры
Object.keys(this.filterControls).forEach((key) => this.closeFilterPanel(key));
this.datePanel.classList.remove("hidden");
}
}
closeDatePanel() {
if (this.datePanel) {
this.datePanel.classList.add("hidden");
}
}
updateDateCounter() {
if (!this.dateCounter) return;
const hasFilter = Boolean(this.dateFilters.from || this.dateFilters.to);
if (hasFilter) {
this.dateCounter.textContent = "●";
this.dateCounter.classList.remove("hidden");
} else {
this.dateCounter.textContent = "";
this.dateCounter.classList.add("hidden");
}
}
/**
* Переключает видимость панели фильтра (открыть/закрыть).
*
* @param {string} key - Ключ фильтра (например, "stand", "status").
* @returns {void}
*/
toggleFilterPanel(key) {
const control = this.filterControls[key];
if (!control || !control.panel) {
return;
}
const isOpen = !control.panel.classList.contains("hidden");
// Закрываем другие панели
Object.keys(this.filterControls).forEach((filterKey) => {
if (filterKey !== key) {
this.closeFilterPanel(filterKey);
}
});
if (isOpen) {
this.closeFilterPanel(key);
} else {
control.panel.classList.remove("hidden");
}
}
/**
* Закрывает панель конкретного фильтра.
*
* @param {string} key - Ключ фильтра.
* @returns {void}
*/
closeFilterPanel(key) {
const control = this.filterControls[key];
if (control && control.panel) {
control.panel.classList.add("hidden");
}
}
/**
* Применение фильтра — собирает выбранные значения чекбоксов и обновляет состояние фильтров.
* После применения:
* - обновляются счётчики
* - закрывается панель
* - перезагружается страница с сервера
*
* @param {string} key - Ключ фильтра.
* @returns {void}
*/
handleFilterApply(key) {
const control = this.filterControls[key];
if (!control || !control.options) {
return;
}
const selectedValues = Array.from(
control.options.querySelectorAll('input[type="checkbox"]')
)
.filter((input) => input.checked)
.map((input) => input.value);
this.filters[key] = selectedValues;
this.updateFilterCounters();
this.closeFilterPanel(key);
this.loadPage();
}
/**
* Сбрасывает фильтр: снимает все чекбоксы, обновляет счётчики и, если был выбор — перезагружает страницу.
*
* @param {string} key - Ключ фильтра.
* @returns {void}
*/
handleFilterReset(key) {
const control = this.filterControls[key];
if (!control || !control.options) {
return;
}
control.options
.querySelectorAll('input[type="checkbox"]')
.forEach((input) => {
input.checked = false;
});
const hadSelection = (this.filters[key] || []).length > 0;
this.filters[key] = [];
this.updateFilterCounters();
this.closeFilterPanel(key);
if (hadSelection) {
this.loadPage();
}
}
/**
* Показывает/скрывает индикатор загрузки и блокирует кнопки на время запроса.
*/
setLoading(isLoading) {
if (this.loadingIndicator) {
this.loadingIndicator.classList.toggle("hidden", !isLoading);
}
[this.prevButton, this.nextButton].forEach((button) => {
if (button) {
button.disabled = isLoading || button.dataset.disabled === "true";
}
});
}
/**
* Загружает очередную страницу отчётов.
* cursor=null означает стартовую страницу (последние записи).
*/
buildQueryParams({ cursor, direction }) {
const params = new URLSearchParams({ limit: this.limit, direction });
if (cursor) {
params.append("cursor", cursor);
}
Object.keys(this.filterConfig).forEach((key) => {
const paramName = this.filterConfig[key].param;
const selected = this.filters[key] || [];
selected.forEach((value) => {
params.append(paramName, value);
});
});
// Добавляем параметры фильтрации по дате
if (this.dateFilters.from) {
params.append("start_date_from", this.dateFilters.from);
}
if (this.dateFilters.to) {
params.append("start_date_to", this.dateFilters.to);
}
return params;
}
/**
* Загружает страницу отчётов с сервера и обновляет представление.
*
* Ожидает ответ формата:
* {
* items: Array<...>,
* next_cursor: string|null,
* prev_cursor: string|null,
* has_next: boolean,
* has_prev: boolean,
* filters: { stands: [...] }
* }
*
* @param {Object} [options] - Опции загрузки.
* @param {string|null} [options.cursor=null] - курсор для пагинации.
* @param {string} [options.direction="next"] - направление ("next" | "prev").
* @returns {Promise<void>}
*/
async loadPage({ cursor = null, direction = "next" } = {}) {
this.setLoading(true);
const params = this.buildQueryParams({ cursor, direction });
try {
const response = await fetch(`${this.dataUrl}?${params.toString()}`);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
this.renderRows(data.items);
this.updateState(data);
this.updateAvailableFilters(data.filters);
} catch (error) {
const errorText = "Не удалось загрузить отчёты. Попробуйте позже.";
this.showMessage(errorText, true);
if (this.tableBody) {
this.tableBody.innerHTML = `
<tr>
<td colspan="5" class="px-4 py-6 text-center text-sm text-red-600">
${errorText}
</td>
</tr>`;
}
console.error(error);
} finally {
this.setLoading(false);
}
}
/**
* Сохраняет курсоры и актуализирует активность кнопок списка.
*/
updateState({ next_cursor, prev_cursor, has_next, has_prev }) {
this.state.nextCursor = next_cursor;
this.state.prevCursor = prev_cursor;
if (this.nextButton) {
this.nextButton.disabled = !has_next;
this.nextButton.dataset.disabled = (!has_next).toString();
}
if (this.prevButton) {
this.prevButton.disabled = !has_prev;
this.prevButton.dataset.disabled = (!has_prev).toString();
}
}
/**
* Перерисовывает тело таблицы.
* При пустом списке показываем дружелюбное сообщение.
*/
updateAvailableFilters(filtersPayload) {
const payload =
filtersPayload && typeof filtersPayload === "object" ? filtersPayload : {};
Object.keys(this.filterConfig).forEach((key) => {
const responseKey = this.filterConfig[key].responseKey;
const valuesRaw = payload[responseKey];
this.availableFilters[key] = Array.isArray(valuesRaw) ? valuesRaw : [];
});
this.renderFilterOptions();
}
/**
* Рисует опции фильтра в панели (чекбоксы).
* Если нет доступных значений — выводит подсказку.
*
* @returns {void}
*/
renderFilterOptions() {
Object.keys(this.filterControls).forEach((key) => {
const control = this.filterControls[key];
if (!control || !control.options) {
return;
}
const values = this.availableFilters[key] || [];
if (!values.length) {
control.options.innerHTML =
'<p class="text-xs text-gray-400">Нет доступных значений</p>';
return;
}
control.options.innerHTML = values
.map((value, index) => {
const safeValue = this.escapeHtml(value);
const inputId = this.buildFilterInputId(key, value, index);
const selected = this.filters[key] || [];
const isChecked = selected.includes(value);
return `
<label class="flex items-center gap-2 text-xs text-gray-700" for="${inputId}">
<input id="${inputId}"
type="checkbox"
value="${safeValue}"
class="h-3 w-3 rounded border-indigo-600 text-indigo-600 accent-indigo-600 focus:ring-indigo-500"
${isChecked ? "checked" : ""}>
<span class="truncate" title="${safeValue}">${safeValue}</span>
</label>`;
})
.join("");
});
this.updateFilterCounters();
}
/**
* Строит безопасный id для инпута фильтра, нормализуя значение.
*
* @param {string} key - Ключ фильтра.
* @param {string} value - Значение опции.
* @param {number} index - Индекс в массиве (используется как fallback).
* @returns {string} id для input.
*/
buildFilterInputId(key, value, index) {
const normalized = String(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
return `filter-${key}-${normalized || index}`;
}
/**
* Обновляет видимые счётчики выбранных значений у каждого фильтра.
*
* @returns {void}
*/
updateFilterCounters() {
Object.keys(this.filterControls).forEach((key) => {
const control = this.filterControls[key];
if (!control || !control.counter) {
return;
}
const count = (this.filters[key] || []).length;
control.counter.textContent = count ? String(count) : "";
control.counter.classList.toggle("hidden", count === 0);
});
}
/**
* Возвращает CSS-класс размера текста для счётчика внутри сегмента шкалы.
*/
getStatusBarLabelClass(totalCount) {
if (totalCount >= 1000) {
return "text-[10px]";
}
if (totalCount >= 100) {
return "text-[11px]";
}
return "text-xs";
}
/**
* Возвращает стили текста для legacy-статуса.
*/
getLegacyStatusPresentation(statusValue) {
let statusClass = "text-gray-800";
let statusStyle = "";
if (statusValue === TEST_STATUS.FAILED || statusValue === TEST_STATUS.LEGACY_FAIL) {
statusClass = "text-red-600";
} else if (statusValue === TEST_STATUS.PASSED) {
statusClass = "text-green-600";
} else if (statusValue === TEST_STATUS.BROKEN) {
statusClass = "";
statusStyle = "color:#f97316;";
} else if (statusValue === TEST_STATUS.SKIPPED) {
statusClass = "text-gray-500";
} else if (statusValue === TEST_STATUS.NO_DATA) {
statusClass = "text-gray-400";
}
return { statusClass, statusStyle };
}
/**
* Рендерит текстовый статус для legacy-записей без status_stats.
*/
renderLegacyStatusText(item) {
const statusValue = String(item.status || "").toLowerCase();
const status = this.escapeHtml(item.status || "-");
const { statusClass, statusStyle } = this.getLegacyStatusPresentation(statusValue);
return (
`<span class="font-semibold ${statusClass}" style="${statusStyle}">${status}</span>`
);
}
/**
* Рендерит цветную шкалу статусов тестов.
*/
renderStatusBar(stats) {
const segments = [
{ key: TEST_STATUS.PASSED, count: stats.passed || 0, colorClass: "bg-green-500", label: TEST_STATUS.PASSED },
{ key: TEST_STATUS.FAILED, count: stats.failed || 0, colorClass: "bg-red-500", label: TEST_STATUS.FAILED },
{ key: TEST_STATUS.BROKEN, count: stats.broken || 0, colorClass: "bg-orange-500", label: TEST_STATUS.BROKEN },
{ key: TEST_STATUS.SKIPPED, count: stats.skipped || 0, colorClass: "bg-gray-400", label: TEST_STATUS.SKIPPED },
].filter((segment) => segment.count > 0);
const totalCount = stats.total || segments.reduce((sum, segment) => sum + segment.count, 0);
const labelClass = this.getStatusBarLabelClass(totalCount);
const ariaLabel = segments
.map((segment) => `${segment.label}: ${segment.count}`)
.join(", ");
const segmentHtml = segments
.map((segment) => {
const share = totalCount > 0 ? segment.count / totalCount : 0;
const showLabel = share >= 0.08;
const title = `${segment.label}: ${segment.count}`;
const labelHtml = showLabel
? `<span class="${labelClass} leading-none">${this.escapeHtml(String(segment.count))}</span>`
: "";
return (
`<div class="flex items-center justify-center min-w-0 text-white font-semibold ${segment.colorClass}" ` +
`style="flex:${segment.count}" title="${this.escapeHtml(title)}">` +
labelHtml +
`</div>`
);
})
.join("");
return (
`<div class="flex h-6 min-w-[180px] w-full rounded overflow-hidden" role="img" ` +
`aria-label="${this.escapeHtml(ariaLabel)}">` +
segmentHtml +
`</div>`
);
}
/**
* Рендерит ячейку статуса: no_data, legacy-текст или шкала.
*/
renderStatusCell(item) {
const statusValue = String(item.status || "").toLowerCase();
if (statusValue === TEST_STATUS.NO_DATA) {
return `<span class="text-sm font-semibold text-gray-400">${TEST_STATUS.NO_DATA}</span>`;
}
if (!item.status_stats) {
return this.renderLegacyStatusText(item);
}
if (item.status_stats.total === 0) {
return `<span class="text-sm font-semibold text-gray-400">${TEST_STATUS.NO_DATA}</span>`;
}
return this.renderStatusBar(item.status_stats);
}
/**
* Перерисовывает тело таблицы.
* При пустом списке — показывает сообщение.
*
* @param {Array<Object>} items - Массив элементов отчётов.
* @returns {void}
*/
renderRows(items = []) {
if (!this.tableBody) {
return;
}
if (!items.length) {
const emptyText = this.hasActiveFilters()
? "Ничего не найдено по выбранным фильтрам."
: "Отчётов пока нет.";
this.tableBody.innerHTML = `
<tr>
<td colspan="5" class="px-4 py-6 text-center text-sm text-gray-500">
${emptyText}
</td>
</tr>`;
this.showMessage(emptyText, false);
this.adjustTableHeight();
return;
}
this.showMessage("", false);
const rows = items
.map((item) => {
const id = this.escapeHtml(item.id ?? "-");
const startDate = this.escapeHtml(this.formatLocalDate(item.start_date));
const endDate = this.escapeHtml(this.formatLocalDate(item.end_date));
const stand = this.escapeHtml(item.stand || "-");
const statusCell = this.renderStatusCell(item);
return (
'<tr class="hover:bg-gray-50">' +
`<td class="px-4 py-2 text-sm">` +
`<a href="/reports/${id}" class="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer">` +
`Запуск #${id}` +
`</a>` +
`</td>` +
`<td class="px-4 py-2 text-sm text-gray-600">${startDate}</td>` +
`<td class="px-4 py-2 text-sm text-gray-600">${endDate}</td>` +
`<td class="px-4 py-2 text-sm text-gray-600">${stand}</td>` +
`<td class="px-4 py-2 text-sm min-w-[200px]">${statusCell}</td>` +
"</tr>"
);
})
.join("");
this.tableBody.innerHTML = rows;
this.adjustTableHeight();
}
/**
* Проверяет, есть ли активные фильтры.
*/
hasActiveFilters() {
const hasStandardFilters = Object.keys(this.filters).some(
(key) => (this.filters[key] || []).length > 0
);
const hasDateFilters = this.dateFilters.from || this.dateFilters.to;
return hasStandardFilters || hasDateFilters;
}
/**
* Управляет строкой сообщения под заголовком (ошибки, пустой список и т.п.).
*/
showMessage(text, isError) {
if (!this.message) {
return;
}
if (!text) {
this.message.classList.add("hidden");
this.message.textContent = "";
return;
}
this.message.textContent = text;
this.message.classList.remove("hidden");
this.message.classList.toggle("text-red-600", Boolean(isError));
this.message.classList.toggle("text-gray-500", !isError);
}
escapeHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* Форматирует ISO дату в локальный часовой пояс пользователя.
* @param {string} isoDateString - дата в формате ISO (например "2025-12-11T12:30:00")
* @returns {string} отформатированная дата или "-" если входные данные пустые
*/
formatLocalDate(isoDateString) {
if (!isoDateString || isoDateString === "-") {
return "-";
}
try {
const date = new Date(isoDateString);
if (isNaN(date.getTime())) {
return isoDateString;
}
const pad = (n) => String(n).padStart(2, "0");
const hh = pad(date.getHours());
const mm = pad(date.getMinutes());
const ss = pad(date.getSeconds());
const dd = pad(date.getDate());
const mon = pad(date.getMonth() + 1);
const yyyy = date.getFullYear();
return `${hh}:${mm}:${ss}, ${dd}.${mon}.${yyyy}`;
} catch {
return isoDateString;
}
}
/**
* Подгоняет минимальную высоту таблицы так, чтобы интерфейс не гулял, когда текущая страница содержит мало строк.
*
* Логика:
* - вычисляем высоту заголовка и примерной строки
* - если defaultTableHeight ещё не установлен — используем fallback: limit * rowHeight
* - минимальная высота = max(defaultTableHeight, header + bodyHeight)
*/
adjustTableHeight() {
if (!this.tableWrapper) {
return;
}
const header = this.tableWrapper.querySelector("thead");
const headerHeight = header
? header.getBoundingClientRect().height
: 0;
const sampleRow = this.tableBody
? this.tableBody.querySelector("tr")
: null;
const rowHeight = sampleRow
? sampleRow.getBoundingClientRect().height
: 48;
if (!this.defaultTableHeight) {
const fallbackLimit = Number(this.limit) || 0;
this.defaultTableHeight = headerHeight + rowHeight * fallbackLimit;
}
const bodyHeight = this.tableBody
? this.tableBody.getBoundingClientRect().height
: 0;
const desiredHeight = headerHeight + bodyHeight;
const targetHeight = Math.max(
this.defaultTableHeight || 0,
desiredHeight
);
this.tableWrapper.style.minHeight = `${Math.ceil(targetHeight)}px`;
}
}
// Явно публикуем класс в глобальную область для inline-инициализации в шаблоне.
if (typeof window !== "undefined") {
window.ReportsPage = ReportsPage;
}