/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/services/FilterStateManager.js
1 916 строк
65 KB
Starolat Sergei
fix: фильтр «Физобъём» снова отключает level-фильтр «!Уровень графика = 3»
14 июл 2026, 07:25
14 июл 2026, 07:25
c9741ec
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview FilterStateManager - Централизованный сервис управления фильтрами (SYS-006) * @module FilterStateManager * @version 1.9.2 * * Single Source of Truth (SSOT) для всех измерений фильтрации данных приложения. * * v1.9.2 — Нормализация HEX-цвета code-значений. * - `_normalizeColor` добавляет `#` к bare-hex строкам ("0000FF" → "#0000FF"), * чтобы inline background-color в активных фильтрах и ECharts получали * валидный CSS-цвет. Используется в `_createCodeFilterItem` и * `_normalizeCodeFilter`. * - Исправляет пустые (прозрачные) индикаторы у активных фильтров S и * "Уровень графика" = 3. * * v1.9.1 — Effective filters исключают фильтры с пустым набором активных value. * - `_getEffectiveCodeFilterItems` теперь пропускает фильтры, у которых после * исключения disabledCodeValueIds не осталось активных value. * - Это позволяет отключать level filter `!Уровень графика = 3` без обнуления * выборки: effective filter исчезает, и данные расширяются до всех уровней. * * v1.9.0 — Level base filters как обычные фильтры (Вариант 2). * - Добавлено хранилище `_levelCodeFilters` и методы управления level filters. * - `getEffectiveCodeFilters()` мержит level и user filters по `codeTypeId`. * - Эмитится событие `level-filter-change` для перезагрузки summary-виджетов. * * v1.8.0 — FEAT-004: per-value включение/выключение в code-фильтре. * - CodeFilterItem теперь хранит disabledCodeValueIds (выключенные, но не удалённые value). * - Новые методы: toggleCodeFilterValue(typeId, valueId), setCodeFilterValueState(typeId, valueId, enabled). * - removeCodeFilterValue удаляет value из обоих массивов (active + disabled). * - setCodeFilter при append=true убирает value из disabled при повторном выборе. * * v1.7.0 — FEAT-004: множественный выбор значений Activity Code. * - CodeFilterItem теперь хранит массивы codeValueIds / codeValues. * - setCodeFilter(payload, { append }) поддерживает Ctrl+Click (multiselect). * - Новый метод: removeCodeFilterValue(typeId, valueId). * - replaceCodeFilters() мигрирует старый формат codeValueId → codeValueIds. * * v1.6.0 — Диапазон недель теперь поддерживает enabled/disabled состояние. * - Новое поле: _weekRangeEnabled: boolean * - Новые методы: toggleWeekRange(), setWeekRangeEnabled(), getSelectedWeekRange(), isWeekRangeEnabled() * - getEffectiveWeekRange() возвращает null-диапазон при отключённом флаге. * - isWeekRangeActive() учитывает флаг enabled. * - getState() включает weekRangeEnabled. * * v1.5.0 — Фильтр по ресурсам теперь поддерживает enabled/disabled состояние. * - Новое поле: _disabledResourceFilterIds: Set<number> * - Новые методы: toggleResourceFilter(), removeResourceFilter(), setResourceFilterState() * - getEffectiveResourceFilterIds() возвращает только включённые ресурсы. * * v1.4.0 — Добавлен фильтр по материальным ресурсам (resourceFilterIds). * - Новое поле: _resourceFilterIds: Set<number> * - Новые методы: setResourceFilter(), clearResourceFilter(), getEffectiveResourceFilterIds() * - Новое событие: resource-filter-change (unified + legacy bridge) * Uni-directional data flow: все компоненты пишут через API сервиса, * сервис оповещает всех подписчиков через `filter-state-change`. * * Backward-compatible legacy event bridge: на переходный период также эмитит * `filter-change`, `grouping-change`, `wbs-filter-change`, `week-select`, * `table-schedule-mode-change` для совместимости с существующими компонентами. * * v1.2.0 — Миграция week-фильтра с string (weekEnd) на WeekRange (startIdx/endIdx). * - Новое поле состояния: `_weekRange: { startIdx: number|null, endIdx: number|null }` * - Новые методы: `setWeekRange(startIdx, endIdx)`, `clearWeekRange()`, `getEffectiveWeekRange()` * - Legacy `setWeekFilter(weekEnd)` конвертирует строку в weekIndex через WeekIndexService * и делегирует в `setWeekRange()`. * - Legacy событие `week-select` сохранено для обратной совместимости. * * v1.3.0 — Единый Data Date (интеграция weekRange + timeline). * - Новое UI-state поле: `_timelineWeekIdx` * - Новые методы: `setTimelineWeekIdx()`, `getEffectiveDataDate()` * - Новое событие: `data-date-change` (дедуплицированное) * - Приоритет: weekRange (полный диапазон) > timeline > null */ /** * @typedef {import('../types.js').FilterState} FilterState * @typedef {import('../types.js').FilterStateChangeDetail} FilterStateChangeDetail * @typedef {import('../types.js').CodeFilterPayload} CodeFilterPayload * @typedef {import('../types.js').CodeFilterItem} CodeFilterItem * @typedef {import('../types.js').Grouping} Grouping * @typedef {import('../types.js').WbsFilterChangeDetail} WbsFilterChangeDetail * @typedef {import('../types.js').WeekRange} WeekRange * @typedef {import('../types.js').EffectiveDataDate} EffectiveDataDate * @typedef {import('../types.js').DataDateChangeDetail} DataDateChangeDetail */ /** * FilterStateManager - централизованное управление состоянием фильтров * @class */ class FilterStateManager { constructor() { /** @type {CodeFilterItem[]} */ this._codeFilters = []; /** * Фильтры активного уровня (baseFilters из LevelContext). * Хранятся отдельно от пользовательских codeFilters, чтобы можно было * восстанавливать scope уровня при level-change, но при этом позволять * пользователю временно отключать/удалять их. * @type {CodeFilterItem[]} */ this._levelCodeFilters = []; /** * Флаг, указывающий, что level filters были явно установлены * для текущего активного уровня. Нужен, чтобы отличать "уровень без * baseFilters" от "level filters ещё не инициализированы". * @type {boolean} */ this._levelFiltersActive = false; /** @type {Set<number>} */ this._wbsFilterIds = new Set(); /** @type {boolean} */ this._showCurrent = true; /** @type {boolean} */ this._showTarget = false; /** * Диапазон недель для фильтрации (weekIndex). * startIdx=null или endIdx=null означает "без ограничения" с этой стороны. * @type {WeekRange} */ this._weekRange = { startIdx: null, endIdx: null }; /** * Флаг активации диапазона недель. Позволяет временно отключить * выбранный диапазон без потери значений (аналог enabled для code filters). * @type {boolean} */ this._weekRangeEnabled = true; /** @type {Set<number>} */ this._resourceFilterIds = new Set(); /** * IDs выбранных, но отключённых ресурсов. * @type {Set<number>} */ this._disabledResourceFilterIds = new Set(); /** @type {Grouping[]} */ this._groupings = []; /** * Последняя выбранная неделя с бегунка timeline (weekIndex). * Не входит в сериализуемое состояние — это UI-state. * @type {number|null} */ this._timelineWeekIdx = null; /** * Предыдущий effective data date для избежания лишних событий. * @type {EffectiveDataDate|null} */ this._previousEffectiveDataDate = null; } // ==================== Mutators: Code Filters ==================== /** * Устанавливает/заменяет фильтр по справочнику. * * Поведение при append=false (обычный клик): * - Если фильтра нет — создаётся с одним value. * - Если фильтр есть и содержит только этот value — удаляется. * - Если фильтр есть и содержит другие value — заменяется на [valueId]. * * Поведение при append=true (Ctrl/Cmd+Click, FEAT-004): * - Если value уже есть — удаляется из набора. * - Если value нет — добавляется к набору. * - Если после удаления value не осталось — фильтр удаляется целиком. * * @param {CodeFilterPayload} payload * @param {{append?: boolean}} [options] * @returns {void} */ setCodeFilter(payload, options = {}) { if (!this._isValidCodeFilterPayload(payload)) { console.warn( "[FilterStateManager] Invalid payload: missing typeId/valueId", ); return; } const { typeName, valueName, color } = payload; const typeId = Number(payload.typeId); const valueId = Number(payload.valueId); const append = Boolean(options?.append); const existingIndex = this._codeFilters.findIndex( (f) => f.codeTypeId === typeId, ); if (append) { if (existingIndex >= 0) { const filter = this._codeFilters[existingIndex]; const activeIndex = filter.codeValueIds.indexOf(valueId); const disabledIndex = filter.disabledCodeValueIds.indexOf(valueId); if (activeIndex >= 0) { // Повторный Ctrl+Click по активному value — удаляем его из фильтра. filter.codeValueIds.splice(activeIndex, 1); const valueMetaIndex = filter.codeValues.findIndex( (v) => v.valueId === valueId, ); if (valueMetaIndex >= 0) { filter.codeValues.splice(valueMetaIndex, 1); } const disabledIdx = filter.disabledCodeValueIds.indexOf(valueId); if (disabledIdx >= 0) { filter.disabledCodeValueIds.splice(disabledIdx, 1); } if ( filter.codeValueIds.length === 0 && filter.disabledCodeValueIds.length === 0 ) { this._codeFilters.splice(existingIndex, 1); this._notify("code-filter-remove", { filter: this._cloneCodeFilter(filter), }); return; } this._syncDeprecatedFields(filter); this._notify("code-filter-set", { filter: this._cloneCodeFilter(filter), }); return; } if (disabledIndex >= 0) { // Value было выключено — включаем его обратно. filter.disabledCodeValueIds.splice(disabledIndex, 1); filter.codeValueIds.push(valueId); const existingMetaIndex = filter.codeValues.findIndex( (v) => v.valueId === valueId, ); if (existingMetaIndex === -1) { filter.codeValues.push({ valueId, valueName: valueName || "", color: this._normalizeColor(color) || undefined, }); } filter.enabled = true; this._syncDeprecatedFields(filter); this._notify("code-filter-set", { filter: this._cloneCodeFilter(filter), }); return; } // Новое value — добавляем. filter.codeValueIds.push(valueId); filter.codeValues.push({ valueId, valueName: valueName || "", color: this._normalizeColor(color) || undefined, }); filter.enabled = true; this._syncDeprecatedFields(filter); this._notify("code-filter-set", { filter: this._cloneCodeFilter(filter), }); return; } const newFilter = this._createCodeFilterItem( typeId, typeName, valueId, valueName, color, ); this._codeFilters.push(newFilter); this._notify("code-filter-set", { filter: this._cloneCodeFilter(newFilter), }); return; } // append === false: одиночный выбор / замена if (existingIndex >= 0) { const filter = this._codeFilters[existingIndex]; if ( filter.codeValueIds.length === 1 && filter.codeValueIds[0] === valueId && filter.disabledCodeValueIds.length === 0 ) { this._codeFilters.splice(existingIndex, 1); this._notify("code-filter-remove", { filter: this._cloneCodeFilter(filter), }); return; } filter.codeValueIds = [valueId]; filter.disabledCodeValueIds = []; filter.codeValues = [ { valueId, valueName: valueName || "", color: this._normalizeColor(color) || undefined, }, ]; filter.enabled = true; this._syncDeprecatedFields(filter); this._notify("code-filter-set", { filter: this._cloneCodeFilter(filter), }); return; } const newFilter = this._createCodeFilterItem( typeId, typeName, valueId, valueName, color, ); this._codeFilters.push(newFilter); this._notify("code-filter-set", { filter: this._cloneCodeFilter(newFilter), }); } /** * Полностью удаляет фильтр по codeTypeId. * @param {number} codeTypeId * @returns {void} */ removeCodeFilter(codeTypeId) { const id = Number(codeTypeId); const index = this._codeFilters.findIndex((f) => f.codeTypeId === id); if (index === -1) return; const removedFilter = this._codeFilters[index]; this._codeFilters.splice(index, 1); this._notify("code-filter-remove", { filter: this._cloneCodeFilter(removedFilter), }); } /** * Удаляет одно значение из фильтра по справочнику (FEAT-004). * Удаляет value из активных, выключенных и мета-данных. * Если после удаления значений не осталось — удаляет фильтр целиком. * @param {number} codeTypeId * @param {number} codeValueId * @returns {void} */ removeCodeFilterValue(codeTypeId, codeValueId) { const typeId = Number(codeTypeId); const valueId = Number(codeValueId); const index = this._codeFilters.findIndex((f) => f.codeTypeId === typeId); if (index === -1) return; const filter = this._codeFilters[index]; const activeIndex = filter.codeValueIds.indexOf(valueId); const disabledIndex = filter.disabledCodeValueIds.indexOf(valueId); if (activeIndex === -1 && disabledIndex === -1) return; if (activeIndex >= 0) { filter.codeValueIds.splice(activeIndex, 1); } if (disabledIndex >= 0) { filter.disabledCodeValueIds.splice(disabledIndex, 1); } const metaIndex = filter.codeValues.findIndex((v) => v.valueId === valueId); if (metaIndex >= 0) { filter.codeValues.splice(metaIndex, 1); } if ( filter.codeValueIds.length === 0 && filter.disabledCodeValueIds.length === 0 ) { this._codeFilters.splice(index, 1); this._notify("code-filter-remove", { filter: this._cloneCodeFilter(filter), }); return; } this._syncDeprecatedFields(filter); this._notify("code-filter-set", { filter: this._cloneCodeFilter(filter), }); } /** * Переключает enabled одного значения в фильтре по справочнику (FEAT-004). * Active value перемещается в disabledCodeValueIds; disabled value — обратно в codeValueIds. * Если value не найдено ни в одном из списков — no-op. * @param {number} codeTypeId * @param {number} codeValueId * @returns {void} */ toggleCodeFilterValue(codeTypeId, codeValueId) { const typeId = Number(codeTypeId); const valueId = Number(codeValueId); const filter = this._codeFilters.find((f) => f.codeTypeId === typeId); if (!filter) return; const activeIndex = filter.codeValueIds.indexOf(valueId); const disabledIndex = filter.disabledCodeValueIds.indexOf(valueId); if (activeIndex >= 0) { filter.codeValueIds.splice(activeIndex, 1); if (!filter.disabledCodeValueIds.includes(valueId)) { filter.disabledCodeValueIds.push(valueId); } this._syncDeprecatedFields(filter); this._notify("code-filter-set", { filter: this._cloneCodeFilter(filter), }); return; } if (disabledIndex >= 0) { filter.disabledCodeValueIds.splice(disabledIndex, 1); if (!filter.codeValueIds.includes(valueId)) { filter.codeValueIds.push(valueId); } this._syncDeprecatedFields(filter); this._notify("code-filter-set", { filter: this._cloneCodeFilter(filter), }); } } /** * Явно устанавливает состояние enabled для одного value в фильтре (FEAT-004). * @param {number} codeTypeId * @param {number} codeValueId * @param {boolean} enabled * @returns {void} */ setCodeFilterValueState(codeTypeId, codeValueId, enabled) { const typeId = Number(codeTypeId); const valueId = Number(codeValueId); const filter = this._codeFilters.find((f) => f.codeTypeId === typeId); if (!filter) return; const activeIndex = filter.codeValueIds.indexOf(valueId); const disabledIndex = filter.disabledCodeValueIds.indexOf(valueId); if (enabled) { if (activeIndex >= 0) return; if (disabledIndex >= 0) { filter.disabledCodeValueIds.splice(disabledIndex, 1); } filter.codeValueIds.push(valueId); } else { if (disabledIndex >= 0) return; if (activeIndex >= 0) { filter.codeValueIds.splice(activeIndex, 1); } if (!filter.disabledCodeValueIds.includes(valueId)) { filter.disabledCodeValueIds.push(valueId); } } this._syncDeprecatedFields(filter); this._notify("code-filter-set", { filter: this._cloneCodeFilter(filter), }); } /** * Переключает enabled существующего фильтра. No-op если фильтра нет. * @param {number} codeTypeId * @returns {void} */ toggleCodeFilter(codeTypeId) { const id = Number(codeTypeId); const filter = this._codeFilters.find((f) => f.codeTypeId === id); if (!filter) return; filter.enabled = !filter.enabled; this._notify("code-filter-toggle", { filter: this._cloneCodeFilter(filter), }); } /** * Удаляет все фильтры по кодам. * @returns {void} */ clearCodeFilters() { if (this._codeFilters.length === 0) return; this._codeFilters = []; this._notify("code-filter-clear", {}); } // ==================== Mutators: Level Code Filters ==================== /** * Полностью заменяет фильтры активного уровня (baseFilters). * Каждый level-change должен приводить к вызову этого метода, чтобы * scope уровня восстанавливался, если пользователь его временно удалил. * * Options: * - silent: не диспатчить событий. * - skipWidgetReload: пометить событие, чтобы виджеты не перезагружались * (используется при первичной синхронизации baseFilters). * * @param {CodeFilterItem[]} filters * @param {{silent?: boolean, skipWidgetReload?: boolean}} [options] * @returns {void} */ setLevelCodeFilters(filters, options = {}) { if (!Array.isArray(filters)) { console.warn( "[FilterStateManager] Invalid level filters: expected array", ); return; } const normalized = filters .map((f) => this._normalizeCodeFilter({ ...f, source: "level" })) .filter((f) => f.codeValueIds.length > 0); const changed = JSON.stringify(this._levelCodeFilters) !== JSON.stringify(normalized) || !this._levelFiltersActive; if (!changed) return; this._levelCodeFilters = normalized; this._levelFiltersActive = true; // Повторно применяем связку «Физобъем ↔ !Уровень графика = 3»: // re-sync baseFilters (context-updated приходит и после resource-filter-change // через перезагрузку уровня) не должен воскрешать значение 3, пока активен // фильтр по ресурсам. this._syncLevelGraphFilterWithResourceFilter(); if (!options.silent) { this._notify("level-filter-change", { skipWidgetReload: options.skipWidgetReload === true, }); } } /** * Удаляет level-фильтр по codeTypeId (локально для текущего уровня). * При следующем level-change фильтр восстановится из baseFilters. * @param {number} codeTypeId * @returns {void} */ removeLevelCodeFilter(codeTypeId) { const id = Number(codeTypeId); const index = this._levelCodeFilters.findIndex((f) => f.codeTypeId === id); if (index === -1) return; const removedFilter = this._levelCodeFilters[index]; this._levelCodeFilters.splice(index, 1); this._notify("level-filter-change", { filter: this._cloneCodeFilter(removedFilter), }); } /** * Удаляет одно значение из level-фильтра. * @param {number} codeTypeId * @param {number} codeValueId * @returns {void} */ removeLevelCodeFilterValue(codeTypeId, codeValueId) { const typeId = Number(codeTypeId); const valueId = Number(codeValueId); const index = this._levelCodeFilters.findIndex( (f) => f.codeTypeId === typeId, ); if (index === -1) return; const filter = this._levelCodeFilters[index]; const activeIndex = filter.codeValueIds.indexOf(valueId); const disabledIndex = filter.disabledCodeValueIds.indexOf(valueId); if (activeIndex === -1 && disabledIndex === -1) return; if (activeIndex >= 0) filter.codeValueIds.splice(activeIndex, 1); if (disabledIndex >= 0) filter.disabledCodeValueIds.splice(disabledIndex, 1); const metaIndex = filter.codeValues.findIndex((v) => v.valueId === valueId); if (metaIndex >= 0) filter.codeValues.splice(metaIndex, 1); if ( filter.codeValueIds.length === 0 && filter.disabledCodeValueIds.length === 0 ) { this._levelCodeFilters.splice(index, 1); } else { this._syncDeprecatedFields(filter); } this._notify("level-filter-change", { filter: this._cloneCodeFilter(filter), }); } /** * Переключает enabled level-фильтра целиком. * @param {number} codeTypeId * @returns {void} */ toggleLevelCodeFilter(codeTypeId) { const id = Number(codeTypeId); const filter = this._levelCodeFilters.find((f) => f.codeTypeId === id); if (!filter) return; filter.enabled = !filter.enabled; this._notify("level-filter-change", { filter: this._cloneCodeFilter(filter), }); } /** * Переключает enabled одного значения в level-фильтре. * @param {number} codeTypeId * @param {number} codeValueId * @returns {void} */ toggleLevelCodeFilterValue(codeTypeId, codeValueId) { const typeId = Number(codeTypeId); const valueId = Number(codeValueId); const filter = this._levelCodeFilters.find((f) => f.codeTypeId === typeId); if (!filter) return; const activeIndex = filter.codeValueIds.indexOf(valueId); const disabledIndex = filter.disabledCodeValueIds.indexOf(valueId); if (activeIndex >= 0) { filter.codeValueIds.splice(activeIndex, 1); if (!filter.disabledCodeValueIds.includes(valueId)) { filter.disabledCodeValueIds.push(valueId); } this._syncDeprecatedFields(filter); this._notify("level-filter-change", { filter: this._cloneCodeFilter(filter), }); return; } if (disabledIndex >= 0) { filter.disabledCodeValueIds.splice(disabledIndex, 1); if (!filter.codeValueIds.includes(valueId)) { filter.codeValueIds.push(valueId); } this._syncDeprecatedFields(filter); this._notify("level-filter-change", { filter: this._cloneCodeFilter(filter), }); } } /** * Сбрасывает все level-фильтры. * * Options: * - silent: не диспатчить событий. * * @param {{silent?: boolean}} [options] * @returns {void} */ clearLevelCodeFilters(options = {}) { if (this._levelCodeFilters.length === 0 && !this._levelFiltersActive) return; this._levelCodeFilters = []; this._levelFiltersActive = false; if (!options.silent) { this._notify("level-filter-change", {}); } } // ==================== Helpers: Resource ↔ Level Filter coupling ==================== /** * Возвращает level-фильтр "!Уровень графика", если он активен. * @returns {CodeFilterItem|undefined} * @private */ _findLevelGraphFilter() { return this._levelCodeFilters.find( (f) => f.codeTypeName === "!Уровень графика", ); } /** * Возвращает ID значения "3" внутри level-фильтра "!Уровень графика". * @param {CodeFilterItem} levelFilter * @returns {number|undefined} * @private */ _findLevelGraphValue3Id(levelFilter) { const value = (levelFilter.codeValues || []).find( (v) => String(v.valueName || "").trim() === "3", ); return value ? Number(value.valueId) : undefined; } /** * Синхронизирует level-фильтр "!Уровень графика" = 3 с фильтром по ресурсам. * Бизнес-правило: при активном фильтре "Физобъем" уровень 3 отключается, * при снятии всех ресурсных фильтров уровень 3 включается обратно. * @returns {boolean} true, если level-фильтр изменился. * @private */ _syncLevelGraphFilterWithResourceFilter() { const levelFilter = this._findLevelGraphFilter(); if (!levelFilter) return false; const valueId = this._findLevelGraphValue3Id(levelFilter); if (valueId == null) return false; const resourceActive = this._resourceFilterIds.size > 0; const activeIndex = (levelFilter.codeValueIds || []).indexOf(valueId); const disabledIndex = (levelFilter.disabledCodeValueIds || []).indexOf(valueId); if (resourceActive) { if (activeIndex >= 0) { levelFilter.codeValueIds.splice(activeIndex, 1); if (disabledIndex === -1) { levelFilter.disabledCodeValueIds.push(valueId); } this._syncDeprecatedFields(levelFilter); return true; } } else { if (disabledIndex >= 0) { levelFilter.disabledCodeValueIds.splice(disabledIndex, 1); if (activeIndex === -1) { levelFilter.codeValueIds.push(valueId); } this._syncDeprecatedFields(levelFilter); return true; } } return false; } // ==================== Mutators: Resource Filter ==================== /** * Устанавливает фильтр по материальным ресурсам. * Дедуплицирует и фильтрует не-числовые значения. * Убирает из disabled те ID, которых больше нет в выборке. * @param {number[] | Set<number>} ids — IDs ресурсов (resource_id из resources) * @returns {void} */ setResourceFilter(ids) { const inputArray = Array.from(ids); const validIds = inputArray.filter(Number.isFinite).map((id) => Number(id)); const uniqueIds = [...new Set(validIds)]; const hadIds = this._resourceFilterIds.size > 0 || this._disabledResourceFilterIds.size > 0; this._resourceFilterIds = new Set(uniqueIds); // Чистим disabled от ID, которые больше не выбраны for (const id of this._disabledResourceFilterIds) { if (!this._resourceFilterIds.has(id)) { this._disabledResourceFilterIds.delete(id); } } const levelFilterChanged = this._syncLevelGraphFilterWithResourceFilter(); const action = uniqueIds.length === 0 ? "clear" : "add"; this._notify("resource-filter-change", { ids: uniqueIds, resourceIds: uniqueIds, action, levelFilterChanged, }); } /** * Полностью заменяет выборку и disabled-набор ресурсов за одну нотификацию. * Используется при восстановлении макета. * @param {number[] | Set<number>} selectedIds * @param {number[] | Set<number>} disabledIds * @returns {void} */ setResourceFilterState(selectedIds, disabledIds) { const inputSelected = Array.from(selectedIds) .filter(Number.isFinite) .map((id) => Number(id)); const inputDisabled = Array.from(disabledIds || []) .filter(Number.isFinite) .map((id) => Number(id)); const newSelected = new Set(inputSelected); const newDisabled = new Set( inputDisabled.filter((id) => newSelected.has(id)), ); const hasChanges = newSelected.size !== this._resourceFilterIds.size || newDisabled.size !== this._disabledResourceFilterIds.size || [...newSelected].some((id) => !this._resourceFilterIds.has(id)) || [...newDisabled].some((id) => !this._disabledResourceFilterIds.has(id)); if (!hasChanges) return; this._resourceFilterIds = newSelected; this._disabledResourceFilterIds = newDisabled; const levelFilterChanged = this._syncLevelGraphFilterWithResourceFilter(); this._notify("resource-filter-change", { ids: Array.from(newSelected), resourceIds: Array.from(newSelected), action: "set", levelFilterChanged, }); } /** * Сбрасывает фильтр по ресурсам. * @returns {void} */ clearResourceFilter() { if ( this._resourceFilterIds.size === 0 && this._disabledResourceFilterIds.size === 0 ) return; this._resourceFilterIds = new Set(); this._disabledResourceFilterIds = new Set(); const levelFilterChanged = this._syncLevelGraphFilterWithResourceFilter(); this._notify("resource-filter-change", { ids: [], resourceIds: [], action: "clear", levelFilterChanged, }); } /** * Удаляет конкретный ресурс из фильтра. * @param {number} resourceId * @returns {void} */ removeResourceFilter(resourceId) { const id = Number(resourceId); if ( !this._resourceFilterIds.has(id) && !this._disabledResourceFilterIds.has(id) ) return; this._resourceFilterIds.delete(id); this._disabledResourceFilterIds.delete(id); const levelFilterChanged = this._syncLevelGraphFilterWithResourceFilter(); this._notify("resource-filter-change", { ids: Array.from(this._resourceFilterIds), resourceIds: Array.from(this._resourceFilterIds), action: "remove", levelFilterChanged, }); } /** * Переключает enabled-ресурса: не выбран → выбран+включён; * включён → отключён (disabled); * отключён → включён. * @param {number} resourceId * @returns {void} */ toggleResourceFilter(resourceId) { const id = Number(resourceId); const isSelected = this._resourceFilterIds.has(id); const isDisabled = this._disabledResourceFilterIds.has(id); if (!isSelected) { this._resourceFilterIds.add(id); this._disabledResourceFilterIds.delete(id); } else if (!isDisabled) { this._disabledResourceFilterIds.add(id); } else { this._disabledResourceFilterIds.delete(id); } this._notify("resource-filter-change", { ids: Array.from(this._resourceFilterIds), resourceIds: Array.from(this._resourceFilterIds), action: "toggle", }); } /** * Полностью заменяет список фильтров по кодам (bulk replace). * Поддерживает миграцию старого формата codeValueId → codeValueIds (FEAT-004). * Фильтры без значений отбрасываются. * @param {CodeFilterItem[]} filters * @returns {void} */ /** * Полностью заменяет список фильтров по кодам (bulk replace). * Поддерживает миграцию старого формата codeValueId → codeValueIds (FEAT-004). * Фильтры без значений отбрасываются. * * Options: * - silent: не диспатчить никаких событий. * - skipWidgetReload: диспатчить filter-state-change, но пометить его так, * чтобы виджеты (SummaryWidgetMixin) игнорировали событие и не перезагружались. * Используется при восстановлении макета / применении дефолтных фильтров, * когда виджеты загружаются единожды через DefaultFilterReadiness.markReady. * * @param {CodeFilterItem[]} filters * @param {{silent?: boolean, skipWidgetReload?: boolean}} [options] * @returns {void} */ replaceCodeFilters(filters, options = {}) { if (!Array.isArray(filters)) { console.warn("[FilterStateManager] Invalid filters: expected array"); return; } this._codeFilters = filters .map((f) => this._normalizeCodeFilter(f)) .filter((f) => f.codeValueIds.length > 0); if (!options.silent) { this._notify("code-filter-replace", { skipWidgetReload: options.skipWidgetReload === true, }); } } // ==================== Mutators: WBS Filter ==================== /** * Устанавливает WBS-фильтр. Дедуплицирует и фильтрует не-числовые значения. * @param {number[] | Set<number>} ids - Сырые выбранные WBS IDs (включая дочерние узлы) * @param {number[] | Set<number>} [effectiveIds] - Эффективные IDs после parent-absorption * @returns {void} */ setWbsFilter(ids, effectiveIds) { const inputArray = Array.from(ids); const validIds = inputArray.filter(Number.isFinite).map((id) => Number(id)); const uniqueIds = [...new Set(validIds)]; const hadIds = this._wbsFilterIds.size > 0; this._wbsFilterIds = new Set(uniqueIds); const action = uniqueIds.length === 0 ? "clear" : hadIds ? "add" : "add"; // спека использует 'add'/'remove'/'clear' const effIds = effectiveIds ? [ ...new Set( Array.from(effectiveIds).filter(Number.isFinite).map(Number), ), ] : uniqueIds; this._notify("wbs-filter-change", { ids: uniqueIds, effectiveIds: effIds, action, }); } /** * Сбрасывает WBS-фильтр. * @returns {void} */ clearWbsFilter() { if (this._wbsFilterIds.size === 0) return; this._wbsFilterIds = new Set(); this._notify("wbs-filter-change", { ids: [], action: "clear" }); } // ==================== Mutators: Schedule Mode ==================== /** * Устанавливает режим отображения графиков. * @param {{showCurrent?: boolean, showTarget?: boolean}} mode * @returns {void} */ setScheduleMode(mode) { if (typeof mode !== "object" || mode === null) { console.warn("[FilterStateManager] Invalid schedule mode"); return; } const hasValidFields = typeof mode.showCurrent === "boolean" || typeof mode.showTarget === "boolean"; if (!hasValidFields) { console.warn("[FilterStateManager] No fields to update"); return; } const hasChanges = (typeof mode.showCurrent === "boolean" && mode.showCurrent !== this._showCurrent) || (typeof mode.showTarget === "boolean" && mode.showTarget !== this._showTarget); if (!hasChanges) return; if (typeof mode.showCurrent === "boolean") this._showCurrent = mode.showCurrent; if (typeof mode.showTarget === "boolean") this._showTarget = mode.showTarget; this._notify("schedule-mode-change", { showCurrent: this._showCurrent, showTarget: this._showTarget, }); } // ==================== Mutators: Groupings ==================== /** * Полностью заменяет список группировок. * @param {Grouping[]} groupings * @returns {void} */ setGroupings(groupings) { const newGroupings = Array.isArray(groupings) ? groupings.map((g) => ({ ...g })) : []; if (this._groupingsEqual(this._groupings, newGroupings)) return; this._groupings = newGroupings; this._notify("grouping-change", { groupings: this._groupings.map((g) => ({ ...g })), }); } /** * Сравнивает два массива группировок по существенным полям. * @param {Grouping[]} a * @param {Grouping[]} b * @returns {boolean} * @private */ _groupingsEqual(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if ( (a[i].kind || "code") !== (b[i].kind || "code") || a[i].field !== b[i].field || a[i].typeId !== b[i].typeId || a[i].enabled !== b[i].enabled || a[i].level !== b[i].level ) { return false; } } return true; } // ==================== Mutators: Week Range Filter ==================== /** * Устанавливает диапазон недель (основной API, v1.2.0). * @param {number|null} startIdx — Начальный weekIndex (null = без ограничения) * @param {number|null} endIdx — Конечный weekIndex (null = без ограничения) * @returns {void} */ setWeekRange(startIdx, endIdx, enabled = true) { const newEnabled = Boolean(enabled); let newStart = startIdx === null ? null : Number(startIdx); let newEnd = endIdx === null ? null : Number(endIdx); if (newStart !== null && !Number.isFinite(newStart)) { console.warn("[FilterStateManager] Invalid startIdx:", startIdx); return; } if (newEnd !== null && !Number.isFinite(newEnd)) { console.warn("[FilterStateManager] Invalid endIdx:", endIdx); return; } // Сброс диапазона if (newStart === null && newEnd === null) { this.clearWeekRange(); return; } // Нормализация: если start > end, меняем местами if (newStart !== null && newEnd !== null && newStart > newEnd) { const tmp = newStart; newStart = newEnd; newEnd = tmp; } if ( this._weekRange.startIdx === newStart && this._weekRange.endIdx === newEnd && this._weekRangeEnabled === newEnabled ) { return; } this._weekRange = { startIdx: newStart, endIdx: newEnd }; this._weekRangeEnabled = newEnabled; this._dispatchDataDateChange("range"); /** @type {Object} */ const extra = { startIdx: newStart, endIdx: newEnd, enabled: newEnabled }; // Обогащаем событие датами через WeekIndexService (global singleton) const wis = typeof window !== "undefined" && window.weekIndexService ? window.weekIndexService : null; if (wis) { if (newStart !== null) { extra.startMonday = wis .getMondayFromIndex(newStart) .toISOString() .split("T")[0]; } if (newEnd !== null) { extra.endSunday = wis.indexToWeekEnd(newEnd); // Legacy weekEnd для backward-compatible consumers extra.weekEnd = extra.endSunday; } } this._notify("week-filter-change", extra); } /** * Сбрасывает диапазон недель. * @returns {void} */ clearWeekRange() { if ( this._weekRange.startIdx === null && this._weekRange.endIdx === null && this._weekRangeEnabled ) return; this._weekRange = { startIdx: null, endIdx: null }; this._weekRangeEnabled = true; this._notify("week-filter-change", { startIdx: null, endIdx: null, enabled: true, }); // Возвращаемся к timeline (последняя запомненная позиция бегунка) this._dispatchDataDateChange("range-reset"); } /** * Переключает enabled-флаг диапазона недель. No-op если диапазон не задан. * @returns {void} */ toggleWeekRange() { if (this._weekRange.startIdx === null && this._weekRange.endIdx === null) return; this._weekRangeEnabled = !this._weekRangeEnabled; this._dispatchDataDateChange( this._weekRangeEnabled ? "range-enable" : "range-disable", ); /** @type {Object} */ const extra = { startIdx: this._weekRange.startIdx, endIdx: this._weekRange.endIdx, enabled: this._weekRangeEnabled, }; const wis = typeof window !== "undefined" && window.weekIndexService ? window.weekIndexService : null; if (wis) { extra.startMonday = wis .getMondayFromIndex(this._weekRange.startIdx) .toISOString() .split("T")[0]; extra.endSunday = wis.indexToWeekEnd(this._weekRange.endIdx); extra.weekEnd = extra.endSunday; } this._notify("week-filter-change", extra); } /** * Принудительно устанавливает enabled-флаг диапазона недель. * No-op если диапазон не задан. * @param {boolean} enabled * @returns {void} */ setWeekRangeEnabled(enabled) { const newEnabled = Boolean(enabled); if (this._weekRange.startIdx === null && this._weekRange.endIdx === null) return; if (this._weekRangeEnabled === newEnabled) return; this._weekRangeEnabled = newEnabled; this._dispatchDataDateChange(newEnabled ? "range-enable" : "range-disable"); /** @type {Object} */ const extra = { startIdx: this._weekRange.startIdx, endIdx: this._weekRange.endIdx, enabled: this._weekRangeEnabled, }; const wis = typeof window !== "undefined" && window.weekIndexService ? window.weekIndexService : null; if (wis) { extra.startMonday = wis .getMondayFromIndex(this._weekRange.startIdx) .toISOString() .split("T")[0]; extra.endSunday = wis.indexToWeekEnd(this._weekRange.endIdx); extra.weekEnd = extra.endSunday; } this._notify("week-filter-change", extra); } /** * Legacy API: устанавливает фильтр по одной неделе (weekEnd string). * Конвертирует строку в weekIndex через WeekIndexService и делегирует в setWeekRange. * @deprecated Используйте setWeekRange(startIdx, endIdx) * @param {string | null} weekEnd * @returns {void} */ setWeekFilter(weekEnd) { if (weekEnd === null) { this.clearWeekRange(); return; } const wis = typeof window !== "undefined" && window.weekIndexService ? window.weekIndexService : null; if (!wis) { console.warn( "[FilterStateManager] WeekIndexService not available for legacy setWeekFilter", ); return; } const idx = wis.weekEndToIndex(String(weekEnd)); this.setWeekRange(idx, idx); } // ==================== Accessors ==================== /** * Возвращает глубокую копию текущего состояния (сериализуемая). * @returns {FilterState} */ getState() { return { codeFilters: this._codeFilters.map((f) => this._cloneCodeFilter(f)), levelCodeFilters: this._levelCodeFilters.map((f) => this._cloneCodeFilter(f), ), wbsFilterIds: Array.from(this._wbsFilterIds), resourceFilterIds: this.getEffectiveResourceFilterIds(), selectedResourceFilterIds: Array.from(this._resourceFilterIds), disabledResourceFilterIds: Array.from(this._disabledResourceFilterIds), showCurrent: this._showCurrent, showTarget: this._showTarget, weekRange: { ...this._weekRange }, weekRangeEnabled: this._weekRangeEnabled, groupings: this._groupings.map((g) => ({ ...g })), levelFiltersActive: this._levelFiltersActive, }; } /** * Возвращает только включённые (enabled=true) фильтры по кодам, * исключая выключенные value (disabledCodeValueIds). * Объединяет пользовательские (_codeFilters) и level-фильтры (_levelCodeFilters) * по codeTypeId, чтобы не было конфликта `typeId IN (...) AND typeId IN (...)`. * @returns {CodeFilterItem[]} */ getEffectiveCodeFilters() { const userFilters = this._getEffectiveCodeFilterItems(this._codeFilters); const levelFilters = this._getEffectiveCodeFilterItems( this._levelCodeFilters, ); /** @type {Map<number, CodeFilterItem>} */ const byTypeId = new Map(); for (const f of levelFilters) { byTypeId.set(f.codeTypeId, f); } for (const f of userFilters) { const existing = byTypeId.get(f.codeTypeId); if (existing) { byTypeId.set(f.codeTypeId, this._mergeCodeFilters(existing, f)); } else { byTypeId.set(f.codeTypeId, f); } } return Array.from(byTypeId.values()); } /** * Возвращает только включённые level-фильтры активного уровня. * @returns {CodeFilterItem[]} */ getEffectiveLevelCodeFilters() { return this._getEffectiveCodeFilterItems(this._levelCodeFilters); } /** * Возвращает true, если level filters были явно установлены * для текущего уровня (даже если список пуст). * @returns {boolean} */ areLevelFiltersActive() { return this._levelFiltersActive; } /** * Вспомогательный метод: фильтрует выключенные value и возвращает копию. * @param {CodeFilterItem[]} filters * @returns {CodeFilterItem[]} * @private */ _getEffectiveCodeFilterItems(filters) { return filters .filter((f) => f.enabled) .map((f) => { const disabledIds = new Set( (f.disabledCodeValueIds || []).map((id) => Number(id)), ); const activeValueIds = f.codeValueIds.filter( (id) => !disabledIds.has(Number(id)), ); const activeValues = f.codeValues.filter( (v) => !disabledIds.has(Number(v.valueId)), ); const clone = this._cloneCodeFilter(f); clone.codeValueIds = activeValueIds; clone.disabledCodeValueIds = []; clone.codeValues = activeValues; return clone; }) .filter((f) => f.codeValueIds.length > 0); } /** * Объединяет два CodeFilterItem с одинаковым codeTypeId. * @param {CodeFilterItem} a * @param {CodeFilterItem} b * @returns {CodeFilterItem} * @private */ _mergeCodeFilters(a, b) { const valueIds = new Set([...a.codeValueIds, ...b.codeValueIds]); /** @type {Map<number, import('../types.js').CodeFilterValueItem>} */ const valuesById = new Map(); for (const v of a.codeValues) valuesById.set(v.valueId, v); for (const v of b.codeValues) { if (!valuesById.has(v.valueId)) valuesById.set(v.valueId, v); } const merged = this._cloneCodeFilter(a); merged.codeValueIds = Array.from(valueIds); merged.codeValues = Array.from(valuesById.values()); merged.disabledCodeValueIds = []; merged.source = a.source === "level" || b.source === "level" ? "mixed" : "user"; this._syncDeprecatedFields(merged); return merged; } /** * Возвращает массив WBS IDs (или пустой массив). * @returns {number[]} */ getEffectiveWbsFilterIds() { return Array.from(this._wbsFilterIds); } /** * Возвращает массив включённых Resource IDs для материального фильтра. * @returns {number[]} */ getEffectiveResourceFilterIds() { return Array.from(this._resourceFilterIds).filter( (id) => !this._disabledResourceFilterIds.has(id), ); } /** * Возвращает массив всех выбранных Resource IDs (включая disabled). * @returns {number[]} */ getSelectedResourceFilterIds() { return Array.from(this._resourceFilterIds); } /** * Возвращает массив отключённых Resource IDs. * @returns {number[]} */ getDisabledResourceFilterIds() { return Array.from(this._disabledResourceFilterIds); } /** * Возвращает только включённые (enabled=true) группировки. * @returns {Grouping[]} */ getEffectiveGroupings() { return this._groupings.filter((g) => g.enabled).map((g) => ({ ...g })); } /** * Возвращает текущий активный диапазон недель. * Если week range отключён, возвращает {startIdx: null, endIdx: null}. * @returns {WeekRange} */ getEffectiveWeekRange() { if (!this._weekRangeEnabled) return { startIdx: null, endIdx: null }; return { ...this._weekRange }; } /** * Возвращает выбранный диапазон недель независимо от флага enabled. * @returns {WeekRange} */ getSelectedWeekRange() { return { ...this._weekRange }; } /** * Возвращает флаг активации диапазона недель. * @returns {boolean} */ isWeekRangeEnabled() { return this._weekRangeEnabled; } /** * Устанавливает неделю с бегунка timeline. Сохраняется всегда, * но data-date-change эмитится только когда weekRange НЕ активен. * @param {number|null} weekIdx * @returns {void} */ setTimelineWeekIdx(weekIdx) { const newIdx = weekIdx === null ? null : Number(weekIdx); if (newIdx !== null && !Number.isFinite(newIdx)) { console.warn("[FilterStateManager] Invalid timeline weekIdx:", weekIdx); return; } if (this._timelineWeekIdx === newIdx) return; this._timelineWeekIdx = newIdx; // Timeline имеет приоритет только когда weekRange не активен if (!this.isWeekRangeActive()) { this._dispatchDataDateChange("timeline"); } } /** * Возвращает единый Data Date для всех виджетов. * Приоритет: weekRange > timeline > null. * @returns {EffectiveDataDate} */ getEffectiveDataDate() { if (this.isWeekRangeActive()) { return { mode: "range", startIdx: this._weekRange.startIdx, endIdx: this._weekRange.endIdx, }; } const wis = typeof window !== "undefined" && window.weekIndexService ? window.weekIndexService : null; return { mode: "timeline", weekIdx: this._timelineWeekIdx, weekEnd: this._timelineWeekIdx !== null && wis ? wis.indexToWeekEnd(this._timelineWeekIdx) : undefined, }; } /** * Проверяет, активен ли диапазон недель (хотя бы одна граница задана). * @returns {boolean} */ isWeekRangeActive() { return ( this._weekRangeEnabled && (this._weekRange.startIdx !== null || this._weekRange.endIdx !== null) ); } // ==================== Internal ==================== /** * Диспатчит событие data-date-change при реальном изменении effective data date. * @param {'timeline'|'range'|'range-reset'} reason * @returns {void} */ _dispatchDataDateChange(reason) { const dataDate = this.getEffectiveDataDate(); const prev = this._previousEffectiveDataDate; // Избегаем дублирования: сравниваем все значимые поля const isSame = prev && prev.mode === dataDate.mode && prev.weekIdx === dataDate.weekIdx && prev.weekEnd === dataDate.weekEnd && prev.startIdx === dataDate.startIdx && prev.endIdx === dataDate.endIdx; if (isSame) return; this._previousEffectiveDataDate = { ...dataDate }; /** @type {DataDateChangeDetail} */ const detail = { dataDate, previousDataDate: prev || undefined, reason, }; document.dispatchEvent( new CustomEvent("data-date-change", { detail, bubbles: true, }), ); } /** * Валидация payload для setCodeFilter. * @param {any} payload * @returns {boolean} */ _isValidCodeFilterPayload(payload) { const typeId = payload?.typeId != null ? Number(payload.typeId) : NaN; const valueId = payload?.valueId != null ? Number(payload.valueId) : NaN; return ( payload != null && typeof payload === "object" && Number.isFinite(typeId) && Number.isFinite(valueId) ); } /** * Нормализует цветовое значение для CSS: добавляет `#` к bare-hex строкам. * Поддерживает 3- и 6-символьные HEX, named colors, rgb()/hsl() и уже * валидные 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; // Already valid CSS color formats. 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; } // Bare hex like "0000FF" or "FFF" → prepend `#`. if (/^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(trimmed)) { return `#${trimmed}`; } return null; } /** * Создаёт новый CodeFilterItem с одним выбранным значением. * @param {number} typeId * @param {string} typeName * @param {number} valueId * @param {string} valueName * @param {string} [color] * @returns {CodeFilterItem} */ _createCodeFilterItem(typeId, typeName, valueId, valueName, color) { const normalizedColor = this._normalizeColor(color); const valueItem = { valueId, valueName: valueName || "", color: normalizedColor || undefined, }; return { id: `filter-${typeId}`, codeTypeId: typeId, codeTypeName: typeName || "", codeValueIds: [valueId], disabledCodeValueIds: [], codeValues: [valueItem], enabled: true, codeValueId: valueId, codeValueName: valueName || "", color: normalizedColor || undefined, }; } /** * Клонирует фильтр по кодам с полным копированием массивов value. * @param {CodeFilterItem} filter * @returns {CodeFilterItem} */ _cloneCodeFilter(filter) { return { ...filter, codeValueIds: [...filter.codeValueIds], disabledCodeValueIds: [...(filter.disabledCodeValueIds || [])], codeValues: filter.codeValues.map((v) => ({ ...v })), }; } /** * Синхронизирует deprecated-поля codeValueId/codeValueName/color * с первым элементом массива value (FEAT-004). * @param {CodeFilterItem} filter * @returns {void} */ _syncDeprecatedFields(filter) { // Deprecated-поля должны отражать первое АКТИВНОЕ значение (BUG-004). // Если активных значений нет — сбрасываем, чтобы legacy consumers // не использовали деактивированное value как выбранное. const activeIds = new Set( (filter.codeValueIds || []).map((id) => Number(id)), ); const firstActive = (filter.codeValues || []).find((v) => activeIds.has(Number(v.valueId)), ); if (firstActive) { filter.codeValueId = firstActive.valueId; filter.codeValueName = firstActive.valueName; filter.color = firstActive.color; } else { filter.codeValueId = undefined; filter.codeValueName = undefined; filter.color = undefined; } } /** * Нормализует фильтр из сохранённого состояния / legacy-формата. * Поддерживает миграцию codeValueId → codeValueIds (FEAT-004). * @param {any} filter * @returns {CodeFilterItem} */ _normalizeCodeFilter(filter) { const codeTypeId = Number(filter.codeTypeId); const enabled = typeof filter.enabled === "boolean" ? filter.enabled : true; let codeValues = []; if (Array.isArray(filter.codeValues)) { codeValues = filter.codeValues .filter((v) => v && Number.isFinite(Number(v.valueId))) .map((v) => ({ valueId: Number(v.valueId), valueName: v.valueName || "", color: this._normalizeColor(v.color) || undefined, })); } let codeValueIds = []; if (codeValues.length > 0) { codeValueIds = codeValues.map((v) => v.valueId); } else if (Array.isArray(filter.codeValueIds)) { codeValueIds = filter.codeValueIds .filter(Number.isFinite) .map((id) => Number(id)); } else if (filter.codeValueId != null) { const legacyId = Number(filter.codeValueId); if (Number.isFinite(legacyId)) { codeValueIds = [legacyId]; } } let disabledCodeValueIds = []; if (Array.isArray(filter.disabledCodeValueIds)) { disabledCodeValueIds = filter.disabledCodeValueIds .filter(Number.isFinite) .map((id) => Number(id)); } const normalizedFilterColor = this._normalizeColor(filter.color); if (codeValues.length === 0 && codeValueIds.length > 0) { codeValues = codeValueIds.map((id, idx) => ({ valueId: id, valueName: idx === 0 ? filter.codeValueName || "" : "", color: idx === 0 ? normalizedFilterColor || undefined : undefined, })); } const item = { id: filter.id || `filter-${codeTypeId}`, codeTypeId, codeTypeName: filter.codeTypeName || "", codeValueIds, disabledCodeValueIds, codeValues, enabled, source: filter.source || "user", }; if (codeValues.length > 0) { const first = codeValues[0]; item.codeValueId = first.valueId; item.codeValueName = first.valueName; item.color = first.color; } else if (normalizedFilterColor) { item.color = normalizedFilterColor; } return item; } /** * Основной метод нотификации: диспатчит unified + legacy события. * @param {FilterStateChangeDetail['action']} action * @param {Object} extra * @param {CodeFilterItem} [extra.filter] * @param {number[]} [extra.ids] * @param {boolean} [extra.showCurrent] * @param {boolean} [extra.showTarget] * @param {Grouping[]} [extra.groupings] * @param {number|null} [extra.startIdx] * @param {number|null} [extra.endIdx] * @param {string|null} [extra.startMonday] * @param {string|null} [extra.endSunday] * @param {string|null} [extra.weekEnd] * @param {string} [extra.action] */ _notify(action, extra) { const state = this.getState(); /** @type {FilterStateChangeDetail} */ const detail = { action, state, ...extra, }; // Fix: extra может содержать поле action (например, 'add'/'clear' из setWbsFilter), // которое перезаписывает unified action. Восстанавливаем его. detail.action = action; // 1. Unified event (новый) if (typeof document !== "undefined") { document.dispatchEvent( new CustomEvent("filter-state-change", { detail, bubbles: true, }), ); } // 2. Legacy bridge events this._dispatchLegacyEvents(action, state, extra); } /** * Диспатчит legacy события для backward compatibility. * @param {string} action * @param {FilterState} state * @param {Object} extra */ _dispatchLegacyEvents(action, state, extra) { if (typeof document === "undefined") return; switch (action) { case "code-filter-set": case "code-filter-remove": case "code-filter-toggle": case "code-filter-clear": case "code-filter-replace": { document.dispatchEvent( new CustomEvent("filter-change", { detail: { filters: state.codeFilters, action: this._mapCodeActionToLegacy(action), changedFilter: extra.filter || undefined, }, bubbles: true, }), ); break; } case "wbs-filter-change": { /** @type {WbsFilterChangeDetail} */ const wbsDetail = { selectedWbsIds: state.wbsFilterIds, // NOTE: effectiveIds передаются вызывающим (WbsFilter), // который знает WBS-иерархию и вычисляет parent-absorption. effectiveWbsIds: extra.effectiveIds || state.wbsFilterIds, action: extra.action || "add", changedWbsId: undefined, }; document.dispatchEvent( new CustomEvent("wbs-filter-change", { detail: wbsDetail, bubbles: true, }), ); break; } case "grouping-change": { document.dispatchEvent( new CustomEvent("grouping-change", { detail: { groupings: state.groupings, action: "set", }, bubbles: true, }), ); break; } case "resource-filter-change": { document.dispatchEvent( new CustomEvent("resource-filter-change", { detail: { resourceFilterIds: state.resourceFilterIds, action: extra.action || "add", }, bubbles: true, }), ); break; } case "week-filter-change": { // Legacy week-select: эмитим если есть weekEnd (точечный выбор одной недели) const weekEnd = extra.weekEnd || null; if (weekEnd) { document.dispatchEvent( new CustomEvent("week-select", { detail: { weekEnd, }, bubbles: true, }), ); } break; } case "schedule-mode-change": { document.dispatchEvent( new CustomEvent("table-schedule-mode-change", { detail: { showCurrent: state.showCurrent, showTarget: state.showTarget, }, bubbles: true, }), ); break; } } } /** * Маппинг внутренних action-имён в legacy формат COMP-003. * @param {string} action * @returns {'add'|'remove'|'toggle'|'set'|'clear'} */ _mapCodeActionToLegacy(action) { switch (action) { case "code-filter-set": return "add"; case "code-filter-remove": return "remove"; case "code-filter-toggle": return "toggle"; case "code-filter-clear": return "clear"; case "code-filter-replace": return "set"; default: return "set"; } } } // Создаём и экспортируем синглтон const filterState = new FilterStateManager(); if (typeof window !== "undefined") { window.FilterStateManager = filterState; } export { filterState, FilterStateManager }; export default filterState;