/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/components/HeaderFilterBar.js
2 381 строка
83 KB
Starolat Sergei
feat: тултип с описанием и сроками на всех строках селектора «Цель КП» (COMP-033)
30 июл 2026, 07:21
30 июл 2026, 07:21
69aa357
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview HeaderFilterBar — управляющие фильтры в шапке приложения. * * Дублирует базовые фильтры панели фильтров для быстрого доступа * в обычном режиме и особенно в дзен-режиме, когда боковые панели скрыты. * * Поддерживает 4 фильтра: * 1. Позиция ГП → справочник "Позиция_ГП_МСГ" * 2. Комплект РД → справочник "!Марка чертежа (Основной)" * 3. Дисциплина → справочник "Вид работ" * 4. Подрядчик → справочник "!Подрядчик по договору" * 5. Физобъем → материальные ресурсы (вкладка Ресурсы панели фильтров) * * Дополнительно — селектор цели локального критического пути «Цель КП» * (COMP-033): триггер по паттерну быстрых фильтров + дропдаун со списком * работ/вех, поиском и секцией настроек (дедлайн, пороги, сброс). * Состояние анализа живёт в LocalCPStateService, UI синхронизируется * через document-событие local-cp-change. * * Логика: * - Множественный выбор значений в режиме ИЛИ (чекбоксы). * - Изменения делегируются в FilterStateManager (SYS-006). * - UI синхронизируется через событие filter-state-change. * - При смене базы данных перезагружаются справочники и ресурсы. * * @version 1.0.0 */ import dataService from "../services/DataService.js"; import dataIndexer from "../services/DataIndexer.js"; import filterState from "../services/FilterStateManager.js"; import { applyCodeFilters, applyWbsFilter, applyResourceFilter, applyWeekRangeFilter, } from "../services/FilterEngine.js"; import { localCPStateService } from "../services/LocalCPStateService.js"; import { daysToIso } from "../services/CalendarMath.js"; import { Check, ChevronDown, X, Flag, AlertTriangle, } from "./ConstantinIcons.js"; /** * @typedef {import('../types.js?__BUILD_ID__').CodeValue} CodeValue * @typedef {import('../types.js?__BUILD_ID__').Resource} Resource * @typedef {import('../types.js?__BUILD_ID__').ResourceFilterItem} ResourceFilterItem */ /** * Описание управляющего фильтра в шапке. * @typedef {Object} HeaderFilterConfig * @property {string} key - Уникальный ключ фильтра. * @property {string} label - Текст на кнопке-триггере. * @property {'code'|'resource'} kind - Тип фильтра. * @property {string} [typeName] - Имя справочника (для kind === 'code'). */ /** @type {HeaderFilterConfig[]} */ const FILTER_CONFIGS = [ { key: "position", label: "Позиция ГП", kind: "code", typeName: "Позиция_ГП_МСГ", }, { key: "drawing", label: "Комплект РД", kind: "code", typeName: "!Марка чертежа (Основной)", }, { key: "discipline", label: "Дисциплина", kind: "code", typeName: "Вид работ", }, { key: "contractor", label: "Подрядчик", kind: "code", typeName: "!Подрядчик по договору", }, { key: "resource", label: "Физобъем", kind: "resource", }, ]; /** Ключ дропдауна селектора цели ЛКП (COMP-033). */ const LOCALCP_KEY = "localcp"; /** Рендер-кап списка целей ЛКП (защита от 50k работ), спека F.2. */ const LOCALCP_RENDER_CAP = 100; /** * Маппинг anchorSource → человекочитаемый ярлык источника якоря * (спека F.2: «план/ограничение/дедлайн»). * @type {Record<string, string>} */ const LOCALCP_ANCHOR_LABELS = { manual: "дедлайн", constraint: "ограничение", planned: "план", }; class HeaderFilterBar extends HTMLElement { constructor() { super(); /** @type {string|null} */ this._currentDatabaseId = null; /** * Загруженные значения кодов по ключу фильтра. * @type {Map<string, CodeValue[]>} */ this._codeValuesByKey = new Map(); /** * Загруженные ресурсы. * @type {ResourceFilterItem[]} */ this._resourceItems = []; /** * IDs доступных значений для открытого фильтра (valueId/resourceId). * @type {Set<number>|null} */ this._availableValueIds = null; /** * Ключ кэша для _availableValueIds: комбинация открытого фильтра и effective state. * @type {string|null} */ this._availableValueCacheKey = null; /** * Открытый в данный момент фильтр (key или null). * @type {string|null} */ this._openFilterKey = null; /** * Выбранные значения по ключу фильтра (kind === 'code'). * @type {Map<string, Set<number>>} */ this._selectedCodeValues = new Map(); /** * Выбранные ресурсы. * @type {Set<number>} */ this._selectedResourceIds = new Set(); /** * Флаг, чтобы избежать закрытия дропдауна при собственных изменениях. * @type {boolean} */ this._ignoreNextFilterStateChangeClose = false; /** @type {Function} */ this._onDatabaseReady = this._onDatabaseReady.bind(this); this._onDatabaseRemoved = this._onDatabaseRemoved.bind(this); this._onFilterStateChange = this._onFilterStateChange.bind(this); this._onDocumentClick = this._onDocumentClick.bind(this); this._onKeyDown = this._onKeyDown.bind(this); this._onLocalCPChange = this._onLocalCPChange.bind(this); this._onDatabaseChanged = this._onDatabaseChanged.bind(this); /** * Кэш настроек анализа ЛКП для дропдауна селектора цели (COMP-033). * Используется, когда цель ещё не задана и состояния в сервисе нет. * @type {{deadlineDate: string, criticalThreshold: number, nearCriticalThreshold: number}} */ this._localCPSettingsCache = { deadlineDate: "", criticalThreshold: 0, nearCriticalThreshold: 5, }; } connectedCallback() { const wasRendered = this.querySelector(".header-filter-bar-root") !== null; if (!wasRendered) { this.render(); this._setupEventListeners(); } document.addEventListener("database-ready", this._onDatabaseReady); document.addEventListener("database-removed", this._onDatabaseRemoved); document.addEventListener("filter-state-change", this._onFilterStateChange); document.addEventListener("click", this._onDocumentClick); document.addEventListener("keydown", this._onKeyDown); document.addEventListener("local-cp-change", this._onLocalCPChange); document.addEventListener("database-changed", this._onDatabaseChanged); // Подхватываем уже активную БД const activeDbId = dataService.getActiveDatabaseId(); if (activeDbId) { this._currentDatabaseId = activeDbId; this._loadAll(activeDbId); } this._syncFromFilterState(); } disconnectedCallback() { document.removeEventListener("database-ready", this._onDatabaseReady); document.removeEventListener("database-removed", this._onDatabaseRemoved); document.removeEventListener( "filter-state-change", this._onFilterStateChange, ); document.removeEventListener("click", this._onDocumentClick); document.removeEventListener("keydown", this._onKeyDown); document.removeEventListener("local-cp-change", this._onLocalCPChange); document.removeEventListener("database-changed", this._onDatabaseChanged); } /** * @param {CustomEvent} e * @private */ _onDatabaseReady(e) { const dbId = e.detail?.databaseId; if (!dbId) return; this._currentDatabaseId = dbId; this._loadAll(dbId); } /** * @param {CustomEvent} e * @private */ _onDatabaseRemoved(e) { const dbId = e.detail?.databaseId; if (dbId && dbId === this._currentDatabaseId) { this._currentDatabaseId = null; this._codeValuesByKey.clear(); this._resourceItems = []; this._selectedCodeValues.clear(); this._selectedResourceIds.clear(); this._availableValueIds = null; this._availableValueCacheKey = null; this._closeDropdown(); this._renderFilters(); } } /** * Обработчик local-cp-change (COMP-033): перерисовка триггера «Цель КП» * и подсветки выбранной цели в открытом дропдауне. * @param {CustomEvent} e * @private */ _onLocalCPChange(e) { const detail = e.detail || {}; const dbId = this._getLocalCPDatabaseId(); if (dbId && detail.databaseId && detail.databaseId !== dbId) return; if (detail.state) { this._localCPSettingsCache = { deadlineDate: detail.state.deadlineDate || "", criticalThreshold: detail.state.criticalThreshold, nearCriticalThreshold: detail.state.nearCriticalThreshold, }; } this._renderFilters(); this._refreshLocalCPDropdownSelection(); this._updateLocalCPDeadlineField(); } /** * Обработчик database-changed: обновляем активную БД и перерисовываем * триггер цели КП (COMP-033). * @param {CustomEvent} e * @private */ _onDatabaseChanged(e) { const dbId = e.detail?.databaseId; if (dbId) this._currentDatabaseId = dbId; this._renderFilters(); } /** * @param {CustomEvent} e * @private */ _onFilterStateChange(e) { const action = e.detail?.action; const relevantActions = [ "code-filter-set", "code-filter-remove", "code-filter-toggle", "code-filter-clear", "code-filter-replace", "resource-filter-change", "level-filter-change", "wbs-filter-change", "week-select", ]; if (!relevantActions.includes(action)) return; // Сбрасываем кэш доступных значений — состояние фильтров изменилось. this._availableValueIds = null; this._availableValueCacheKey = null; // Если дропдаун открыт, закрываем его, чтобы UI оставался консистентным // (пересчёт disabled-значений происходит при следующем открытии). // Исключение: изменение инициировано изнутри дропдауна (клик по опции). if (this._openFilterKey && !this._ignoreNextFilterStateChangeClose) { this._closeDropdown(); } this._ignoreNextFilterStateChangeClose = false; this._syncFromFilterState(); } /** * Синхронизирует локальное состояние с FilterStateManager. * @private */ _syncFromFilterState() { const state = filterState.getState(); // Code filters: собираем выбранные value по typeName /** @type {Map<string, Set<number>>} */ const selectedByKey = new Map(); for (const cfg of FILTER_CONFIGS.filter((c) => c.kind === "code")) { selectedByKey.set(cfg.key, new Set()); } for (const filter of state.codeFilters || []) { if (!filter.enabled) continue; const cfg = FILTER_CONFIGS.find( (c) => c.kind === "code" && this._resolveTypeId(c.typeName) === filter.codeTypeId, ); if (!cfg) continue; const set = selectedByKey.get(cfg.key) || new Set(); for (const id of filter.codeValueIds || []) { set.add(Number(id)); } selectedByKey.set(cfg.key, set); } this._selectedCodeValues = selectedByKey; // Resource filters: use effective (enabled-only) IDs so disabled resources // are not shown as selected in the header dropdown. this._selectedResourceIds = new Set( filterState.getEffectiveResourceFilterIds(), ); this._renderFilters(); this._refreshOpenDropdownSelection(); } /** * Обновляет состояние чекбоксов в открытом дропдауне без полной перерисовки. * @private */ _refreshOpenDropdownSelection() { if (!this._openFilterKey) return; const cfg = FILTER_CONFIGS.find((c) => c.key === this._openFilterKey); if (!cfg) return; const selectedIds = cfg.kind === "resource" ? this._selectedResourceIds : this._getSelectedSet(cfg.key); const list = document.querySelector( `.header-filter-dropdown-list[data-filter-key="${cfg.key}"]`, ); if (!list) return; list.querySelectorAll(".header-filter-option").forEach((option) => { const valueId = parseInt(option.getAttribute("data-value-id") || "0", 10); const selected = selectedIds.has(valueId); option.classList.toggle("selected", selected); option.setAttribute("aria-checked", selected ? "true" : "false"); }); } /** * Загружает все данные для управляющих фильтров. * @param {string} dbId * @private */ async _loadAll(dbId) { await Promise.all([ this._loadCodeFilters(dbId), this._loadResources(dbId), ]); this._syncFromFilterState(); } /** * Загружает значения для code-фильтров. * @param {string} dbId * @private */ async _loadCodeFilters(dbId) { const codeTypes = await dataService.getCodeTypes(dbId, { scheduleType: "current", }); for (const cfg of FILTER_CONFIGS.filter((c) => c.kind === "code")) { const typeName = cfg.typeName || ""; let codeType = codeTypes.find((ct) => ct.name === typeName); if (!codeType && typeName.startsWith("!")) { // Fallback: иногда Primavera хранит имена без префикса codeType = codeTypes.find((ct) => ct.name === typeName.slice(1)); } if (!codeType) { console.warn(`[HeaderFilterBar] Code type not found: "${typeName}"`); this._codeValuesByKey.set(cfg.key, []); continue; } try { const values = await dataService.getCodeValues( String(codeType.typeId), dbId, { scheduleType: "current" }, ); this._codeValuesByKey.set( cfg.key, values.map((v) => ({ ...v, valueId: Number(v.valueId), typeId: Number(v.typeId), })), ); } catch (err) { console.warn( `[HeaderFilterBar] Failed to load values for "${typeName}":`, err, ); this._codeValuesByKey.set(cfg.key, []); } } } /** * Загружает материальные ресурсы. * @param {string} dbId * @private */ async _loadResources(dbId) { const indexes = dataIndexer.getIndexes(dbId); if (!indexes) { this._resourceItems = []; return; } /** @type {ResourceFilterItem[]} */ const items = []; for (const [resourceId, resource] of indexes.resourcesById) { if (resource.type !== "material") continue; items.push({ resourceId: Number(resourceId), name: String(resource.name || ""), shortName: undefined, unitName: String(resource.unit || ""), targetUnits: 0, actualUnits: 0, }); } items.sort((a, b) => String(a.name).localeCompare(String(b.name))); this._resourceItems = items; } /** * Возвращает ID справочника по имени из уже загруженных данных. * @param {string|undefined} typeName * @returns {number|null} * @private */ _resolveTypeId(typeName) { if (!typeName) return null; for (const cfg of FILTER_CONFIGS.filter((c) => c.kind === "code")) { if (cfg.typeName !== typeName) continue; const values = this._codeValuesByKey.get(cfg.key); if (values && values.length > 0) { return values[0].typeId; } } return null; } /** * Возвращает ID значения "3" справочника "!Уровень графика". * @param {import('../types.js?__BUILD_ID__').DatabaseIndexes} indexes * @returns {number|null} * @private */ _getLevelGraphValue3Id(indexes) { if (!indexes || !indexes.codeTypeById || !indexes.codeValueById) return null; for (const type of indexes.codeTypeById.values()) { const name = String(type.name || "").trim(); if (name !== "!Уровень графика" && name !== "Уровень графика") continue; for (const value of indexes.codeValueById.values()) { if ( Number(value.typeId) === Number(type.typeId) && String(value.shortName || "").trim() === "3" ) { return Number(value.valueId); } } } return null; } /** * @param {string} key * @returns {CodeValue[]} * @private */ _getCodeValues(key) { return this._codeValuesByKey.get(key) || []; } /** * @param {string} key * @returns {Set<number>} * @private */ _getSelectedSet(key) { return this._selectedCodeValues.get(key) || new Set(); } /** * Возвращает effective-фильтры из FilterStateManager, исключая открываемый фильтр. * @param {string} openKey * @returns {{ * codeFilters: import('../types.js?__BUILD_ID__').CodeFilterItem[], * wbsFilterIds: number[], * resourceFilterIds: number[], * weekRange: import('../types.js?__BUILD_ID__').WeekRange|null * }} * @private */ _getEffectiveFiltersExcluding(openKey) { const cfg = FILTER_CONFIGS.find((c) => c.key === openKey); const allCodeFilters = filterState.getEffectiveCodeFilters() || []; let codeFilters = allCodeFilters; if (cfg && cfg.kind === "code") { const excludedTypeId = this._resolveTypeId(cfg.typeName); if (excludedTypeId != null) { codeFilters = allCodeFilters.filter( (f) => Number(f.codeTypeId) !== Number(excludedTypeId), ); } } // При открытии фильтра "Физобъем" исключаем значение "3" из // level/user фильтра "!Уровень графика", т.к. resource-фильтр и // уровень 3 взаимоисключаются по бизнес-правилу. if (cfg && cfg.kind === "resource") { const indexes = dataIndexer.getIndexes(this._currentDatabaseId); const levelValue3Id = indexes ? this._getLevelGraphValue3Id(indexes) : null; if (levelValue3Id != null) { codeFilters = codeFilters .map((f) => { const name = String(f.codeTypeName || "").trim(); if (name !== "!Уровень графика" && name !== "Уровень графика") { return f; } const activeIds = (f.codeValueIds || []).filter( (id) => Number(id) !== levelValue3Id, ); if (activeIds.length === 0) return null; const disabledIds = (f.disabledCodeValueIds || []).filter( (id) => Number(id) !== levelValue3Id, ); const values = (f.codeValues || []).filter( (v) => Number(v.valueId) !== levelValue3Id, ); return { ...f, codeValueIds: activeIds, disabledCodeValueIds: disabledIds, codeValues: values }; }) .filter(Boolean); } } const allResourceIds = filterState.getEffectiveResourceFilterIds(); const resourceFilterIds = cfg && cfg.kind === "resource" ? [] : allResourceIds; return { codeFilters, wbsFilterIds: filterState.getEffectiveWbsFilterIds(), resourceFilterIds, weekRange: filterState.getEffectiveWeekRange(), }; } /** * Возвращает набор ID работ, проходящих effective-фильтры без открываемого. * @param {HeaderFilterConfig} cfg * @returns {Set<number>} * @private */ _getFilteredActivityIds(cfg) { const indexes = dataIndexer.getIndexes(this._currentDatabaseId); if (!indexes || !indexes.activitiesById) return new Set(); const filters = this._getEffectiveFiltersExcluding(cfg.key); let activities = new Set(indexes.activitiesById.values()); activities = applyCodeFilters(indexes, activities, filters.codeFilters); activities = applyWbsFilter(indexes, activities, filters.wbsFilterIds); activities = applyResourceFilter( indexes, activities, filters.resourceFilterIds, ); activities = applyWeekRangeFilter( indexes, activities, filters.weekRange, ); const ids = new Set(); for (const a of activities) { ids.add(a.id); } return ids; } /** * Строит ключ кэша для доступных значений открытого фильтра. * @param {string} openKey * @returns {string} * @private */ _buildAvailabilityCacheKey(openKey) { const codeKey = (filterState.getEffectiveCodeFilters() || []) .map((f) => `${f.codeTypeId}:${f.codeValueIds.join(",")}`) .join("|"); const wbsKey = filterState.getEffectiveWbsFilterIds().join(","); const resKey = filterState.getEffectiveResourceFilterIds().join(","); const weekRange = filterState.getEffectiveWeekRange(); const weekKey = `${weekRange?.startIdx ?? ""}-${weekRange?.endIdx ?? ""}`; return `${this._currentDatabaseId || ""};${openKey};${codeKey};${wbsKey};${resKey};${weekKey}`; } /** * Возвращает IDs значений открытого фильтра, которые присутствуют * у работ, отфильтрованных по остальным активным фильтрам. * @param {HeaderFilterConfig} cfg * @returns {Set<number>} * @private */ _getAvailableValueIds(cfg) { const indexes = dataIndexer.getIndexes(this._currentDatabaseId); if ( !indexes || !indexes.activitiesById || !indexes.activitiesByCodeValue || !indexes.assignmentsByActivity ) { this._availableValueIds = null; this._availableValueCacheKey = null; return null; } const cacheKey = this._buildAvailabilityCacheKey(cfg.key); if ( this._availableValueCacheKey === cacheKey && this._availableValueIds != null ) { return this._availableValueIds; } const filteredActivityIds = this._getFilteredActivityIds(cfg); const available = new Set(); if (cfg.kind === "resource") { for (const activityId of filteredActivityIds) { const assignments = indexes.assignmentsByActivity.get(activityId) || []; for (const ra of assignments) { if (ra.resourceId != null) { available.add(Number(ra.resourceId)); } } } } else { const values = this._getCodeValues(cfg.key); for (const value of values) { const activityIds = indexes.activitiesByCodeValue.get(value.valueId); if (!activityIds) continue; for (const activityId of activityIds) { if (filteredActivityIds.has(activityId)) { available.add(Number(value.valueId)); break; } } } } this._availableValueIds = available; this._availableValueCacheKey = cacheKey; return available; } /** * Снимает выделение с уже выбранных значений открытого фильтра, * которых нет в доступных. * @param {HeaderFilterConfig} cfg * @param {Set<number>} availableIds * @private */ _cleanupUnavailableSelections(cfg, availableIds) { if (!availableIds) return; if (cfg.kind === "resource") { const toRemove = []; for (const id of this._selectedResourceIds) { if (!availableIds.has(id)) toRemove.push(id); } for (const id of toRemove) { filterState.removeResourceFilter(id); } return; } const typeId = this._resolveTypeId(cfg.typeName); if (typeId == null) return; const selectedIds = this._getSelectedSet(cfg.key); const toRemove = []; for (const id of selectedIds) { if (!availableIds.has(id)) toRemove.push(id); } for (const id of toRemove) { filterState.removeCodeFilterValue(typeId, id); } } render() { this.innerHTML = ` <style> .header-filter-bar-root { display: flex; align-items: center; gap: var(--space-2xs, 4px); height: 24px; padding: 0 var(--space-2xs, 4px); } .header-filter-item { position: relative; display: flex; align-items: center; } .header-filter-trigger { display: inline-flex; align-items: center; gap: 4px; height: 22px; padding: 0 6px; font-size: var(--font-size-xs, 10px); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary); background: transparent; border: 1px solid transparent; border-radius: var(--radius-xs, 2px); cursor: pointer; white-space: nowrap; outline: none; transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease; } .header-filter-trigger:hover { color: var(--text-primary); background: var(--color-bg-ghost, rgba(0,66,105,0.04)); } .header-filter-trigger:focus-visible { border-color: var(--accent-color); } .header-filter-trigger.active { color: var(--accent-color); background: var(--accent-bg); } .header-filter-trigger .filter-value { color: var(--text-primary); max-width: 80px; overflow: hidden; text-overflow: ellipsis; } .header-filter-trigger .filter-chevron { width: 12px; height: 12px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-tertiary); transition: transform 0.15s ease; } .header-filter-trigger.active .filter-chevron { transform: rotate(180deg); } .header-filter-dropdown { position: fixed; top: 0; left: 0; display: none; flex-direction: column; min-width: 220px; max-width: 320px; max-height: 360px; background: var(--surface-bg); border: 1px solid var(--border-color); border-radius: var(--radius-s, 4px); box-shadow: var(--shadow-modal, 0 4px 16px rgba(0,0,0,0.15)); z-index: 1000; overflow: hidden; } .header-filter-dropdown.open { display: flex; } .header-filter-dropdown-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 6px; border-bottom: 1px solid var(--border-light); flex-shrink: 0; } .header-filter-dropdown-title { font-size: var(--font-size-xs, 10px); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary); } .header-filter-dropdown-close { width: 18px; height: 18px; padding: 0; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: none; color: var(--text-tertiary); cursor: pointer; border-radius: var(--radius-xs, 2px); } .header-filter-dropdown-close:hover { background: var(--color-bg-ghost); color: var(--text-primary); } .header-filter-search { display: flex; align-items: center; gap: 4px; padding: 4px 6px; border-bottom: 1px solid var(--border-light); flex-shrink: 0; } .header-filter-search input { flex: 1; height: 22px; 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: var(--radius-xs, 2px); outline: none; } .header-filter-search input:focus { border-color: var(--accent-color); } .header-filter-dropdown-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 2px 0; } .header-filter-option { display: flex; align-items: center; padding: 2px var(--space-2xs, 4px); font-size: var(--font-size-xs, 10px); color: var(--color-typo-primary); cursor: pointer; border-radius: 2px; overflow: hidden; outline: none; } .header-filter-option.selected { background: var(--hover-bg, rgba(0,0,0,0.04)); color: var(--text-primary); } .header-filter-option:hover { background: var(--hover-bg, rgba(0,0,0,0.04)); } .header-filter-option:focus-visible { outline: 2px solid var(--accent-color); outline-offset: -2px; } .header-filter-option.disabled { opacity: 0.5; cursor: not-allowed; } .header-filter-checkbox { width: 12px; height: 12px; border-radius: 2px; border: 1.5px solid var(--border-color); background: transparent; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; transition: all 0.15s ease; color: var(--accent-color); margin-right: 4px; } .header-filter-checkbox svg { width: 10px; height: 10px; opacity: 0; transition: opacity 0.15s ease; pointer-events: none; } .header-filter-option.selected .header-filter-checkbox { border-color: var(--accent-color); } .header-filter-option.selected .header-filter-checkbox svg { opacity: 1; } .header-filter-color-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-right: 4px; } .header-filter-option-name { font-weight: 500; color: var(--accent-color); flex-shrink: 0; margin-right: 4px; font-size: var(--font-size-xs, 10px); } .header-filter-option-desc { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; color: var(--color-typo-primary); font-size: var(--font-size-xs, 10px); } .header-filter-empty { padding: 8px 6px; font-size: var(--font-size-xs, 10px); color: var(--text-tertiary); text-align: center; } .header-filter-actions { display: flex; gap: 4px; padding: 4px 6px; border-top: 1px solid var(--border-light); flex-shrink: 0; } .header-filter-action-btn { flex: 1; height: 20px; padding: 0 4px; font-size: var(--font-size-xs, 10px); font-weight: 600; color: var(--text-secondary); background: transparent; border: 1px solid var(--border-color); border-radius: var(--radius-xs, 2px); cursor: pointer; transition: all 0.15s ease; } .header-filter-action-btn:hover { background: var(--color-bg-ghost); color: var(--text-primary); } .localcp-clear { width: 16px; height: 16px; padding: 0; margin-left: 2px; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: none; color: var(--text-tertiary); cursor: pointer; border-radius: var(--radius-xs, 2px); flex-shrink: 0; } .localcp-clear:hover { background: var(--color-bg-ghost); color: var(--text-primary); } .localcp-clear svg { width: 10px; height: 10px; } .localcp-warn-icon { width: 12px; height: 12px; display: inline-flex; align-items: center; justify-content: center; color: var(--color-typo-warning, var(--warning-color, #f38b00)); flex-shrink: 0; } .localcp-warn-icon svg { width: 12px; height: 12px; } .localcp-target-badge { width: 12px; height: 12px; display: inline-flex; align-items: center; justify-content: center; color: var(--accent-color); flex-shrink: 0; margin-right: 4px; } .localcp-target-badge svg { width: 10px; height: 10px; } .localcp-option-key { background: var(--accent-color-light, rgba(0, 120, 210, 0.15)); border-left: 2px solid var(--accent-color); } .localcp-option-key .header-filter-option-desc { font-weight: 600; } .localcp-tooltip-dates { margin-top: 4px; font-size: var(--font-size-xs, 10px); color: var(--text-secondary); line-height: 1.4; } .localcp-divider { height: 1px; margin: 2px var(--space-2xs, 4px); background: var(--border-light, rgba(0, 66, 105, 0.2)); } .localcp-dropdown { min-width: 280px; } .localcp-settings { display: flex; flex-direction: column; gap: 4px; padding: 4px 6px; border-top: 1px solid var(--border-light); flex-shrink: 0; } .localcp-settings-row { display: flex; align-items: flex-end; gap: 4px; } .localcp-settings-field { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; } .localcp-settings-field > span { font-size: var(--font-size-xs, 10px); color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.05em; } .localcp-settings-field input { width: 100%; height: 22px; padding: 0 4px; font-size: var(--font-size-xs, 10px); font-family: inherit; color: var(--text-primary); background: var(--input-bg, var(--surface-bg)); border: 1px solid var(--border-color); border-radius: var(--radius-xs, 2px); outline: none; } .localcp-settings-field input.localcp-deadline-auto { color: var(--text-secondary); font-style: italic; } .localcp-settings-field input:focus { border-color: var(--accent-color); } .header-filter-tooltip { position: fixed; display: flex; flex-direction: column; gap: 4px; min-width: 140px; max-width: 260px; max-height: 240px; padding: 6px 8px; background: var(--surface-bg); border: 1px solid var(--border-color); border-radius: var(--radius-s, 4px); box-shadow: var(--shadow-modal, 0 4px 16px rgba(0,0,0,0.15)); z-index: 1000; overflow: hidden; pointer-events: none; } .header-filter-tooltip-header { font-size: var(--font-size-xs, 10px); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary); border-bottom: 1px solid var(--border-light); padding-bottom: 4px; } .header-filter-tooltip-list { margin: 0; padding: 0 0 0 12px; list-style: disc; font-size: var(--font-size-xs, 10px); color: var(--text-primary); overflow-y: auto; } .header-filter-tooltip-list li { padding: 1px 0; word-break: break-word; } .header-filter-tooltip-empty { font-size: var(--font-size-xs, 10px); color: var(--text-tertiary); font-style: italic; } </style> <div class="header-filter-bar-root" role="toolbar" aria-label="Быстрые фильтры"> <!-- Фильтры рендерятся здесь --> </div> `; this._renderFilters(); } /** * Рендерит триггеры фильтров и обновляет тексты. * @private */ _renderFilters() { const root = this.querySelector(".header-filter-bar-root"); if (!root) return; root.innerHTML = FILTER_CONFIGS.map((cfg) => { const selected = this._getSelection(cfg); const valueText = this._formatTriggerValue(cfg, selected); const activeClass = selected.length > 0 ? "active" : ""; const isOpen = this._openFilterKey === cfg.key; return ` <div class="header-filter-item" data-filter-key="${cfg.key}"> <button class="header-filter-trigger ${activeClass}" data-filter-key="${cfg.key}" aria-haspopup="true" aria-expanded="${isOpen ? "true" : "false"}"> <span>${this._escapeHtml(cfg.label)}</span> <span class="filter-value">${this._escapeHtml(valueText)}</span> <span class="filter-chevron">${ChevronDown}</span> </button> </div> `; }).join("") + this._renderLocalCPTrigger(); root.querySelectorAll(".header-filter-trigger").forEach((trigger) => { const key = trigger.getAttribute("data-filter-key"); if (!key) return; trigger.addEventListener("mouseenter", () => this._showTooltip(key, trigger)); trigger.addEventListener("mouseleave", () => this._hideTooltip()); }); } /** * Возвращает массив выбранных ID/значений для конфигурации. * @param {HeaderFilterConfig} cfg * @returns {Array<{id: number, name: string}>} * @private */ _getSelection(cfg) { if (cfg.kind === "resource") { return Array.from(this._selectedResourceIds) .map((id) => { const item = this._resourceItems.find((r) => r.resourceId === id); return item ? { id, name: item.name } : { id, name: `ID ${id}` }; }) .sort((a, b) => String(a.name).localeCompare(String(b.name))); } const selectedIds = this._getSelectedSet(cfg.key); const values = this._getCodeValues(cfg.key); return Array.from(selectedIds) .map((id) => { const value = values.find((v) => v.valueId === id); return value ? { id, name: value.shortName || value.description || `ID ${id}` } : { id, name: `ID ${id}` }; }) .sort((a, b) => String(a.name).localeCompare(String(b.name))); } /** * Форматирует текст выбранных значений для триггера. * @param {HeaderFilterConfig} cfg * @param {Array<{id: number, name: string}>} selected * @returns {string} * @private */ _formatTriggerValue(cfg, selected) { if (selected.length === 0) return "ВСЕ"; if (selected.length === 1) return selected[0].name; return `${selected[0].name} +${selected.length - 1}`; } /** * Показывает тултип со списком активных значений фильтра. * @param {string} key * @param {Element} trigger * @private */ _showTooltip(key, trigger) { const cfg = FILTER_CONFIGS.find((c) => c.key === key); if (!cfg) return; const selected = this._getSelection(cfg); if (selected.length === 0) return; this._hideTooltip(); const tooltip = document.createElement("div"); tooltip.className = "header-filter-tooltip"; tooltip.setAttribute("role", "tooltip"); tooltip.setAttribute("data-filter-key", key); tooltip.innerHTML = this._renderTooltipContent(cfg, selected); document.body.appendChild(tooltip); this._positionTooltip(tooltip, trigger); } /** * Скрывает тултип. * @private */ _hideTooltip() { const tooltip = document.querySelector(".header-filter-tooltip"); if (tooltip) tooltip.remove(); } /** * Позиционирует тултип относительно триггера. * @param {HTMLElement} tooltip * @param {Element} trigger * @private */ _positionTooltip(tooltip, trigger) { const rect = trigger.getBoundingClientRect(); const tooltipRect = tooltip.getBoundingClientRect(); const margin = 8; let top = rect.bottom + margin; let left = rect.left; if (left + tooltipRect.width > window.innerWidth) { left = Math.max(margin, window.innerWidth - tooltipRect.width - margin); } if (top + tooltipRect.height > window.innerHeight) { top = Math.max(margin, rect.top - tooltipRect.height - margin); } tooltip.style.top = `${top}px`; tooltip.style.left = `${left}px`; } /** * Рендерит содержимое тултипа. * @param {HeaderFilterConfig} cfg * @param {Array<{id: number, name: string}>} selected * @returns {string} * @private */ _renderTooltipContent(cfg, selected) { const title = this._escapeHtml(cfg.label); const itemsHtml = selected .map((item) => ` <li>${this._escapeHtml(item.name)}</li>`) .join(""); return ` <div class="header-filter-tooltip-header">${title}</div> <ul class="header-filter-tooltip-list">${itemsHtml}</ul> `; } /** * Проверяет, обрезается ли текст элемента многоточием. * @param {HTMLElement} el * @returns {boolean} * @private */ _isTextTruncated(el) { return el.scrollWidth > el.clientWidth; } /** * Показывает тултип с полным описанием для обрезанного значения фильтра. * @param {HTMLElement} anchor * @param {string} text * @private */ _showDescTooltip(anchor, text) { if (!text) return; this._hideTooltip(); const tooltip = document.createElement("div"); tooltip.className = "header-filter-tooltip"; tooltip.setAttribute("role", "tooltip"); tooltip.innerHTML = ` <div class="header-filter-tooltip-header">Описание</div> <div style="font-size: var(--font-size-xs, 10px); color: var(--text-primary); line-height: 1.4; word-break: break-word;">${this._escapeHtml(text)}</div> `; document.body.appendChild(tooltip); this._positionTooltip(tooltip, anchor); } _setupEventListeners() { this.addEventListener("click", (e) => { const target = /** @type {HTMLElement} */ (e.target); // COMP-033: кнопка × сброса цели ЛКП const localcpClear = target.closest(".localcp-clear"); if (localcpClear) { e.stopPropagation(); const dbId = this._getLocalCPDatabaseId(); if (dbId) localCPStateService.clearTarget(dbId); return; } const trigger = target.closest(".header-filter-trigger"); if (trigger) { if (trigger.hasAttribute("data-localcp-trigger")) { e.stopPropagation(); this._toggleLocalCPDropdown(trigger); return; } const key = trigger.getAttribute("data-filter-key"); if (key) { e.stopPropagation(); this._toggleDropdown(key, trigger); } return; } }); this.addEventListener("keydown", (e) => { const target = /** @type {HTMLElement} */ (e.target); const option = target.closest(".header-filter-option"); if (option && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); if (option.classList.contains("disabled")) return; const key = option.getAttribute("data-filter-key"); const valueId = parseInt(option.getAttribute("data-value-id") || "0", 10); if (key && valueId) { this._toggleValue(key, valueId); } } }); } /** * Открывает/закрывает выпадающий список фильтра. * @param {string} key * @param {Element} trigger * @private */ _toggleDropdown(key, trigger) { if (this._openFilterKey === key) { this._closeDropdown(); return; } this._openDropdown(key, trigger); } /** * Открывает выпадающий список фильтра. * @param {string} key * @param {Element} trigger * @private */ _openDropdown(key, trigger) { this._closeDropdown(); this._hideTooltip(); this._openFilterKey = key; const existing = this.querySelector(".header-filter-dropdown"); if (existing) existing.remove(); const cfg = FILTER_CONFIGS.find((c) => c.key === key); if (!cfg) return; const availableIds = this._getAvailableValueIds(cfg); this._availableValueIds = availableIds; this._cleanupUnavailableSelections(cfg, availableIds); const dropdown = document.createElement("div"); dropdown.className = "header-filter-dropdown open"; dropdown.setAttribute("data-filter-key", key); dropdown.innerHTML = this._renderDropdownContent(cfg); // Дропдаун рендерится в body, поэтому обработчики навешиваем прямо на него this._dropdownClickHandler = (/** @type {MouseEvent} */ e) => { const target = /** @type {HTMLElement} */ (e.target); const option = target.closest(".header-filter-option"); if (option) { if (option.classList.contains("disabled")) return; const optionKey = option.getAttribute("data-filter-key"); const valueId = parseInt(option.getAttribute("data-value-id") || "0", 10); if (optionKey && valueId) { e.stopPropagation(); this._toggleValue(optionKey, valueId); } return; } const clearBtn = target.closest(".header-filter-clear-btn"); if (clearBtn) { const clearKey = clearBtn.getAttribute("data-filter-key"); if (clearKey) { e.stopPropagation(); this._clearFilter(clearKey); } return; } const closeBtn = target.closest(".header-filter-dropdown-close"); if (closeBtn) { e.stopPropagation(); this._closeDropdown(); } }; dropdown.addEventListener("click", this._dropdownClickHandler); this._dropdownInputHandler = (/** @type {Event} */ e) => { const target = /** @type {HTMLElement} */ (e.target); const input = target.closest(".header-filter-search-input"); if (input) { this._filterDropdownList( key, /** @type {HTMLInputElement} */ (input).value, ); } }; dropdown.addEventListener("input", this._dropdownInputHandler); document.body.appendChild(dropdown); this._positionDropdown(dropdown, trigger); // Тултип для обрезанных описаний значений dropdown.querySelectorAll(".header-filter-option-desc").forEach((descEl) => { descEl.addEventListener("mouseenter", () => { if (this._isTextTruncated(/** @type {HTMLElement} */ (descEl))) { const fullDesc = descEl.getAttribute("data-full-desc") || ""; this._showDescTooltip(/** @type {HTMLElement} */ (descEl), fullDesc); } }); descEl.addEventListener("mouseleave", () => this._hideTooltip()); }); // Обновляем aria-expanded this.querySelectorAll(".header-filter-trigger").forEach((btn) => { btn.setAttribute("aria-expanded", "false"); }); const activeTrigger = this.querySelector( `.header-filter-trigger[data-filter-key="${key}"]`, ); if (activeTrigger) activeTrigger.setAttribute("aria-expanded", "true"); } /** * Закрывает выпадающий список. * @private */ _closeDropdown() { const dropdown = document.querySelector(".header-filter-dropdown"); if (dropdown) { if (this._dropdownClickHandler) { dropdown.removeEventListener("click", this._dropdownClickHandler); } if (this._dropdownInputHandler) { dropdown.removeEventListener("input", this._dropdownInputHandler); } dropdown.remove(); } this._openFilterKey = null; this._dropdownClickHandler = null; this._dropdownInputHandler = null; this.querySelectorAll(".header-filter-trigger").forEach((btn) => { btn.setAttribute("aria-expanded", "false"); }); } /** * Позиционирует дропдаун под триггером. * @param {HTMLElement} dropdown * @param {Element} trigger * @private */ _positionDropdown(dropdown, trigger) { const rect = trigger.getBoundingClientRect(); const dropdownRect = dropdown.getBoundingClientRect(); const margin = 4; let top = rect.bottom + margin; let left = rect.left; // Не выходим за правый край viewport if (left + dropdownRect.width > window.innerWidth) { left = Math.max(margin, window.innerWidth - dropdownRect.width - margin); } // Не выходим за нижний край viewport — открываем вверх if (top + dropdownRect.height > window.innerHeight) { top = Math.max(margin, rect.top - dropdownRect.height - margin); } dropdown.style.top = `${top}px`; dropdown.style.left = `${left}px`; } /** * Рендерит содержимое выпадающего списка. * @param {HeaderFilterConfig} cfg * @returns {string} * @private */ _renderDropdownContent(cfg) { const title = this._escapeHtml(cfg.label); if (cfg.kind === "resource") { const items = this._resourceItems; return this._renderOptionsList(cfg, items, (item) => ({ id: item.resourceId, name: item.name, desc: item.unitName || "", color: "", })); } const values = this._getCodeValues(cfg.key); return this._renderOptionsList(cfg, values, (value) => ({ id: value.valueId, name: value.shortName || value.description || `ID ${value.valueId}`, desc: value.shortName && value.description ? value.description : "", color: value.color || "", })); } /** * Универсальный рендер списка значений. * @template T * @param {HeaderFilterConfig} cfg * @param {T[]} items * @param {(item: T) => {id: number, name: string, desc: string, color?: string}} mapFn * @returns {string} * @private */ _renderOptionsList(cfg, items, mapFn) { const title = this._escapeHtml(cfg.label); const selectedIds = cfg.kind === "resource" ? this._selectedResourceIds : this._getSelectedSet(cfg.key); let optionsHtml = ""; if (items.length === 0) { optionsHtml = `<div class="header-filter-empty">Нет доступных значений</div>`; } else { optionsHtml = items .map((item) => { const { id, name, desc, color } = mapFn(item); const selected = selectedIds.has(id); const disabled = this._availableValueIds != null && !this._availableValueIds.has(id); const selectedClass = selected ? "selected" : ""; const disabledClass = disabled ? "disabled" : ""; const ariaChecked = selected ? "true" : "false"; const ariaDisabled = disabled ? "true" : "false"; const tabIndex = disabled ? "-1" : "0"; const colorDot = color && String(color).trim() ? `<span class="header-filter-color-dot" style="background-color: ${this._escapeHtml(String(color))}"></span>` : `<span class="header-filter-color-dot" style="visibility: hidden"></span>`; return ` <div class="header-filter-option ${selectedClass} ${disabledClass}" role="checkbox" aria-checked="${ariaChecked}" aria-disabled="${ariaDisabled}" tabindex="${tabIndex}" data-filter-key="${cfg.key}" data-value-id="${id}"> <span class="header-filter-checkbox">${Check}</span> ${colorDot} <span class="header-filter-option-name">${this._escapeHtml(name)}</span> ${desc ? `<span class="header-filter-option-desc" data-full-desc="${this._escapeHtml(desc)}">${this._escapeHtml(desc)}</span>` : ""} </div> `; }) .join(""); } return ` <div class="header-filter-dropdown-header"> <span class="header-filter-dropdown-title">${title}</span> <button class="header-filter-dropdown-close" aria-label="Закрыть">${X}</button> </div> <div class="header-filter-search"> <input type="text" class="header-filter-search-input" data-filter-key="${cfg.key}" placeholder="Поиск..." aria-label="Поиск по ${title}"> </div> <div class="header-filter-dropdown-list" data-filter-key="${cfg.key}"> ${optionsHtml} </div> <div class="header-filter-actions"> <button class="header-filter-action-btn header-filter-clear-btn" data-filter-key="${cfg.key}">Сбросить</button> </div> `; } /** * Фильтрует список значений по строке поиска. * @param {string} key * @param {string} query * @private */ _filterDropdownList(key, query) { const list = document.querySelector( `.header-filter-dropdown-list[data-filter-key="${key}"]`, ); if (!list) return; const q = query.trim().toLowerCase(); const options = list.querySelectorAll(".header-filter-option"); options.forEach((option) => { const nameEl = option.querySelector(".header-filter-option-name"); const descEl = option.querySelector(".header-filter-option-desc"); const name = nameEl?.textContent?.toLowerCase() || ""; const desc = descEl?.textContent?.toLowerCase() || ""; option.style.display = !q || name.includes(q) || desc.includes(q) ? "" : "none"; }); } /** * Переключает выбор значения. * @param {string} key * @param {number} valueId * @private */ _toggleValue(key, valueId) { const cfg = FILTER_CONFIGS.find((c) => c.key === key); if (!cfg) return; this._ignoreNextFilterStateChangeClose = true; if (cfg.kind === "resource") { const effectiveIds = filterState.getEffectiveResourceFilterIds(); if (effectiveIds.includes(valueId)) { filterState.removeResourceFilter(valueId); } else { const newIds = new Set(effectiveIds); newIds.add(valueId); filterState.setResourceFilter(newIds); } return; } const values = this._getCodeValues(key); const value = values.find((v) => v.valueId === valueId); if (!value) return; const typeId = value.typeId; const selected = this._getSelectedSet(key); if (selected.has(valueId)) { filterState.removeCodeFilterValue(typeId, valueId); } else { filterState.setCodeFilter( { typeId: Number(typeId), valueId: Number(valueId), typeName: cfg.typeName || "", valueName: value.shortName || "", color: value.color || undefined, }, { append: true }, ); } } /** * Сбрасывает фильтр. * @param {string} key * @private */ _clearFilter(key) { const cfg = FILTER_CONFIGS.find((c) => c.key === key); if (!cfg) return; this._ignoreNextFilterStateChangeClose = true; if (cfg.kind === "resource") { filterState.clearResourceFilter(); return; } const typeId = this._resolveTypeId(cfg.typeName); if (typeId != null) { filterState.removeCodeFilter(typeId); } } /** * Закрывает дропдаун при клике вне компонента. * @param {MouseEvent} e * @private */ _onDocumentClick(e) { if (!this._openFilterKey) return; const target = /** @type {HTMLElement} */ (e.target); if (this.contains(target)) return; const dropdown = document.querySelector(".header-filter-dropdown"); if (dropdown && dropdown.contains(target)) return; this._closeDropdown(); } /** * Обработка нажатия Escape для закрытия дропдауна. * Для селектора цели ЛКП фокус возвращается на триггер (спека H). * @param {KeyboardEvent} e * @private */ _onKeyDown(e) { if (e.key === "Escape" && this._openFilterKey) { const wasLocalCP = this._openFilterKey === LOCALCP_KEY; this._closeDropdown(); if (wasLocalCP) { /** @type {HTMLElement|null} */ ( this.querySelector("[data-localcp-trigger]") )?.focus(); } } } // ==================== COMP-033: Селектор цели ЛКП ==================== /** * Активная БД для селектора цели ЛКП. * @returns {string|null} * @private */ _getLocalCPDatabaseId() { return this._currentDatabaseId || dataService.getActiveDatabaseId() || null; } /** * Рендерит триггер селектора цели ЛКП (COMP-033, спека F.2). * Без цели — приглушённый «Цель КП: не задана»; с целью — код, имя и ×. * @returns {string} * @private */ _renderLocalCPTrigger() { const dbId = this._getLocalCPDatabaseId(); const state = dbId ? localCPStateService.getState(dbId) : null; const result = dbId ? localCPStateService.getResult(dbId) : null; const isOpen = this._openFilterKey === LOCALCP_KEY; if (!state) { return ` <div class="header-filter-item" data-filter-key="${LOCALCP_KEY}"> <button class="header-filter-trigger" data-localcp-trigger aria-haspopup="listbox" aria-expanded="${isOpen ? "true" : "false"}" title="Цель локального критического пути не задана"> <span>Цель КП</span> <span class="filter-value">не задана</span> <span class="filter-chevron">${ChevronDown}</span> </button> </div> `; } const options = dbId ? localCPStateService.getTargetOptions(dbId) : []; const option = options.find((o) => o.activityId === state.targetId) || null; const code = option?.activityCode || `ID ${state.targetId}`; const name = option?.taskName || ""; const valueText = name ? `${code} · ${name}` : code; // Спека G: warnings результата → warning-иконка на триггере const warnings = result?.warnings || []; const hasTargetWarning = warnings.includes("target-not-found") || warnings.includes("target-completed"); const warnIcon = hasTargetWarning ? `<span class="localcp-warn-icon" aria-hidden="true">${AlertTriangle}</span>` : ""; const title = this._buildLocalCPTriggerTitle(state, result, option); return ` <div class="header-filter-item" data-filter-key="${LOCALCP_KEY}"> <button class="header-filter-trigger active" data-localcp-trigger aria-haspopup="listbox" aria-expanded="${isOpen ? "true" : "false"}" title="${this._escapeHtml(title)}"> ${warnIcon} <span>Цель КП</span> <span class="filter-value">${this._escapeHtml(valueText)}</span> <span class="filter-chevron">${ChevronDown}</span> </button> <button class="localcp-clear" aria-label="Сбросить цель критического пути" title="Сбросить цель">${X}</button> </div> `; } /** * Собирает title триггера цели ЛКП: код, имя, anchorSource * («план/ограничение/дедлайн») и дата якоря (спека F.2, G). * @param {import('../types.js?__BUILD_ID__').LocalCPUIState} state * @param {import('../types.js?__BUILD_ID__').LocalCPResult|null} result * @param {import('../types.js?__BUILD_ID__').LocalCPTargetOption|null} option * @returns {string} * @private */ _buildLocalCPTriggerTitle(state, result, option) { const lines = []; const code = option?.activityCode || `ID ${state.targetId}`; const name = option?.taskName || ""; lines.push(name ? `${code} — ${name}` : code); if (result) { const anchorLabel = LOCALCP_ANCHOR_LABELS[result.anchorSource] || result.anchorSource; let anchorDate = ""; try { anchorDate = daysToIso(result.anchorDateDays); } catch { anchorDate = ""; } lines.push(`Якорь: ${anchorLabel}${anchorDate ? ` (${anchorDate})` : ""}`); if (result.calendarMode === "calendar") { lines.push("Резервы в календарных днях (календарь не найден)"); } const warnings = result.warnings || []; if (warnings.includes("target-not-found")) { lines.push("Цель не найдена в графике"); } if (warnings.includes("target-completed")) { lines.push("Целевая работа завершена"); } } if (state.deadlineDate) { lines.push(`Дедлайн: ${state.deadlineDate}`); } lines.push( `Пороги: критич. ≤ ${state.criticalThreshold}, близкие ≤ ${state.nearCriticalThreshold} раб. дн.`, ); return lines.join("\n"); } /** * Открывает/закрывает дропдаун селектора цели ЛКП. * @param {Element} trigger * @private */ _toggleLocalCPDropdown(trigger) { if (this._openFilterKey === LOCALCP_KEY) { this._closeDropdown(); return; } this._openLocalCPDropdown(trigger); } /** * Открывает дропдаун селектора цели ЛКП (COMP-033, спека F.2): * поиск, список работ/вех из LocalCPStateService, секция настроек. * @param {Element} trigger * @private */ _openLocalCPDropdown(trigger) { this._closeDropdown(); this._hideTooltip(); this._openFilterKey = LOCALCP_KEY; const dropdown = document.createElement("div"); dropdown.className = "header-filter-dropdown localcp-dropdown open"; dropdown.setAttribute("data-filter-key", LOCALCP_KEY); dropdown.setAttribute("role", "listbox"); dropdown.innerHTML = this._renderLocalCPDropdownContent(); dropdown.addEventListener("click", (e) => { const target = /** @type {HTMLElement} */ (e.target); const option = target.closest(".localcp-option"); if (option) { e.stopPropagation(); const targetId = parseInt( option.getAttribute("data-activity-id") || "0", 10, ); this._selectLocalCPTarget(targetId); return; } const resetBtn = target.closest("[data-localcp-reset]"); if (resetBtn) { e.stopPropagation(); const dbId = this._getLocalCPDatabaseId(); if (dbId) localCPStateService.clearTarget(dbId); return; } const closeBtn = target.closest(".header-filter-dropdown-close"); if (closeBtn) { e.stopPropagation(); this._closeDropdown(); } }); dropdown.addEventListener("input", (e) => { const target = /** @type {HTMLElement} */ (e.target); if (target.matches("[data-localcp-search]")) { this._refreshLocalCPList( /** @type {HTMLInputElement} */ (target).value, ); return; } if ( target.matches("[data-localcp-critical]") || target.matches("[data-localcp-near]") ) { this._applyLocalCPSettings(dropdown); } }); // Дедлайн — по change (input срабатывает на частичный ввод даты) dropdown.addEventListener("change", (e) => { const target = /** @type {HTMLElement} */ (e.target); if (target.matches("[data-localcp-deadline]")) { this._applyLocalCPSettings(dropdown); } }); // Клавиатурная навигация по списку: стрелки + Enter (спека H) dropdown.addEventListener("keydown", (e) => { if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Enter") { return; } const options = Array.from(dropdown.querySelectorAll(".localcp-option")); if (options.length === 0) return; const currentIdx = options.indexOf( /** @type {Element|null} */ (document.activeElement), ); if (e.key === "Enter") { if (currentIdx >= 0) { e.preventDefault(); /** @type {HTMLElement} */ (options[currentIdx]).click(); } return; } e.preventDefault(); const nextIdx = e.key === "ArrowDown" ? currentIdx < 0 ? 0 : Math.min(currentIdx + 1, options.length - 1) : currentIdx < 0 ? options.length - 1 : Math.max(currentIdx - 1, 0); /** @type {HTMLElement} */ (options[nextIdx]).focus(); }); document.body.appendChild(dropdown); this._positionDropdown(dropdown, trigger); this.querySelectorAll(".header-filter-trigger").forEach((btn) => { btn.setAttribute("aria-expanded", "false"); }); trigger.setAttribute("aria-expanded", "true"); const searchInput = dropdown.querySelector("[data-localcp-search]"); if (searchInput) /** @type {HTMLElement} */ (searchInput).focus(); this._attachLocalCPDescTooltips(dropdown); } /** * Тултипы строк списка целей ЛКП — на всех работах/вехах (не только при * обрезанном описании): код, полное описание и сроки (начало/окончание; * для вехи — одна дата). Стиль — общий `.header-filter-tooltip`. * Вызывается после каждого перерендера списка (открытие, поиск). * @param {ParentNode} root * @private */ _attachLocalCPDescTooltips(root) { root.querySelectorAll("[data-localcp-list] .localcp-option").forEach((optionEl) => { const el = /** @type {HTMLElement} */ (optionEl); el.addEventListener("mouseenter", () => { const code = el.querySelector(".header-filter-option-name")?.textContent || ""; const desc = el.querySelector(".header-filter-option-desc")?.getAttribute("data-full-desc") || ""; const start = this._formatLocalCPDate(el.dataset.start || ""); const finish = this._formatLocalCPDate(el.dataset.finish || ""); this._showLocalCPOptionTooltip(el, code, desc, start, finish); }); el.addEventListener("mouseleave", () => this._hideTooltip()); }); } /** * Тултип строки селектора цели ЛКП: заголовок — код работы, тело — * полное описание и сроки. Веха (start === finish) — одна дата. * @param {HTMLElement} anchor * @param {string} code * @param {string} desc * @param {string} start - отформатированная дата либо '' * @param {string} finish - отформатированная дата либо '' * @private */ _showLocalCPOptionTooltip(anchor, code, desc, start, finish) { this._hideTooltip(); let datesHtml = ""; if (start && finish && start === finish) { datesHtml = `<div class="localcp-tooltip-dates">Дата: ${this._escapeHtml(start)}</div>`; } else if (start || finish) { datesHtml = `<div class="localcp-tooltip-dates">Начало: ${this._escapeHtml(start || "—")}</div>` + `<div class="localcp-tooltip-dates">Окончание: ${this._escapeHtml(finish || "—")}</div>`; } const tooltip = document.createElement("div"); tooltip.className = "header-filter-tooltip"; tooltip.setAttribute("role", "tooltip"); tooltip.innerHTML = ` <div class="header-filter-tooltip-header">${this._escapeHtml(code || "Работа")}</div> <div style="font-size: var(--font-size-xs, 10px); color: var(--text-primary); line-height: 1.4; word-break: break-word;">${this._escapeHtml(desc)}</div> ${datesHtml} `; document.body.appendChild(tooltip); this._positionTooltip(tooltip, anchor); } /** * YYYY-MM-DD → DD.MM.YYYY для тултипа селектора ЛКП; прочие форматы — as is. * @param {string} iso * @returns {string} * @private */ _formatLocalCPDate(iso) { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso.trim()); return m ? `${m[3]}.${m[2]}.${m[1]}` : iso.trim(); } /** * Выбор цели ЛКП из списка: setTarget БЕЗ закрытия дропдауна — пользователь * может сразу выбрать другую цель; подсветка обновляется через * local-cp-change → _refreshLocalCPDropdownSelection (спека F.2). * Дедлайн сохраняется из текущего состояния/кэша настроек. * @param {number} targetId * @private */ _selectLocalCPTarget(targetId) { const dbId = this._getLocalCPDatabaseId(); if (!dbId || !targetId) return; const state = localCPStateService.getState(dbId); localCPStateService.setTarget({ databaseId: dbId, targetId, deadlineDate: state?.deadlineDate || this._localCPSettingsCache.deadlineDate || undefined, }); } /** * Рендерит содержимое дропдауна селектора цели ЛКП: * заголовок, поиск, список, секция настроек (спека F.2). * @returns {string} * @private */ _renderLocalCPDropdownContent() { const dbId = this._getLocalCPDatabaseId(); const state = dbId ? localCPStateService.getState(dbId) : null; const deadline = state?.deadlineDate ?? this._localCPSettingsCache.deadlineDate; const critical = state?.criticalThreshold ?? this._localCPSettingsCache.criticalThreshold; const near = state?.nearCriticalThreshold ?? this._localCPSettingsCache.nearCriticalThreshold; // Авто-дата из графика (финишное ограничение цели) — показывается // в поле дедлайна серым, пока ручной дедлайн не задан. const autoDeadline = deadline ? "" : this._getLocalCPAutoDeadline(dbId); const deadlineValue = deadline || autoDeadline; const deadlineAutoClass = !deadline && autoDeadline ? "localcp-deadline-auto" : ""; const deadlineTitle = deadline ? "Ручной дедлайн цели (очистите поле, чтобы вернуться к дате ограничения из графика)" : autoDeadline ? "Дата ограничения из графика. Введите дату, чтобы задать ручной дедлайн" : "Ручной дедлайн цели"; return ` <div class="header-filter-dropdown-header"> <span class="header-filter-dropdown-title">Цель критического пути</span> <button class="header-filter-dropdown-close" aria-label="Закрыть">${X}</button> </div> <div class="header-filter-search"> <input type="text" class="header-filter-search-input" data-localcp-search placeholder="Поиск по коду или названию…" aria-label="Поиск работы или вехи"> </div> <div class="header-filter-dropdown-list" data-localcp-list> ${this._renderLocalCPOptions("")} </div> <div class="localcp-settings"> <div class="localcp-settings-row"> <label class="localcp-settings-field"> <span>Дедлайн</span> <input type="date" data-localcp-deadline class="${deadlineAutoClass}" data-auto-date="${this._escapeHtml(autoDeadline)}" value="${this._escapeHtml(deadlineValue)}" title="${deadlineTitle}" aria-label="Дедлайн цели (серым — дата из графика)"> </label> <label class="localcp-settings-field"> <span>Порог</span> <input type="number" data-localcp-critical value="${critical}" step="1" aria-label="Порог критичности, рабочие дни"> </label> <label class="localcp-settings-field"> <span>Близкие</span> <input type="number" data-localcp-near value="${near}" step="1" aria-label="Порог близкой зоны, рабочие дни"> </label> </div> <div class="localcp-settings-row"> <button class="header-filter-action-btn" data-localcp-reset>Сбросить цель</button> </div> </div> `; } /** * Рендерит элементы списка целей ЛКП: без запроса — только вехи, * с запросом — подстрока по коду+имени (case-insensitive). * Рендер-кап: первые 100 совпадений + строка «… ещё N» (спека F.2). * @param {string} query * @returns {string} * @private */ _renderLocalCPOptions(query) { const dbId = this._getLocalCPDatabaseId(); const state = dbId ? localCPStateService.getState(dbId) : null; const options = dbId ? localCPStateService.getTargetOptions(dbId) : []; if (!dbId || options.length === 0) { return `<div class="header-filter-empty">Нет работ для выбора</div>`; } const q = query.trim().toLowerCase(); const filtered = q ? options.filter((o) => `${o.activityCode} ${o.taskName}`.toLowerCase().includes(q), ) : options.filter((o) => o.isMilestone || o.isKeyMilestone); if (filtered.length === 0) { return `<div class="header-filter-empty">Ничего не найдено</div>`; } // Ключевые вехи («Техзапуск»/«ВехаСМР» из «ЦПС_Отчетность») — наверху // списка, отделены отсечкой и визуально выделены. Кап применяется ПОСЛЕ // разбиения, чтобы ключевые вехи не отрезались лимитом рендера. const keyFiltered = filtered.filter((o) => o.isKeyMilestone); const restFiltered = filtered.filter((o) => !o.isKeyMilestone); const keyShown = keyFiltered.slice(0, LOCALCP_RENDER_CAP); const restShown = restFiltered.slice(0, LOCALCP_RENDER_CAP - keyShown.length); const rest = filtered.length - keyShown.length - restShown.length; const renderItem = (/** @type {import('../types.js').LocalCPTargetOption} */ o) => { const selected = state != null && o.activityId === state.targetId; const badge = o.isMilestone ? `<span class="localcp-target-badge" title="Веха">${Flag}</span>` : `<span class="localcp-target-badge" aria-hidden="true"></span>`; const classes = [ "header-filter-option", "localcp-option", selected ? "selected" : "", o.isKeyMilestone ? "localcp-option-key" : "", ] .filter(Boolean) .join(" "); return ` <div class="${classes}" role="option" aria-selected="${selected ? "true" : "false"}" tabindex="0" data-activity-id="${o.activityId}" data-start="${this._escapeHtml(o.startDate || "")}" data-finish="${this._escapeHtml(o.endDate || "")}"> ${badge} <span class="header-filter-option-name">${this._escapeHtml(o.activityCode)}</span> <span class="header-filter-option-desc" data-full-desc="${this._escapeHtml(o.taskName)}">${this._escapeHtml(o.taskName)}</span> </div> `; }; let itemsHtml = keyShown.map(renderItem).join(""); if (keyShown.length > 0 && restShown.length > 0) { itemsHtml += `<div class="localcp-divider" role="separator" aria-hidden="true"></div>`; } itemsHtml += restShown.map(renderItem).join(""); const moreHtml = rest > 0 ? `<div class="header-filter-empty">… ещё ${rest}</div>` : ""; return itemsHtml + moreHtml; } /** * Перерисовывает список целей ЛКП по поисковому запросу. * @param {string} query * @private */ _refreshLocalCPList(query) { const list = document.querySelector("[data-localcp-list]"); if (!list) return; list.innerHTML = this._renderLocalCPOptions(query || ""); this._attachLocalCPDescTooltips(list); } /** * Обновляет подсветку выбранной цели в открытом дропдауне ЛКП * (после setTarget/clearTarget без пересоздания дропдауна). * @private */ _refreshLocalCPDropdownSelection() { if (this._openFilterKey !== LOCALCP_KEY) return; const list = document.querySelector("[data-localcp-list]"); if (!list) return; const dbId = this._getLocalCPDatabaseId(); const state = dbId ? localCPStateService.getState(dbId) : null; list.querySelectorAll(".localcp-option").forEach((el) => { const id = parseInt(el.getAttribute("data-activity-id") || "0", 10); const selected = state != null && id === state.targetId; el.classList.toggle("selected", selected); el.setAttribute("aria-selected", selected ? "true" : "false"); }); } /** * Авто-дата дедлайна из графика: дата финишного ограничения цели * (anchorSource 'constraint'), если ручной дедлайн не задан. * Плановый финиш (anchorSource 'planned') в поле НЕ подставляется. * @param {string|null} dbId * @returns {string} YYYY-MM-DD либо '' * @private */ _getLocalCPAutoDeadline(dbId) { if (!dbId) return ""; const state = localCPStateService.getState(dbId); if (state?.deadlineDate) return ""; const result = localCPStateService.getResult(dbId); if (!result || result.anchorSource !== "constraint") return ""; if (!Number.isFinite(result.anchorDateDays)) return ""; try { return daysToIso(result.anchorDateDays); } catch { return ""; } } /** * Обновляет поле дедлайна в открытом дропдауне после local-cp-change: * ручной дедлайн — обычным цветом; без него — дата якоря из графика * серым (класс localcp-deadline-auto). Поле не трогаем, пока пользователь * его редактирует (фокус в инпуте). * @private */ _updateLocalCPDeadlineField() { if (this._openFilterKey !== LOCALCP_KEY) return; const input = /** @type {HTMLInputElement|null} */ ( document.querySelector("[data-localcp-deadline]") ); if (!input) return; const dbId = this._getLocalCPDatabaseId(); const state = dbId ? localCPStateService.getState(dbId) : null; const manual = state?.deadlineDate || ""; const auto = this._getLocalCPAutoDeadline(dbId); input.dataset.autoDate = auto; // Класс обновляем всегда (ручная дата должна «почернеть» сразу после // выбора, пока фокус ещё в инпуте); значение при фокусе не перезаписываем. input.classList.toggle("localcp-deadline-auto", !manual && !!auto); if (document.activeElement === input) { // Сброс ручной даты в сфокусированном поле — сразу возвращаем // дату ограничения из графика (иначе остаётся placeholder «дд.мм.гггг»). if (!manual && input.value === "" && auto) input.value = auto; return; } input.value = manual || auto; } /** * Применяет настройки анализа ЛКП из инпутов дропдауна на лету через * LocalCPStateService.setOptions (спека F.2). Пустой дедлайн — сброс * (возврат к авто-дате из графика); значение, равное авто-дате, ручным * дедлайном НЕ становится (undefined-семантика сервиса). * NaN-пороги не передаются. * @param {HTMLElement} dropdown * @private */ _applyLocalCPSettings(dropdown) { const dbId = this._getLocalCPDatabaseId(); if (!dbId) return; const deadlineInput = /** @type {HTMLInputElement|null} */ ( dropdown.querySelector("[data-localcp-deadline]") ); const criticalInput = /** @type {HTMLInputElement|null} */ ( dropdown.querySelector("[data-localcp-critical]") ); const nearInput = /** @type {HTMLInputElement|null} */ ( dropdown.querySelector("[data-localcp-near]") ); if (!deadlineInput || !criticalInput || !nearInput) return; const autoDate = deadlineInput.dataset.autoDate || ""; /** @type {string|undefined} undefined — не менять (в поле авто-дата из графика) */ let deadlineDate; if (autoDate && deadlineInput.value === autoDate) { deadlineDate = undefined; } else { deadlineDate = deadlineInput.value; // '' — сброс ручного дедлайна } const criticalThreshold = Number.isFinite(criticalInput.valueAsNumber) ? criticalInput.valueAsNumber : undefined; const nearCriticalThreshold = Number.isFinite(nearInput.valueAsNumber) ? nearInput.valueAsNumber : undefined; this._localCPSettingsCache = { deadlineDate: deadlineDate !== undefined ? deadlineDate : this._localCPSettingsCache.deadlineDate, criticalThreshold: criticalThreshold ?? this._localCPSettingsCache.criticalThreshold, nearCriticalThreshold: nearCriticalThreshold ?? this._localCPSettingsCache.nearCriticalThreshold, }; localCPStateService.setOptions({ databaseId: dbId, deadlineDate, criticalThreshold, nearCriticalThreshold, }); } /** * @param {string} text * @returns {string} * @private */ _escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } } customElements.define("header-filter-bar", HeaderFilterBar); export { HeaderFilterBar };