/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/components/FilterPanel.js
1 503 строки
59 KB
Starolat Sergei
fix: сброс быстрого фильтра по физобъемам при перезагрузке приложения
22 июл 2026, 12:54
22 июл 2026, 12:54
5128e16
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview FilterPanel - Панель управления фильтрами данных (v3.10.1) * * Использует <code-picker> для выбора справочника и кода. * Делегирует управление состоянием фильтров в FilterStateManager (SYS-006). * Слушает `filter-state-change` для синхронизации UI. * * v3.10.2 — Активный фильтр использует цвет Consta. * - `_renderCodeFilterChips` больше не использует `value.color` из БД для * индикатора активного фильтра. Активный шарик всегда заполнен * `var(--accent-color)`, неактивный — прозрачный. Это устраняет * некорректные цвета (например, синий #0000FF) у дефолтных фильтров. * * v3.10.1 — Нормализация цвета активных фильтров. * - `_normalizeColor` добавляет `#` к bare-hex строкам, чтобы * `background-color` индикатора `.code-value-radio` не игнорировался * браузером (например, "0000FF" → "#0000FF"). Исправляет пустые * шарики у активных фильтров S и "Уровень графика" = 3. * * v3.10.0 — Single radio indicator. * - `_renderCodeFilterChips` рендерит активные фильтры с единым `.code-value-radio`, * заполненным цветом value (или `--accent-color`). Устраняет "двойные шары" * при погружении на детальные уровни. Toggle value работает по клику на * radio/name/desc. * * v3.9.1 — Level filter normalization. * - `_syncLevelBaseFilters` использует `findCodeTypeByName` из `DataIndexer` * для корректного мэтчинга `baseFilters` с code types в БД, включая * Primavera-имена с ведущим `!` (`!Уровень графика`). * * v3.9.0 — Level base filters как обычные фильтры (Вариант 2). * - `baseFilters` активного уровня синхронизируются с FilterStateManager как * `levelCodeFilters` (source: 'level'). * - Level filters рендерятся в активных фильтрах с badge «уровень», поддерживают * per-value toggle/remove и отключение фильтра целиком. * - Удалённые/отключённые level filters восстанавливаются при каждом level-change. * - `!Уровень графика` = '3' на manhours-уровнях стал отключаемым фильтром. * * v3.6.0 — FEAT-004: per-value управление code-фильтрами в зоне активных фильтров. * - Каждое выбранное значение справочника отображается отдельной строкой. * - Клик по индикатору/имени value включает/выключает только это value. * - Кнопка X удаляет только это value (а не весь справочник). * * v3.5.1 — FEAT-004: восстановлена компактная стилизация активных фильтров. * - Code-фильтры отображаются одной строкой: radio + значения + тип. * - Убраны объёмные чипы и per-value кнопки удаления из зоны активных фильтров. * * v3.5.0 — FEAT-004: множественный выбор значений Activity Code. * - `addCodeFilter` передаёт флаг `ctrlKey` в FilterStateManager (Ctrl/Cmd+Click). * - Активные фильтры группируют все выбранные value под названием справочника. * * v3.3.0 — Удалена секция фильтра по материалам (Material Filter). * - Фильтрация по материальным ресурсам теперь выполняется в отдельном виджете * <resource-filter> (COMP-026), чтобы избежать дублирования функциональности. * * v3.8.2 — Учитываем Shadow DOM MainContentTabs при поиске summary-drill-navigator * и запоминаем currentLevelId из level-change. Document-слушатели идемпотентны * при reconnect. * * v3.8.1 — Игнорируем context-updated от неактивного уровня (см. FilterResourceTabs v1.2.1). * * v3.8.0 — Отображение implicit/base фильтров активного уровня. * - Слушает `context-updated` от LevelLayoutPanel. * - Рендерит baseFilters как read-only системные фильтры с badge «уровень». * - Убран устаревший `!Уровень графика` = '3' из `assets/layouts/default.json`. * * v3.7.0 — FEAT-004-phase5: дефолтный фильтр `!Уровень графика` = '3' перенесён * на уровневые baseFilters (dataDomain === 'manhours'). Глобально остаётся * только `ЦПС_Отчетность` = 'S'. * * v3.1.1 — Добавлена автоматическая установка дефолтных бизнес-фильтров. * - При `database-ready` устанавливает фильтр `ЦПС_Отчетность` = 'S' * - Только если фильтры ещё пусты (не перезаписывает пользовательские) * - Пользователь может отключить/удалить дефолтные фильтры * * v3.1.0 — Добавлена секция выбора диапазона недель (Week Range Filter). * - Два <select> «От недели» / «До недели» (данные из WeekIndexService) * - Кнопка «Сбросить период» * - Chip с активным диапазоном * - Синхронизация с filter-state-change (week-filter-change) * * @version 3.10.2 */ import "./CodePicker.js"; import filterState from "../services/FilterStateManager.js"; import weekIndexService from "../services/WeekIndexService.js"; import dataService from "../services/DataService.js"; import dataIndexer, { findCodeTypeByName, } from "../services/DataIndexer.js"; import defaultFilterReadiness from "../services/DefaultFilterReadiness.js"; import { ArrowDown, Layout, X, Draggable } from "./ConstantinIcons.js"; /** * @typedef {import('../types.js').CodeFilterItem} CodeFilterItem * @typedef {import('../types.js').CodeFilterValueItem} CodeFilterValueItem * @typedef {import('../types.js').CodePickerSelection} CodePickerSelection * @typedef {import('../types.js').WeekInfo} WeekInfo */ class FilterPanel extends HTMLElement { constructor() { super(); /** @type {boolean} */ this._embedded = false; /** @type {Function|null} */ this._filterStateHandler = null; /** @type {Function|null} */ this._databaseReadyHandler = null; /** @type {Function|null} */ this._databaseRemovedHandler = null; /** @type {string|null} */ this._currentDatabaseId = null; /** @type {WeekInfo[]} */ this._weeks = []; /** * Promise-based lock для _applyDefaultFilters: параллельные вызовы * (database-ready + FilterResourceTabs.applyDefaultFilters) ждут одного * и того же запроса вместо двойного применения фильтров. * @type {Map<string, Promise<void>>} */ this._applyDefaultFiltersLocks = new Map(); /** * Implicit/base фильтры текущего активного уровня (dataDomain/baseFilters). * Отображаются в панели фильтров как read-only системные фильтры. * @type {import('../types.js').Filter[]} */ this._levelBaseFilters = []; /** * Домен данных текущего активного уровня ('manhours' | 'material'). * @type {string|null} */ this._levelDataDomain = null; /** * ID текущего активного уровня (из события level-change). * summary-drill-navigator находится внутри Shadow DOM MainContentTabs. * @type {string|null} */ this._currentLevelId = null; /** @type {Function|null} */ this._levelContextHandler = null; /** @type {Function|null} */ this._levelChangeHandler = null; this._disableHostDrag = this._disableHostDrag.bind(this); this._restoreHostDrag = this._restoreHostDrag.bind(this); this._onLevelContextUpdated = this._onLevelContextUpdated.bind(this); this._onLevelChange = this._onLevelChange.bind(this); } /** * Возвращает true, если компонент встроен в <filter-resource-tabs>. * В embedded-режиме рендерится только <code-picker>, без активных фильтров и week range. * @returns {boolean} */ get embedded() { return this._embedded; } /** * Активные фильтры (включённые и деактивированные). * Читаются напрямую из FilterStateManager. * @returns {CodeFilterItem[]} */ get activeFilters() { return filterState.getState().codeFilters; } connectedCallback() { this._embedded = this.hasAttribute("embedded"); const wasRendered = this.querySelector(".filter-panel-body") !== null; if (!wasRendered) { this.render(); this.setupEventListeners(); } // Идемпотентно навешиваем документ-слушатели: при перемещении элемента // disconnectedCallback снимает их, reconnect должен повесить заново. this._detachDocumentListeners(); this.addEventListener("mousedown", this._disableHostDrag); this.addEventListener("pointerdown", this._disableHostDrag); this._filterStateHandler = (/** @type {CustomEvent} */ e) => this._onFilterStateChange(e); document.addEventListener("filter-state-change", this._filterStateHandler); this._databaseReadyHandler = (/** @type {CustomEvent} */ e) => this._onDatabaseReady(e); document.addEventListener("database-ready", this._databaseReadyHandler); this._databaseRemovedHandler = (/** @type {CustomEvent} */ e) => this._onDatabaseRemoved(e); document.addEventListener("database-removed", this._databaseRemovedHandler); // Слушаем изменения контекста активного уровня, чтобы отобразить его // implicit/base фильтры как read-only системные фильтры. this._levelContextHandler = (/** @type {CustomEvent} */ e) => this._onLevelContextUpdated(e); document.addEventListener("context-updated", this._levelContextHandler); // Следим за сменой уровня, чтобы знать активный levelId (summary-drill-navigator // находится внутри Shadow DOM MainContentTabs, document.querySelector не находит). this._levelChangeHandler = (/** @type {CustomEvent} */ e) => this._onLevelChange(e); document.addEventListener("level-change", this._levelChangeHandler); // Синхронизировать UI с текущим состоянием менеджера if (!this._embedded) { this.renderActiveFilters(); this._updateWeekRangeChip(); } customElements .whenDefined("code-picker") .then(() => this._updateCodePicker()); // Если БД уже загружена (компонент подключился после события database-ready), // применяем дефолтные фильтры напрямую const activeDbId = dataService.getActiveDatabaseId(); if (activeDbId && !this._currentDatabaseId) { console.log( `[FilterPanel] DB already active on connect (${activeDbId}), applying default filters`, ); this._currentDatabaseId = activeDbId; this._applyDefaultFilters(activeDbId); // В обычном режиме также загружаем недели if (!this._embedded) { const cached = weekIndexService.getAllWeeks(activeDbId); if (cached.length > 0) { this._weeks = cached; this._populateWeekSelects(); this._updateWeekRangeUI(); } else { weekIndexService .buildFromDatabase(activeDbId) .then((weeks) => { this._weeks = weeks; this._populateWeekSelects(); this._updateWeekRangeUI(); }) .catch((err) => { console.warn( "[FilterPanel] Failed to build week index on connect:", err, ); }); } } } } /** * Снимает все документ-уровневые слушатели и локальные drag-слушатели. * Используется перед повторным подключением, чтобы избежать дублирования. * @private */ _detachDocumentListeners() { this.removeEventListener("mousedown", this._disableHostDrag); this.removeEventListener("pointerdown", this._disableHostDrag); document.removeEventListener("mouseup", this._restoreHostDrag); document.removeEventListener("pointerup", this._restoreHostDrag); if (this._filterStateHandler) { document.removeEventListener( "filter-state-change", this._filterStateHandler, ); this._filterStateHandler = null; } if (this._databaseReadyHandler) { document.removeEventListener( "database-ready", this._databaseReadyHandler, ); this._databaseReadyHandler = null; } if (this._databaseRemovedHandler) { document.removeEventListener( "database-removed", this._databaseRemovedHandler, ); this._databaseRemovedHandler = null; } document.removeEventListener("context-updated", this._levelContextHandler); if (this._levelContextHandler) { this._levelContextHandler = null; } if (this._levelChangeHandler) { document.removeEventListener("level-change", this._levelChangeHandler); this._levelChangeHandler = null; } } disconnectedCallback() { this._detachDocumentListeners(); } _disableHostDrag(/** @type {MouseEvent} */ e) { const header = this.querySelector(".module-header") || this.querySelector("h3"); if (!header) return; if ( e.target.closest("input, textarea, select, button, [contenteditable]") ) { if (header.getAttribute("draggable") === "true") { header.setAttribute("draggable", "false"); this._dragRestored = false; document.addEventListener("mouseup", this._restoreHostDrag, { once: true, }); document.addEventListener("pointerup", this._restoreHostDrag, { once: true, }); } } } _restoreHostDrag() { if (!this._dragRestored) { const header = this.querySelector(".module-header") || this.querySelector("h3"); if (header) header.setAttribute("draggable", "true"); this._dragRestored = true; } } render() { if (this._embedded) { this._renderEmbedded(); return; } this._renderFull(); } /** * Embedded-рендер: только <code-picker>, без заголовка, активных фильтров и week range. * Используется внутри <filter-resource-tabs>. * @private */ _renderEmbedded() { this.innerHTML = ` <style> filter-panel { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; } filter-panel .filter-panel-body { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; } filter-panel .available-settings { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; } filter-panel .available-settings-content { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; } filter-panel .available-settings-content .code-picker-search .compact-input { border-radius: var(--radius-s, 4px); } filter-panel .available-settings-content code-picker { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; } </style> <div class="filter-panel-body filter-panel-body--embedded"> <div class="filter-section available-settings" aria-label="Доступные настройки фильтрации"> <div id="available-settings-content" class="available-settings-content"> <code-picker></code-picker> </div> </div> </div> `; this._updateCodePicker(); } /** * Полный рендер для standalone-использования. * @private */ _renderFull() { this.innerHTML = ` <style> filter-panel .available-settings-content .code-picker-search .compact-input { border-radius: var(--radius-s, 4px); } filter-panel .filter-active-list { gap: 0; } filter-panel .week-range-section { display: flex; flex-direction: column; gap: var(--space-2xs, 4px); } filter-panel .week-range-header { display: flex; align-items: center; justify-content: space-between; min-height: 20px; } filter-panel .week-range-selects { display: flex; align-items: center; gap: var(--space-2xs, 4px); } filter-panel .compact-select { flex: 1; height: 24px; padding: 0 4px; font-size: var(--font-size-xs, 10px); color: var(--text-primary); background: var(--input-bg, var(--surface-bg)); border: 1px solid var(--border-color); border-radius: 2px; outline: none; cursor: pointer; min-width: 0; } filter-panel .compact-select:disabled { opacity: 0.5; cursor: not-allowed; color: var(--text-tertiary); } filter-panel .compact-select:focus { border-color: var(--accent-color); } filter-panel .week-range-separator { font-size: var(--font-size-xs, 10px); color: var(--text-secondary); flex-shrink: 0; } filter-panel .week-range-reset { font-size: var(--font-size-xs, 10px); color: var(--accent-color); background: transparent; border: none; cursor: pointer; padding: 0; height: 20px; line-height: 20px; } filter-panel .week-range-reset:hover { text-decoration: underline; } filter-panel .week-range-reset:disabled { opacity: 0.4; cursor: not-allowed; text-decoration: none; } filter-panel .week-range-chip { display: flex; align-items: center; gap: var(--space-2xs, 4px); padding: 2px var(--space-xs, 8px); background: var(--accent-color); color: var(--accent-text, #fff); border-radius: var(--border-radius, 4px); font-size: var(--font-size-xs, 10px); font-weight: 500; min-height: 20px; width: fit-content; } filter-panel .week-range-chip .chip-remove { display: inline-flex; align-items: center; justify-content: center; width: 12px; height: 12px; padding: 0; margin: 0; background: transparent; border: none; color: inherit; cursor: pointer; opacity: 0.8; } filter-panel .week-range-chip .chip-remove:hover { opacity: 1; } </style> <div class="module-header"> <h3><span class="draggable-icon" style="display: inline-flex; width: 20px; height: 20px; vertical-align: middle; margin-right: 2px; color: var(--text-tertiary);">${Draggable}</span>Фильтры</h3> <div class="module-actions"> <button class="collapse-btn icon-button" title="Свернуть/Развернуть"><span class="arrow-icon">${ArrowDown}</span></button> <button class="dock-toggle-btn icon-button" data-target="filter-module" title="Открепить/Закрепить">${Layout}</button> </div> </div> <div class="filter-panel-body"> <div class="filter-section" aria-label="Активные фильтры"> <div id="active-filters-list" class="filter-active-list" role="list" aria-live="polite" aria-label="Список активных фильтров"> <!-- Активные фильтры рендерятся здесь --> </div> </div> <div class="filter-section week-range-section" aria-label="Диапазон недель"> <div class="week-range-header"> <span class="filter-section-title" style="font-size:var(--font-size-xs,10px);font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-secondary);">Период</span> <button class="week-range-reset" id="week-range-reset" disabled title="Сбросить период">Сбросить</button> </div> <div class="week-range-selects"> <select class="compact-select week-range-from" id="week-range-from" aria-label="От недели" disabled> <option value="">Все недели</option> </select> <span class="week-range-separator">–</span> <select class="compact-select week-range-to" id="week-range-to" aria-label="До недели" disabled> <option value="">Все недели</option> </select> </div> <div class="week-range-chip" id="week-range-chip" style="display:none;" role="status" aria-live="polite"></div> </div> <div class="filter-section available-settings" aria-label="Доступные настройки фильтрации"> <div id="available-settings-content" class="available-settings-content"> <code-picker></code-picker> </div> </div> </div> `; this.renderActiveFilters(); this._updateCodePicker(); } setupEventListeners() { // Делегирование событий на корневом элементе this.addEventListener("click", (e) => { if (this._embedded) return; const target = /** @type {HTMLElement} */ (e.target); // Удаление отдельного значения code-фильтра (level или user) const removeBtn = target.closest(".remove-filter[data-code-value-id]"); if (removeBtn) { const typeId = parseInt(removeBtn.dataset.codeTypeId || "0", 10); const valueId = parseInt(removeBtn.dataset.codeValueId || "0", 10); const source = removeBtn.dataset.source || "user"; if (typeId && valueId) { if (source === "level") { filterState.removeLevelCodeFilterValue(typeId, valueId); } else { filterState.removeCodeFilterValue(typeId, valueId); } } return; } // Удаление фильтра по коду целиком (legacy / без value-id, level или user) const removeFilterBtn = target.closest(".remove-filter"); if (removeFilterBtn) { const typeId = parseInt(removeFilterBtn.dataset.codeTypeId || "0", 10); const source = removeFilterBtn.dataset.source || "user"; if (typeId) { if (source === "level") { filterState.removeLevelCodeFilter(typeId); } else { this.removeCodeFilter(typeId); } } return; } // Переключение enabled отдельного значения code-фильтра (level или user) const toggleTrigger = target.closest( ".filter-toggle-label[data-code-value-id]", ); if (toggleTrigger) { const typeId = parseInt(toggleTrigger.dataset.codeTypeId || "0", 10); const valueId = parseInt(toggleTrigger.dataset.codeValueId || "0", 10); const source = toggleTrigger.dataset.source || "user"; if (typeId && valueId) { if (source === "level") { filterState.toggleLevelCodeFilterValue(typeId, valueId); } else { filterState.toggleCodeFilterValue(typeId, valueId); } } return; } // Переключение enabled фильтра целиком (legacy / без value-id, level или user) const toggleFilterTrigger = target.closest( ".filter-toggle-label", ); if (toggleFilterTrigger) { const typeId = parseInt( toggleFilterTrigger.dataset.codeTypeId || "0", 10, ); const source = toggleFilterTrigger.dataset.source || "user"; if (typeId) { if (source === "level") { filterState.toggleLevelCodeFilter(typeId); } else { this.toggleCodeFilter(typeId); } } return; } // Сброс диапазона недель (week range reset button OR week chip remove) const resetWeekBtn = target.closest("#week-range-reset, .chip-remove"); if (resetWeekBtn) { this._onClearWeekRange(); return; } }); // Выбор кода из <code-picker> this.addEventListener("code-value-select", (e) => { const detail = /** @type {CustomEvent} */ (e).detail; if (detail && detail.typeId != null && detail.valueId != null) { this.addCodeFilter(detail); } }); // Изменение диапазона недель (только в полном режиме) if (!this._embedded) { const fromSelect = this.querySelector("#week-range-from"); const toSelect = this.querySelector("#week-range-to"); if (fromSelect) { fromSelect.addEventListener("change", () => this._onWeekRangeChange()); } if (toSelect) { toSelect.addEventListener("change", () => this._onWeekRangeChange()); } } } // ==================== Week Range Filter (v3.1.0) ==================== /** * Обработчик события database-ready — загружает недели для select'ов * и применяет дефолтные бизнес-фильтры (если фильтры ещё не заданы). * @param {CustomEvent} e * @private */ async _onDatabaseReady(e) { const dbId = e.detail?.databaseId; console.log(`[FilterPanel] database-ready received for db ${dbId}`); if (!dbId) return; this._currentDatabaseId = dbId; // Применяем дефолтные бизнес-фильтры, если пользователь ещё ничего не выбрал await this._applyDefaultFilters(dbId); if (this._embedded) return; // Если недели ещё не построены — вызываем buildFromDatabase (идемпотентно) const cached = weekIndexService.getAllWeeks(dbId); if (cached.length > 0) { this._weeks = cached; this._populateWeekSelects(); this._updateWeekRangeUI(); } else { weekIndexService .buildFromDatabase(dbId) .then((weeks) => { this._weeks = weeks; this._populateWeekSelects(); this._updateWeekRangeUI(); }) .catch((err) => { console.warn("[FilterPanel] Failed to build week index:", err); }); } // Если контекст уровня уже известен, но индексы были недоступны // при первом вызове _syncLevelBaseFilters, повторяем синхронизацию. if (this._levelBaseFilters && this._levelBaseFilters.length > 0) { this._syncLevelBaseFilters(this._levelBaseFilters); } } /** * Применяет дефолтные бизнес-фильтры при загрузке новой базы. * Ищет в JS Data Engine код 'ЦПС_Отчетность' = 'S' и добавляет его в * FilterStateManager, только если фильтры ещё пусты. Фильтр уровня графика * (`!Уровень графика` = '3') больше не применяется глобально — он теперь * является implicit base filter для manhours-уровней (см. LevelLayoutPanel * и SummaryJsQueryEngine._resolveActivitySet). * @param {string} dbId * @private */ async _applyDefaultFilters(dbId) { console.log(`[FilterPanel] _applyDefaultFilters called for db ${dbId}`); // Promise-based lock: параллельные вызовы ждут одного и того же запроса. const existingLock = this._applyDefaultFiltersLocks.get(dbId); if (existingLock) { console.log( `[FilterPanel] Waiting for in-flight default filters for db ${dbId}`, ); return existingLock; } let releaseLock = () => {}; const lock = new Promise((resolve) => { releaseLock = resolve; }); this._applyDefaultFiltersLocks.set(dbId, lock); try { // Сбрасываем фильтры от предыдущей базы / предыдущего состояния. // Это единственный источник правды для дефолтных фильтров новой БД. filterState.clearCodeFilters(); // Быстрый фильтр по физобъемам (материальным ресурсам) тоже сбрасываем, // чтобы он не переживал перезагрузку, как и фильтры по кодам. filterState.clearResourceFilter(); // Не перезаписываем фильтры, если пользователь уже что-то выбрал. // (clearCodeFilters выше всегда очищает, поэтому эта проверка — // защита от race-condition, если другой код успел установить фильтры.) const existingFilters = filterState.getState().codeFilters; if (existingFilters.length > 0) { console.log( `[FilterPanel] Skipping default filters — ${existingFilters.length} existing code filters`, ); defaultFilterReadiness.markReady(dbId); this._dispatchDefaultFiltersApplied(dbId, false); return; } // Querying JS Data Engine for default business filter codes... const codeTypes = await dataService.getCodeTypes(dbId, { scheduleType: "current", }); const targetSpecs = [ { typeName: "ЦПС_Отчетность", valueShortName: "S" }, ]; /** @type {import('../types.js').CodeFilterItem[]} */ const newFilters = []; for (const spec of targetSpecs) { const codeType = codeTypes.find((ct) => ct.name === spec.typeName); if (!codeType) continue; const values = await dataService.getCodeValues( codeType.typeId, dbId, { scheduleType: "current" }, ); const matchedValues = values .filter( (cv) => String(cv.shortName || "").trim() === spec.valueShortName, ) .sort((a, b) => (a.valueId || 0) - (b.valueId || 0)); if (matchedValues.length === 0) continue; // Use the minimum value_id to match legacy SQL GROUP BY + MIN. const cv = matchedValues[0]; const valueId = Number(cv.valueId); const valueName = String(cv.shortName || ""); const color = this._normalizeColor(cv.color) || undefined; newFilters.push({ id: `filter-${codeType.typeId}`, codeTypeId: Number(codeType.typeId), codeTypeName: String(codeType.name), codeValueIds: [valueId], codeValues: [{ valueId, valueName, color }], codeValueId: valueId, codeValueName: valueName, color, enabled: true, }); } if (newFilters.length > 0) { // Applying default filters in batch. Use skipWidgetReload so FilterPanel // and FilterResourceTabs update their UI, but widgets load exactly once // via DefaultFilterReadiness.markReady instead of twice. filterState.replaceCodeFilters(newFilters, { skipWidgetReload: true }); // Applied default business filters } } catch (err) { console.warn("[FilterPanel] Failed to apply default filters:", err); } finally { console.log(`[FilterPanel] _applyDefaultFilters finished for db ${dbId}`); defaultFilterReadiness.markReady(dbId); this._dispatchDefaultFiltersApplied(dbId, true); this._applyDefaultFiltersLocks.delete(dbId); releaseLock(); } } /** * Уведомляет виджеты, что этап применения дефолтных фильтров завершён. * @param {string} dbId * @param {boolean} searched * @private */ _dispatchDefaultFiltersApplied(dbId, searched) { document.dispatchEvent( new CustomEvent("default-filters-applied", { bubbles: true, detail: { databaseId: dbId, searched }, }), ); } /** * Обработчик события database-removed — очищает select'ы недель. * @param {CustomEvent} e * @private */ _onDatabaseRemoved(e) { const dbId = e.detail?.databaseId; if (dbId && dbId === this._currentDatabaseId) { this._currentDatabaseId = null; this._weeks = []; if (!this._embedded) { this._populateWeekSelects(); this._updateWeekRangeUI(); } // НЕ сбрасываем фильтры при удалении БД — состояние должно сохраняться // при переключении на другую активную БД. Дефолтные фильтры применятся // в _onDatabaseReady только если фильтры действительно пусты. } } /** * Возвращает ID текущего активного уровня из SummaryDrillNavigator. * @returns {string|null} * @private */ _getCurrentLevelId() { // Предпочитаем живой элемент summary-drill-navigator (если он в light DOM). const drillNav = document.querySelector("summary-drill-navigator") || this._findSummaryDrillNavigatorDeep(document.documentElement); const result = drillNav ? drillNav.currentLevelId || null : this._currentLevelId || null; return result; } /** * Рекурсивно ищет <summary-drill-navigator> внутри Shadow DOM. * @param {Element} root * @returns {HTMLElement|null} * @private */ _findSummaryDrillNavigatorDeep(root) { if (!root) return null; const direct = root.querySelector?.("summary-drill-navigator"); if (direct) return direct; const shadows = root.querySelectorAll?.("*"); if (!shadows) return null; for (const el of shadows) { if (el.shadowRoot) { const found = this._findSummaryDrillNavigatorDeep(el.shadowRoot); if (found) return found; } } return null; } /** * Обработчик события context-updated от LevelLayoutPanel. * Сохраняет baseFilters и dataDomain активного уровня для отображения * в панели фильтров как read-only системные фильтры. * * Игнорирует события от неактивного уровня, чтобы stale context-updated * предыдущего LevelLayoutPanel не перезаписывал фильтры текущего уровня. * @param {CustomEvent} e * @private */ _onLevelContextUpdated(e) { const context = e.detail?.context; if (!context) return; const eventLevelId = e.detail?.levelId; const currentLevelId = this._getCurrentLevelId(); if ( currentLevelId && eventLevelId && eventLevelId !== currentLevelId ) { return; } const baseFilters = Array.isArray(context.baseFilters) ? context.baseFilters : []; const dataDomain = context.dataDomain || null; const changed = this._levelDataDomain !== dataDomain || JSON.stringify(this._levelBaseFilters) !== JSON.stringify(baseFilters); // Always sync level filters to the current level's baseFilters, even if the // baseFilters themselves haven't changed. After drill-down/back the level // filters may have been cleared by SummaryDrillNavigator, so we need to // restore them from the level context on every context-updated event. this._levelBaseFilters = baseFilters; this._levelDataDomain = dataDomain; this._syncLevelBaseFilters(baseFilters); } /** * Синхронизирует baseFilters активного уровня с FilterStateManager * как level-code-filters. * @param {import('../types.js').Filter[]} baseFilters * @private */ _syncLevelBaseFilters(baseFilters) { const dbId = this._currentDatabaseId || dataService.getActiveDatabaseId(); if (!dbId) { filterState.setLevelCodeFilters([], { skipWidgetReload: true }); return; } const indexes = dataIndexer.getIndexes(dbId); if (!indexes) { filterState.setLevelCodeFilters([], { skipWidgetReload: true }); return; } /** @type {import('../types.js').CodeFilterItem[]} */ const levelFilters = []; for (const f of baseFilters) { if (!f.field || !String(f.field).startsWith("code:")) continue; const typeNameOrId = String(f.field).replace("code:", ""); let type; if (/^\d+$/.test(typeNameOrId)) { type = indexes.codeTypeById.get(Number(typeNameOrId)); } else { // Strict match for the canonical Primavera code type name. // The level filter must only apply to the "!" prefixed type. // Legacy non-prefixed "Уровень графика" is intentionally ignored, // even if the project contains a code type with that exact name. if (typeNameOrId === "Уровень графика") { console.warn( "[FilterPanel] Ignoring legacy non-prefixed level base filter:", f.field, ); continue; } type = typeNameOrId === "!Уровень графика" ? findCodeTypeByName(indexes, "!Уровень графика") : findCodeTypeByName(indexes, typeNameOrId); } if (!type) { console.warn( `[FilterPanel] Level base filter code type not found: "${typeNameOrId}"`, ); continue; } const rawValues = Array.isArray(f.value) ? f.value : [f.value]; const codeValueIds = []; const codeValues = []; for (const v of rawValues) { const shortName = String(v ?? "").trim(); if (!shortName) continue; const value = Array.from(indexes.codeValueById.values()).find( (cv) => cv.typeId === type.typeId && String(cv.shortName) === shortName, ); if (value) { codeValueIds.push(Number(value.valueId)); codeValues.push({ valueId: Number(value.valueId), valueName: String(value.shortName), color: this._normalizeColor(value.color) || undefined, }); } } if (codeValueIds.length === 0) continue; levelFilters.push({ id: `level-filter-${type.typeId}`, codeTypeId: Number(type.typeId), codeTypeName: String(type.name), codeValueIds, disabledCodeValueIds: [], codeValues, enabled: f.enabled !== false, source: "level", }); } filterState.setLevelCodeFilters(levelFilters, { skipWidgetReload: true }); } /** * Слушатель level-change от SummaryDrillNavigator. * Сохраняет ID активного уровня, т.к. summary-drill-navigator находится * внутри Shadow DOM и недоступен через document.querySelector. * @param {CustomEvent} e * @private */ _onLevelChange(e) { const currentLevelId = e.detail?.currentLevelId; if (currentLevelId) { this._currentLevelId = currentLevelId; } } /** * Форматирует метку недели для option в select. * Формат: "04 2026 06.04 - 12.04" * @param {WeekInfo} week * @returns {string} * @private */ _formatWeekOptionLabel(week) { const wn = String(week.weekNumber).padStart(2, "0"); const startDay = week.weekStart.slice(8); const startMonth = week.weekStart.slice(5, 7); const endDay = week.weekEnd.slice(8); const endMonth = week.weekEnd.slice(5, 7); return `${wn} ${week.year} ${startDay}.${startMonth}-${endDay}.${endMonth}`; } /** * Заполняет option'ами <select> для выбора недель. * @private */ _populateWeekSelects() { if (this._embedded) return; const fromSelect = /** @type {HTMLSelectElement|null} */ ( this.querySelector("#week-range-from") ); const toSelect = /** @type {HTMLSelectElement|null} */ ( this.querySelector("#week-range-to") ); if (!fromSelect || !toSelect) return; const dbId = this._currentDatabaseId; const weeks = this._weeks; // Сохраняем текущие значения const prevFrom = fromSelect.value; const prevTo = toSelect.value; // Очищаем и добавляем placeholder fromSelect.innerHTML = '<option value="">Все недели</option>'; toSelect.innerHTML = '<option value="">Все недели</option>'; if (!dbId || weeks.length === 0) { fromSelect.disabled = true; toSelect.disabled = true; return; } weeks.forEach((week) => { const label = this._formatWeekOptionLabel(week); const colorStyle = week.isPast ? "color: var(--text-link);" : "color: var(--text-tertiary);"; const fontStyle = "font-family: var(--font-mono);"; const optionFrom = document.createElement("option"); optionFrom.value = String(week.weekIndex); optionFrom.textContent = label; optionFrom.style.cssText = `${fontStyle} ${colorStyle}`; fromSelect.appendChild(optionFrom); const optionTo = document.createElement("option"); optionTo.value = String(week.weekIndex); optionTo.textContent = label; optionTo.style.cssText = `${fontStyle} ${colorStyle}`; toSelect.appendChild(optionTo); }); fromSelect.disabled = false; toSelect.disabled = false; // Восстанавливаем значения если они ещё валидны if (prevFrom && fromSelect.querySelector(`option[value="${prevFrom}"]`)) { fromSelect.value = prevFrom; } if (prevTo && toSelect.querySelector(`option[value="${prevTo}"]`)) { toSelect.value = prevTo; } } /** * Обработчик изменения select'ов недель. * @private */ _onWeekRangeChange() { const fromSelect = /** @type {HTMLSelectElement|null} */ ( this.querySelector("#week-range-from") ); const toSelect = /** @type {HTMLSelectElement|null} */ ( this.querySelector("#week-range-to") ); if (!fromSelect || !toSelect) return; const startIdx = fromSelect.value ? parseInt(fromSelect.value, 10) : null; const endIdx = toSelect.value ? parseInt(toSelect.value, 10) : null; filterState.setWeekRange(startIdx, endIdx); } /** * Сброс диапазона недель. * @private */ _onClearWeekRange() { filterState.clearWeekRange(); } /** * Обновляет UI диапазона недель на основе текущего состояния FilterStateManager. * @private */ _updateWeekRangeUI() { if (this._embedded) return; const fromSelect = /** @type {HTMLSelectElement|null} */ ( this.querySelector("#week-range-from") ); const toSelect = /** @type {HTMLSelectElement|null} */ ( this.querySelector("#week-range-to") ); if (!fromSelect || !toSelect) return; const { startIdx, endIdx } = filterState.getEffectiveWeekRange(); fromSelect.value = startIdx !== null ? String(startIdx) : ""; toSelect.value = endIdx !== null ? String(endIdx) : ""; this._updateWeekRangeChip(); } /** * Рендерит chip с активным диапазоном недель (или скрывает его). * @private */ _updateWeekRangeChip() { if (this._embedded) return; const chip = this.querySelector("#week-range-chip"); const resetBtn = /** @type {HTMLButtonElement|null} */ ( this.querySelector("#week-range-reset") ); if (!chip || !resetBtn) return; const { startIdx, endIdx } = filterState.getEffectiveWeekRange(); const dbId = this._currentDatabaseId; const hasRange = startIdx !== null || endIdx !== null; if (!hasRange || !dbId) { chip.style.display = "none"; chip.innerHTML = ""; resetBtn.disabled = true; return; } resetBtn.disabled = false; let label = ""; if (startIdx !== null && endIdx !== null) { const startWeek = weekIndexService.getWeekInfo(dbId, startIdx); const endWeek = weekIndexService.getWeekInfo(dbId, endIdx); const startNum = startWeek?.weekNumber ?? startIdx; const endNum = endWeek?.weekNumber ?? endIdx; if (startIdx === endIdx) { label = `Нед ${startNum}`; } else { label = `Нед ${startNum}–${endNum}`; } } else if (startIdx !== null) { const startWeek = weekIndexService.getWeekInfo(dbId, startIdx); label = `от Нед ${startWeek?.weekNumber ?? startIdx}`; } else if (endIdx !== null) { const endWeek = weekIndexService.getWeekInfo(dbId, endIdx); label = `до Нед ${endWeek?.weekNumber ?? endIdx}`; } chip.style.display = "flex"; chip.innerHTML = ` <span>${this._escapeHtml(label)}</span> <button class="icon-button chip-remove" aria-label="Сбросить период" title="Сбросить период" style="display:inline-flex;align-items:center;justify-content:center;width:12px;height:12px;padding:0;margin:0;background:transparent;border:none;color:inherit;cursor:pointer;opacity:0.8;"> ${X} </button> `; } // ==================== FilterStateManager Integration ==================== /** * Обработчик изменения состояния фильтров из FilterStateManager. * @param {CustomEvent} e * @private */ _onFilterStateChange(e) { const action = e.detail?.action; if ( action === "code-filter-set" || action === "code-filter-remove" || action === "code-filter-toggle" || action === "code-filter-clear" || action === "code-filter-replace" || action === "level-filter-change" || action === "resource-filter-change" ) { if (!this._embedded) { this.renderActiveFilters(); } this._updateCodePicker(); } if (action === "week-filter-change" && !this._embedded) { this._updateWeekRangeUI(); } } // ==================== Filter Management (delegated to FilterStateManager) ==================== /** * @param {CodePickerSelection} selection */ addCodeFilter(selection) { filterState.setCodeFilter( { typeId: selection.typeId, valueId: selection.valueId, typeName: selection.typeName, valueName: selection.valueName, color: selection.color, }, { append: Boolean(selection.ctrlKey) }, ); } /** * @param {number} codeTypeId */ removeCodeFilter(codeTypeId) { filterState.removeCodeFilter(codeTypeId); } /** * @param {number} codeTypeId */ toggleCodeFilter(codeTypeId) { filterState.toggleCodeFilter(codeTypeId); } clearFilters() { filterState.clearCodeFilters(); } /** * Публичный метод для применения дефолтных бизнес-фильтров. * Используется AppController при хард-ресете после загрузки новой БД. * @param {string} dbId */ async applyDefaultFilters(dbId) { await this._applyDefaultFilters(dbId); } // ==================== Rendering ==================== renderActiveFilters() { if (this._embedded) return; const container = this.querySelector("#active-filters-list"); if (!container) return; const state = filterState.getState(); const levelFilters = state.levelCodeFilters || []; const userFilters = state.codeFilters || []; /** @type {string[]} */ const chips = []; // Level filter chips — scope of the active level, restored on level-change. for (const filter of levelFilters) { chips.push(...this._renderCodeFilterChips(filter, { source: "level" })); } // User code filter chips — hide those fully covered by a level filter. const visibleUserFilters = this._filterVisibleUserFilters( userFilters, levelFilters, ); for (const filter of visibleUserFilters) { chips.push(...this._renderCodeFilterChips(filter, { source: "user" })); } if (chips.length === 0) { container.innerHTML = '<div class="compact-placeholder" role="status" style="min-height:24px;border:none;background:transparent;font-style:normal;">Нет активных фильтров</div>'; } else { container.innerHTML = chips.join(""); } } /** * Фильтрует пользовательские code-фильтры, полностью дублируемые level-фильтром. * @param {import('../types.js').CodeFilterItem[]} userFilters * @param {import('../types.js').CodeFilterItem[]} levelFilters * @returns {import('../types.js').CodeFilterItem[]} * @private */ _filterVisibleUserFilters(userFilters, levelFilters) { return userFilters.filter((userFilter) => { const levelFilter = levelFilters.find( (lf) => lf.codeTypeId === userFilter.codeTypeId, ); if (!levelFilter) return true; const levelActiveIds = new Set( (levelFilter.codeValueIds || []).filter( (id) => !(levelFilter.disabledCodeValueIds || []).includes(id), ), ); const userActiveIds = new Set( (userFilter.codeValueIds || []).filter( (id) => !(userFilter.disabledCodeValueIds || []).includes(id), ), ); for (const id of userActiveIds) { if (!levelActiveIds.has(id)) return true; } return false; }); } /** * Рендерит чипы для одного code-фильтра (level или user). * @param {import('../types.js').CodeFilterItem} filter * @param {{source: 'level'|'user'}} options * @returns {string[]} * @private */ _renderCodeFilterChips(filter, { source }) { /** @type {import('../types.js').CodeFilterValueItem[]} */ const values = filter.codeValues?.length ? filter.codeValues : filter.codeValueId != null ? [ { valueId: filter.codeValueId, valueName: filter.codeValueName || "", color: filter.color, }, ] : []; if (values.length === 0) return []; const activeIds = new Set( (filter.codeValueIds || []).map((id) => Number(id)), ); const disabledIds = new Set( (filter.disabledCodeValueIds || []).map((id) => Number(id)), ); /** @type {string[]} */ const chips = []; for (const value of values) { const valueId = Number(value.valueId); const valueEnabled = filter.enabled && activeIds.has(valueId) && !disabledIds.has(valueId); const disabledClass = valueEnabled ? "" : "filter-chip--disabled"; const radioColor = "var(--accent-color)"; const valueName = value.valueName || `ID ${valueId}`; const radioStyle = `background-color:${valueEnabled ? radioColor : "transparent"};border-color:var(--border-color);box-shadow:none;`; const radioHtml = `<span class="code-value-radio filter-toggle-label" data-source="${source}" data-code-type-id="${filter.codeTypeId}" data-code-value-id="${valueId}" style="${radioStyle}" role="switch" aria-checked="${valueEnabled}" title="${valueEnabled ? "Отключить" : "Включить"}"></span>`; const sourceLabel = source === "level" ? "уровень" : "пользователь"; chips.push(` <div class="filter-chip ${disabledClass}" data-source="${source}" data-code-type-id="${filter.codeTypeId}" data-code-value-id="${valueId}" role="listitem"> ${radioHtml} <span class="code-value-name text-truncate filter-toggle-label" data-source="${source}" data-code-type-id="${filter.codeTypeId}" data-code-value-id="${valueId}" title="${this._escapeHtml(valueName)}" style="max-width:140px;">${this._escapeHtml(valueName)}</span> <span class="code-value-desc text-truncate filter-toggle-label" data-source="${source}" data-code-type-id="${filter.codeTypeId}" data-code-value-id="${valueId}" title="${this._escapeHtml(filter.codeTypeName)}">${this._escapeHtml(filter.codeTypeName)}</span> <span class="filter-chip-badge">${sourceLabel}</span> <button class="icon-button remove-filter" data-source="${source}" data-code-type-id="${filter.codeTypeId}" data-code-value-id="${valueId}" aria-label="Удалить значение ${this._escapeHtml(valueName)} справочника ${this._escapeHtml(filter.codeTypeName)}"> ${X} </button> </div> `); } return chips; } _updateCodePicker() { const codePicker = this.querySelector("code-picker"); if (!codePicker) return; const filters = this.activeFilters; if (typeof codePicker.setHighlightedTypeIds === "function") { const activeTypeIds = filters .filter((f) => f.enabled) .map((f) => f.codeTypeId); codePicker.setHighlightedTypeIds(activeTypeIds); } if (typeof codePicker.setSelections === "function") { /** @type {{typeId: number, valueId: number}[]} */ const selections = []; filters .filter((f) => f.enabled) .forEach((f) => { // BUG-004: codeValueIds — SSOT для активных выбранных значений. // Fallback на deprecated codeValueId исключён, чтобы деактивированные // per-value фильтры не оставались отмеченными в code-picker. const ids = f.codeValueIds?.length ? f.codeValueIds : []; ids.forEach((valueId) => { selections.push({ typeId: f.codeTypeId, valueId }); }); }); codePicker.setSelections(selections); } } /** * Нормализует цветовое значение для CSS: добавляет `#` к bare-hex строкам. * Возвращает null для пустых/невалидных значений. * @param {string|undefined|null} color * @returns {string|null} * @private */ _normalizeColor(color) { if (color == null) return null; const trimmed = String(color).trim(); if (!trimmed) return null; if ( /^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?([0-9A-Fa-f]{2})?$/.test(trimmed) || /^(?:rgb|hsl|hwb|lab|lch|oklab|oklch|color)\(/i.test(trimmed) || /^[a-zA-Z]+$/.test(trimmed) ) { return trimmed; } if (/^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(trimmed)) { return `#${trimmed}`; } return null; } /** * @param {string} text * @returns {string} */ _escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } // ==================== Legacy API (v1.x / v2.x compatibility) ==================== /** * Legacy: добавить фильтр старого формата * @param {Object} filter * @deprecated */ addFilter(filter) { console.warn( "[FilterPanel] addFilter() is deprecated in v2.0.0. Use addCodeFilter().", ); } /** * @returns {CodeFilterItem[]} */ getFilters() { return filterState.getState().codeFilters; } /** * @param {CodeFilterItem[]} filters */ setFilters(filters) { filterState.replaceCodeFilters(filters || []); } } customElements.define("filter-panel", FilterPanel);