/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
js/components/ResourceVolumeTable.js
781 строка
21 KB
Starolat Sergei
fix: пересчёт итогов групп при фильтрах заголовков в виджете объёмов, нормализация «-0» в дельтах
13 июл 2026, 14:13
13 июл 2026, 14:13
6bd8f71
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview ResourceVolumeTable - Таблица ресурсных объёмов * @version 0.1.0 * @element resource-volume-table * * Тонкая склейка над <data-grid>, <data-grid-toolbar> и ResourceVolumeDataSource. * Отображает физические объёмы материалов с группировкой по типу ресурса / ресурсу / работе. * * @fires table-row-click - При клике по строке * @fires table-row-dblclick - При двойном клике по строке * @fires table-selection-change - При изменении выбора * @fires table-sort-change - При изменении сортировки * @fires table-filter-change - При изменении автофильтра * @fires table-columns-change - При изменении конфигурации столбцов * @fires table-wbs-expand - При разворачивании/сворачивании строки * @fires table-data-loaded - После загрузки данных * @fires table-copied - После копирования в буфер обмена * @fires table-copy-error - При ошибке копирования * @fires table-schedule-mode-change - При переключении ТГ/ЦП * @fires table-levels-change - При изменении уровня развёрнутости */ import DataGrid from "./DataGrid.js"; import DataGridToolbar from "./DataGridToolbar.js"; import ResourceVolumeDataSource from "../services/ResourceVolumeDataSource.js"; import filterState from "../services/FilterStateManager.js"; /** * @typedef {import('../types.js').TableColumn} TableColumn * @typedef {import('../types.js').TableRow} TableRow */ class ResourceVolumeTable extends HTMLElement { static get observedAttributes() { return [ "database-id", "row-height", "schedule-mode", "show-current", "show-target", "group-by", "embedded", ]; } constructor() { super(); this.attachShadow({ mode: "open" }); /** @type {DataGrid|null} */ this._dataGrid = null; /** @type {DataGridToolbar|null} */ this._toolbar = null; /** @type {ResourceVolumeDataSource|null} */ this._dataSource = null; /** @type {string|null} */ this.currentDatabaseId = null; /** @type {IntersectionObserver|null} */ this._visibilityObserver = null; /** @type {string|null} */ this._pendingDatabaseId = null; // Bound handlers this._boundHandleGridRowClick = this._handleGridRowClick.bind(this); this._boundHandleGridRowDblClick = this._handleGridRowDblClick.bind(this); this._boundHandleGridSelectionChange = this._handleGridSelectionChange.bind(this); this._boundHandleGridSortChange = this._handleGridSortChange.bind(this); this._boundHandleGridFilterChange = this._handleGridFilterChange.bind(this); this._boundHandleGridColumnsChange = this._handleGridColumnsChange.bind(this); this._boundHandleGridRowExpand = this._handleGridRowExpand.bind(this); this._boundHandleGridCopied = this._handleGridCopied.bind(this); this._boundHandleGridCopyError = this._handleGridCopyError.bind(this); this._boundHandleDataSourceDataLoaded = this._handleDataSourceDataLoaded.bind(this); this._boundHandleDataSourceScheduleModeChange = this._handleDataSourceScheduleModeChange.bind(this); this._boundHandleDataSourceLevelsChange = this._handleDataSourceLevelsChange.bind(this); this._boundHandleToolbarAction = this._handleToolbarAction.bind(this); this._boundHandleVisibilityChange = this._handleVisibilityChange.bind(this); } // ==================== Public getters / state ==================== get showCurrent() { return this._dataSource?.showCurrent ?? true; } get showTarget() { return this._dataSource?.showTarget ?? false; } set showCurrent(value) { this._dataSource?.setShowCurrent(!!value); } set showTarget(value) { this._dataSource?.setShowTarget(!!value); } get selectedRowIds() { return this._dataGrid?.selectedRowIds ?? new Set(); } get columns() { return this._dataGrid?.getColumns() ?? []; } get allRows() { return this._dataGrid?.getRows() ?? []; } get visibleRows() { return this._dataGrid?.getVisibleRows() ?? []; } get activeFilters() { return this._dataGrid?.activeFilters ?? []; } set activeFilters(value) { this._dataGrid?.applyFilters(value); } get isLoading() { return this._dataSource?.isLoading() ?? false; } // ==================== Lifecycle ==================== connectedCallback() { this._readAttributes(); this.render(); this._initializeElements(); this._createDataSource(); this._setupEventListeners(); this._initVisibilityObserver(); this._loadInitialData(); } disconnectedCallback() { this._cleanup(); } /** * @param {string} name * @param {string|null} oldValue * @param {string|null} newValue */ attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; this._applyAttribute(name, newValue); } _readAttributes() { const dbId = this.getAttribute("database-id"); if (dbId) this.currentDatabaseId = dbId; } /** * @param {string} name * @param {string|null} newValue * @private */ _applyAttribute(name, newValue) { switch (name) { case "database-id": this.currentDatabaseId = newValue; this._loadInitialData(newValue); break; case "row-height": if (this._dataGrid) { this._dataGrid.setAttribute("row-height", newValue || "20"); } break; case "schedule-mode": { const sm = newValue || "both"; const showCurrent = sm === "current" || sm === "both"; const showTarget = sm === "target" || sm === "both"; if (this._dataSource) { this._dataSource.setShowCurrent(showCurrent); this._dataSource.setShowTarget(showTarget); } break; } case "show-current": this._dataSource?.setShowCurrent(newValue !== "false"); break; case "show-target": this._dataSource?.setShowTarget(newValue !== "false"); break; case "group-by": if (newValue) { this._dataSource?.setGroupBy(newValue); } break; case "embedded": if (this.isConnected) { this._cleanup(); this.render(); this._initializeElements(); this._createDataSource(); this._setupEventListeners(); this._loadInitialData(); } break; } } // ==================== Rendering ==================== render() { const isEmbedded = this.getAttribute("embedded") !== null; const rowHeight = this.getAttribute("row-height") || "20"; this.shadowRoot.innerHTML = ` <style>${this._getStyles()}</style> <div class="resource-volume-table ${isEmbedded ? "embedded" : ""}" role="grid" aria-label="Таблица ресурсных объёмов"> ${isEmbedded ? "" : `<data-grid-toolbar></data-grid-toolbar>`} <data-grid embedded row-height="${rowHeight}"></data-grid> </div> `; } /** * @returns {string} * @private */ _getStyles() { return ` :host { display: block; width: 100%; height: 100%; font-family: var(--font-primary, Inter, sans-serif); } .resource-volume-table { display: flex; flex-direction: column; height: 100%; background: var(--color-bg-default, #fff); border: 1px solid var(--color-bg-border, #e5e5e5); border-radius: var(--radius-s, 4px); overflow: hidden; } .resource-volume-table.embedded { border-radius: 0; border: none; } data-grid { flex: 1; min-height: 0; } data-grid-toolbar { flex-shrink: 0; } `; } /** * @private */ _initializeElements() { this._toolbar = this.shadowRoot.querySelector("data-grid-toolbar"); this._dataGrid = this.shadowRoot.querySelector("data-grid"); if (this._dataGrid) { this._dataGrid.setAttribute("row-height", this.getAttribute("row-height") || "20"); } } // ==================== DataSource wiring ==================== /** * @private */ _createDataSource() { if (this._dataSource) return; const fsState = filterState.getState(); this._dataSource = new ResourceVolumeDataSource({ databaseId: null, filterState, showCurrent: fsState.showCurrent, showTarget: fsState.showTarget, groupBy: this.getAttribute("group-by") || "resource-type", }); this._dataSource.attach(); this._dataSource.addEventListener("data-loaded", this._boundHandleDataSourceDataLoaded); this._dataSource.addEventListener("schedule-mode-change", this._boundHandleDataSourceScheduleModeChange); this._dataSource.addEventListener("levels-change", this._boundHandleDataSourceLevelsChange); if (this._dataGrid) { this._dataGrid.dataSource = this._dataSource; } if (this._toolbar) { this._toolbar.dataSource = this._dataSource; } } // ==================== Event listeners ==================== /** * @private */ _setupEventListeners() { if (this._dataGrid) { this._dataGrid.addEventListener("grid-row-click", this._boundHandleGridRowClick); this._dataGrid.addEventListener("grid-row-dblclick", this._boundHandleGridRowDblClick); this._dataGrid.addEventListener("grid-selection-change", this._boundHandleGridSelectionChange); this._dataGrid.addEventListener("grid-sort-change", this._boundHandleGridSortChange); this._dataGrid.addEventListener("grid-filter-change", this._boundHandleGridFilterChange); this._dataGrid.addEventListener("grid-columns-change", this._boundHandleGridColumnsChange); this._dataGrid.addEventListener("grid-row-expand", this._boundHandleGridRowExpand); this._dataGrid.addEventListener("grid-copied", this._boundHandleGridCopied); this._dataGrid.addEventListener("grid-copy-error", this._boundHandleGridCopyError); } if (this._toolbar) { this._toolbar.addEventListener("toolbar-action", this._boundHandleToolbarAction); } } /** * @private */ _cleanup() { if (this._dataGrid) { this._dataGrid.removeEventListener("grid-row-click", this._boundHandleGridRowClick); this._dataGrid.removeEventListener("grid-row-dblclick", this._boundHandleGridRowDblClick); this._dataGrid.removeEventListener("grid-selection-change", this._boundHandleGridSelectionChange); this._dataGrid.removeEventListener("grid-sort-change", this._boundHandleGridSortChange); this._dataGrid.removeEventListener("grid-filter-change", this._boundHandleGridFilterChange); this._dataGrid.removeEventListener("grid-columns-change", this._boundHandleGridColumnsChange); this._dataGrid.removeEventListener("grid-row-expand", this._boundHandleGridRowExpand); this._dataGrid.removeEventListener("grid-copied", this._boundHandleGridCopied); this._dataGrid.removeEventListener("grid-copy-error", this._boundHandleGridCopyError); this._dataGrid.dispose(); this._dataGrid = null; } if (this._toolbar) { this._toolbar.removeEventListener("toolbar-action", this._boundHandleToolbarAction); this._toolbar = null; } if (this._dataSource) { this._dataSource.removeEventListener("data-loaded", this._boundHandleDataSourceDataLoaded); this._dataSource.removeEventListener("schedule-mode-change", this._boundHandleDataSourceScheduleModeChange); this._dataSource.removeEventListener("levels-change", this._boundHandleDataSourceLevelsChange); this._dataSource.detach(); this._dataSource = null; } if (this._visibilityObserver) { this._visibilityObserver.disconnect(); this._visibilityObserver = null; } this._pendingDatabaseId = null; } // ==================== Lazy loading ==================== /** * @private */ _initVisibilityObserver() { if (this._visibilityObserver) return; this._visibilityObserver = new IntersectionObserver(this._boundHandleVisibilityChange, { threshold: 0 }); this._visibilityObserver.observe(this); } /** * @param {IntersectionObserverEntry[]} entries * @private */ _handleVisibilityChange(entries) { const isVisible = entries.some((entry) => entry.isIntersecting); if (isVisible && this._pendingDatabaseId) { const dbId = this._pendingDatabaseId; this._pendingDatabaseId = null; this._loadInitialData(dbId); } } /** * @returns {boolean} * @private */ _isElementVisible() { return this.isConnected && this.offsetParent !== null; } /** * @param {string} [dbId] * @private */ _loadInitialData(dbId) { const id = dbId || this.currentDatabaseId; if (!id) return; if (!this._isElementVisible()) { this._pendingDatabaseId = id; return; } this._pendingDatabaseId = null; if (this._dataSource && this._dataSource.databaseId !== id) { this._dataSource.setDatabaseId(id); } } /** * Вызывается родителем, когда таблица становится видимой. */ _onBecameVisible() { if (this._pendingDatabaseId) { const dbId = this._pendingDatabaseId; this._pendingDatabaseId = null; this._loadInitialData(dbId); } if (this._dataSource && filterState) { this._dataSource.applyGroupingsFromState(filterState.getState().groupings || []); } } // ==================== Public API ==================== /** * Загрузить данные по ID базы. * @param {string} databaseId * @returns {Promise<void>} */ loadData(databaseId) { this.setAttribute("database-id", databaseId); return Promise.resolve(); } /** * Перезагрузить данные таблицы. * @returns {Promise<void>} */ refresh() { return this._dataSource?.refresh() ?? Promise.resolve(); } /** * @param {boolean} visible */ setShowCurrent(visible) { this._dataSource?.setShowCurrent(visible); } /** * @param {boolean} visible */ setShowTarget(visible) { this._dataSource?.setShowTarget(visible); } /** * @param {'current'|'target'|'both'|'none'} mode */ setScheduleMode(mode) { this.setAttribute("schedule-mode", mode); } /** * @returns {'current'|'target'|'both'|'none'} */ getScheduleMode() { const showCurrent = this.showCurrent; const showTarget = this.showTarget; if (showCurrent && showTarget) return "both"; if (showCurrent) return "current"; if (showTarget) return "target"; return "none"; } /** * @param {'resource-type'|'resource'|'activity'} groupBy */ setGroupBy(groupBy) { this.setAttribute("group-by", groupBy); } /** * @param {boolean} embedded */ setEmbedded(embedded) { if (embedded) { this.setAttribute("embedded", ""); } else { this.removeAttribute("embedded"); } } /** * @param {TableColumn[]} columns */ setColumns(columns) { this._dataGrid?.setColumns(columns); } /** * @returns {TableColumn[]} */ getColumns() { return this._dataGrid?.getColumns() ?? []; } /** * @param {import('../types.js').AutoFilter[]} filters */ applyFilters(filters) { this._dataGrid?.applyFilters(filters); } /** * @param {string} [columnId] */ clearFilters(columnId) { this._dataGrid?.clearFilters(columnId); } /** * @param {string} columnId * @param {'asc'|'desc'} [direction='asc'] */ sortBy(columnId, direction = "asc") { this._dataGrid?.sortBy(columnId, direction); } /** * @param {string} rowId */ scrollToRow(rowId) { this._dataGrid?.scrollToRow(rowId); } /** * Развернуть все строки. */ expandAll() { this._dataSource?.expandAll(); } /** * Свернуть все строки. */ collapseAll() { this._dataSource?.collapseAll(); } /** * Свернуть дерево до указанного уровня. * @param {number} level */ collapseToLevel(level) { this._dataSource?.collapseToLevel(level); } /** * @returns {Promise<void>} */ async copyToClipboard(options = {}) { await this._dataGrid?.copyToClipboard(options); } /** * @param {string} [filename] */ exportToCsv(filename = "resource_volumes.csv") { this._dataGrid?.exportToCsv(filename); } /** * Открыть настройки столбцов. */ openSettings() { this._dataGrid?.openSettings(); } /** * Освободить ресурсы. */ dispose() { this._cleanup(); } // ==================== Event proxy: DataGrid -> table-* ==================== /** * @param {CustomEvent} event * @private */ _handleGridRowClick(event) { this.dispatchEvent( new CustomEvent("table-row-click", { detail: event.detail, bubbles: true, composed: true, }), ); } /** * @param {CustomEvent} event * @private */ _handleGridRowDblClick(event) { this.dispatchEvent( new CustomEvent("table-row-dblclick", { detail: event.detail, bubbles: true, composed: true, }), ); } /** * @param {CustomEvent} event * @private */ _handleGridSelectionChange(event) { this.dispatchEvent( new CustomEvent("table-selection-change", { detail: event.detail, bubbles: true, composed: true, }), ); } /** * @param {CustomEvent} event * @private */ _handleGridSortChange(event) { this.dispatchEvent( new CustomEvent("table-sort-change", { detail: event.detail, bubbles: true, composed: true, }), ); } /** * @param {CustomEvent} event * @private */ _handleGridFilterChange(event) { // Пересчёт итогов групп по видимым работам (фильтры заголовков) if (this._dataSource && typeof this._dataSource.setVisibleRowFilter === "function") { this._dataSource.setVisibleRowFilter(event.detail?.visibleLeafIds ?? null); } this.dispatchEvent( new CustomEvent("table-filter-change", { detail: event.detail, bubbles: true, composed: true, }), ); } /** * @param {CustomEvent} event * @private */ _handleGridColumnsChange(event) { this.dispatchEvent( new CustomEvent("table-columns-change", { detail: event.detail, bubbles: true, composed: true, }), ); } /** * @param {CustomEvent} event * @private */ _handleGridRowExpand(event) { this.dispatchEvent( new CustomEvent("table-wbs-expand", { detail: event.detail, bubbles: true, composed: true, }), ); } /** * @param {CustomEvent} event * @private */ _handleGridCopied(event) { this.dispatchEvent( new CustomEvent("table-copied", { detail: event.detail, bubbles: true, composed: true, }), ); } /** * @param {CustomEvent} event * @private */ _handleGridCopyError(event) { this.dispatchEvent( new CustomEvent("table-copy-error", { detail: event.detail, bubbles: true, composed: true, }), ); } // ==================== Event proxy: DataSource -> table-* ==================== /** * @param {CustomEvent} event * @private */ _handleDataSourceDataLoaded(event) { this.dispatchEvent( new CustomEvent("table-data-loaded", { detail: event.detail, bubbles: true, composed: true, }), ); } /** * @param {CustomEvent} event * @private */ _handleDataSourceScheduleModeChange(event) { this.dispatchEvent( new CustomEvent("table-schedule-mode-change", { detail: event.detail, bubbles: true, composed: true, }), ); } /** * @param {CustomEvent} event * @private */ _handleDataSourceLevelsChange(event) { this.dispatchEvent( new CustomEvent("table-levels-change", { detail: event.detail, bubbles: true, composed: true, }), ); } // ==================== Event proxy: Toolbar -> actions ==================== /** * @param {CustomEvent} event * @private */ _handleToolbarAction(event) { const { id } = event.detail || {}; if (id === "btn-copy") { this._dataGrid?.copyToClipboard(); } } } customElements.define("resource-volume-table", ResourceVolumeTable); export { ResourceVolumeTable }; export default ResourceVolumeTable;