/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/components/summary/VolumeTableWidget.js
516 строк
16 KB
Starolat Sergei
feat: модальное окно «Работы 4 уровня» для гамаков в Анализе отклонений
02 авг 2026, 12:48
02 авг 2026, 12:48
edc77bf
Код
Авторство
О чём код?
// @ts-check import { SummaryWidgetMixin } from "./SummaryWidgetMixin.js"; import DataGrid from "../DataGrid.js"; import DataGridToolbar from "../DataGridToolbar.js"; import ResourceVolumeDataSource from "../../services/ResourceVolumeDataSource.js"; import filterState from "../../services/FilterStateManager.js"; import { localCPStateService } from "../../services/LocalCPStateService.js"; import { Table } from "../ConstantinIcons.js"; /** * VolumeTableWidget — таблица физических объёмов (материалов) на базе DataGrid. * * Полноценная обёртка над DataGridToolbar + DataGrid + ResourceVolumeDataSource. * Поддерживает группировку по ресурсу, ТГ/ЦП, WBS/ресурсные фильтры, drill-down и levelContext. * * @tag volume-table-widget * @attr {string} title — Заголовок * @attr {boolean} drillable — Возможность drill-down */ export class VolumeTableWidget extends SummaryWidgetMixin(HTMLElement) { #title = ""; #lastClickedResourceId = null; /** @type {DataGrid|null} */ #dataGrid = null; /** @type {DataGridToolbar|null} */ #toolbar = null; /** @type {ResourceVolumeDataSource|null} */ #dataSource = null; constructor() { super(); this.attachShadow({ mode: "open" }); this._widgetType = "volumeTable"; this._boundHandleGridRowClick = this._handleGridRowClick.bind(this); this._boundHandleGridSelectionChange = this._handleGridSelectionChange.bind(this); this._boundHandleGridFilterChange = this._handleGridFilterChange.bind(this); this._boundHandleToolbarAction = this._handleToolbarAction.bind(this); this._boundHandleLocalCPChange = this._handleLocalCPChange.bind(this); } /** * @param {string|null} id */ set databaseId(id) { if (this._databaseId === id) return; this._cancelFiltersReadyWait(); this._databaseId = id; if (id != null) { this._autoDatabaseId = false; } this.#applyDataSourceConfig(); } /** * @param {import('../../types.js').LevelContext|null} ctx */ set levelContext(ctx) { const prev = JSON.stringify(this._levelContext); const next = JSON.stringify(ctx); if (prev === next) return; this._levelContext = ctx; this.#applyDataSourceConfig(); } /** * @param {import('../../types.js').Filter[]} filters */ set privateFilters(filters) { const next = JSON.stringify(filters || []); const prev = JSON.stringify(this._privateFilters || []); if (next === prev) return; this._privateFilters = filters || []; this.#applyDataSourceConfig(); } /** * Игнорировать глобальные фильтры (FilterStateManager): виджет показывает * данные только по levelContext/privateFilters. Для модалок вроде * HammockLevel4Modal. * @param {boolean} value */ set ignoreGlobalFilters(value) { const next = Boolean(value); if (this._ignoreGlobalFilters === next) return; this._ignoreGlobalFilters = next; if (this.#dataSource) { this.#dataSource.ignoreGlobalFilters = next; this.#dataSource.refresh(); } } /** * Компактные ширины колонок: таблица ужимается, чтобы помещаться * без горизонтального скролла (для модалок вроде HammockLevel4Modal). * @param {boolean} value */ set compactColumns(value) { const next = Boolean(value); if (this._compactColumns === next) return; this._compactColumns = next; if (this.#dataSource) { this.#dataSource.setCompactColumns(next); } } static get observedAttributes() { return ["title", "drillable"]; } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; if (name === "title") { this.#title = newValue || ""; if (this.isConnected) this.render(); } if (name === "drillable") { this.drillable = newValue !== null; if (this.isConnected) this.render(); } } connectedCallback() { this.render(); this.#initializeElements(); this.#createDataSource(); this.#setupEventListeners(); document.addEventListener("local-cp-change", this._boundHandleLocalCPChange); // COMP-033: первичное применение — виджет мог подключиться после расчёта { const dbId = this.#dataSource?.databaseId || this._databaseId; if (dbId) { this.#dataSource?.applyLocalCPResult( localCPStateService.getResult(dbId), ); } } super.connectedCallback?.(); } disconnectedCallback() { this.#cleanup(); super.disconnectedCallback?.(); } /** * @protected * @param {import('../../types.js').VolumeTableRow[]} data */ _onDataLoaded(data) { // Pull-модель отключена в пользу ResourceVolumeDataSource. Метод оставлен // для совместимости с демо и ручными тестами, которые прокидывают mock-данные. if (!data || !Array.isArray(data)) return; if (this.#dataSource) { this.#dataSource._materialItems = data; this.#dataSource._setColumns(this.#dataSource._getColumns()); this.#dataSource._setToolbarItems(this.#dataSource.getToolbarConfig()); this.#dataSource._rebuildHierarchy(); this.#dataSource._emitDataLoaded(); } } /** * @protected * @param {Error} error */ _onDataError(error) { console.error("[VolumeTableWidget] Pull-model error:", error); } _onLoadingStart() { this.#setLoading(true); } _onLoadingEnd() { this.#setLoading(false); } /** * @protected * @returns {Object|null} */ _buildDrillDownDetail() { const rid = this.#lastClickedResourceId; if (rid != null) { return { clickedField: "resourceId", clickedValue: rid, privateFilters: [ { field: "resourceId", condition: "equals", value: rid, enabled: true, }, ], }; } return { clickedField: "resourceId", clickedValue: null, privateFilters: [], }; } /** * @param {MouseEvent} e * @protected */ _shouldIgnoreClickForDrill(e) { // DataGrid сам эмитит grid-row-click; хостовый drill-down игнорирует клики внутри таблицы. if (e.target.closest("data-grid, [data-no-drill]")) return true; return super._shouldIgnoreClickForDrill ? super._shouldIgnoreClickForDrill(e) : false; } /** * @private */ #initializeElements() { this.#toolbar = this.shadowRoot?.querySelector("data-grid-toolbar"); this.#dataGrid = this.shadowRoot?.querySelector("data-grid"); if (this.#dataGrid) { this.#dataGrid.setAttribute("embedded", ""); this.#dataGrid.setAttribute("row-height", "20"); } } /** * @private */ #createDataSource() { if (this.#dataSource) return; const fsState = filterState.getState(); this.#dataSource = new ResourceVolumeDataSource({ databaseId: this._databaseId, filterState, showCurrent: fsState.showCurrent, showTarget: fsState.showTarget, groupBy: "resource", baseFilters: this._levelContext?.baseFilters || [], privateFilters: this._privateFilters || [], ignoreGlobalFilters: Boolean(this._ignoreGlobalFilters), compactColumns: Boolean(this._compactColumns), }); this.#dataSource.attach(); if (this.#dataGrid) { this.#dataGrid.dataSource = this.#dataSource; } if (this.#toolbar) { this.#toolbar.dataSource = this.#dataSource; } if (this._databaseId) { this.#dataSource.refresh(); } } /** * @private */ #setupEventListeners() { if (this.#dataGrid) { this.#dataGrid.addEventListener("grid-row-click", this._boundHandleGridRowClick); this.#dataGrid.addEventListener("grid-selection-change", this._boundHandleGridSelectionChange); this.#dataGrid.addEventListener("grid-filter-change", this._boundHandleGridFilterChange); } if (this.#toolbar) { this.#toolbar.addEventListener("toolbar-action", this._boundHandleToolbarAction); } } /** * @private */ #cleanup() { document.removeEventListener("local-cp-change", this._boundHandleLocalCPChange); if (this.#dataGrid) { this.#dataGrid.removeEventListener("grid-row-click", this._boundHandleGridRowClick); this.#dataGrid.removeEventListener("grid-selection-change", this._boundHandleGridSelectionChange); this.#dataGrid.removeEventListener("grid-filter-change", this._boundHandleGridFilterChange); this.#dataGrid.dispose(); this.#dataGrid = null; } if (this.#toolbar) { this.#toolbar.removeEventListener("toolbar-action", this._boundHandleToolbarAction); this.#toolbar = null; } if (this.#dataSource) { this.#dataSource.detach(); this.#dataSource = null; } } /** * @private */ #applyDataSourceConfig() { if (!this.#dataSource) return; this.#dataSource.setDatabaseId(this._databaseId); this.#dataSource.setBaseFilters(this._levelContext?.baseFilters || []); this.#dataSource.setPrivateFilters(this._privateFilters || []); } /** * @private */ #setLoading(loading) { const overlay = this.shadowRoot?.querySelector(".loading-overlay"); if (overlay) { overlay.style.display = loading ? "flex" : "none"; } } /** * @param {CustomEvent} event * @private */ _handleGridRowClick(event) { const data = event.detail?.data; const resourceId = data?.resourceId; if (!resourceId) return; this.#lastClickedResourceId = resourceId; this._dispatchDrillDown({ clickedField: "resourceId", clickedValue: resourceId, privateFilters: [ { field: "resourceId", condition: "equals", value: resourceId, enabled: true, }, ], }); } /** * Выделение строк: ретрансляция в table-selection-change с числовыми * activityIds (как в WbsActivitiesTable) — на это событие подписана * панель «Цепочка работ» (ActivityChainPanel). * @param {CustomEvent} event * @private */ _handleGridSelectionChange(event) { const selectedIds = event.detail?.selectedIds || []; const rows = this.#dataGrid?.getRows() || []; const selectedRows = selectedIds .map((id) => rows.find((r) => r.id === id)) .filter((r) => r && r.type === "activity"); const activityIds = selectedRows.map((r) => r.activityId); const scheduleType = selectedRows[0]?.data?.scheduleType; this.dispatchEvent( new CustomEvent("table-selection-change", { detail: { selectedIds, activityIds, scheduleType }, bubbles: true, composed: true, }), ); } /** * Фильтры заголовков: пересчёт итогов групп по видимым работам. * @param {CustomEvent} event * @private */ _handleGridFilterChange(event) { this.#dataSource?.setVisibleRowFilter(event.detail?.visibleLeafIds ?? null); } /** * Обработчик local-cp-change (COMP-033): инжект значений резерва * в листовые items datasource без перезагрузки данных. * @param {CustomEvent} event * @private */ _handleLocalCPChange(event) { const detail = event.detail || {}; const dbId = this.#dataSource?.databaseId || this._databaseId; if (dbId && detail.databaseId && detail.databaseId !== dbId) return; this.#dataSource?.applyLocalCPResult(detail.result ?? null); } /** * @param {CustomEvent} event * @private */ _handleToolbarAction(event) { const { id } = event.detail || {}; if (id === "btn-copy") { this.#dataGrid?.copyToClipboard(); } } render() { const titleText = this._escapeHtml(this.#title || "Объёмы"); this.shadowRoot.innerHTML = ` <style> :host { display: flex; flex-direction: column; flex: 1; min-width: 0; background: var(--color-bg-default, #fff); border: 1px solid var(--color-bg-border, #e5e5e5); border-radius: var(--radius-s, 4px); font-family: var(--font-primary, Inter, sans-serif); overflow: hidden; box-sizing: border-box; } :host([drillable]) { cursor: pointer; } .widget-header { display: flex; align-items: center; justify-content: space-between; min-height: 24px; padding: var(--space-2xs, 4px) var(--space-xs, 8px); background: var(--color-bg-default, #fff); border-bottom: 1px solid var(--color-bg-border, #e5e5e5); flex-shrink: 0; box-sizing: border-box; } .header-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; color: var(--color-typo-ghost, #999); } .header-icon svg { width: 16px; height: 16px; } .title { display: flex; align-items: center; gap: var(--space-2xs, 4px); font-size: var(--size-text-2xs, 10px); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-typo-primary, #333); } .table-wrapper { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; position: relative; } data-grid { flex: 1; min-height: 0; } data-grid-toolbar { flex-shrink: 0; } .loading-overlay { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; gap: var(--space-xs, 8px); background: var(--color-bg-default, rgba(255,255,255,0.85)); color: var(--color-typo-secondary, rgba(0,32,51,0.6)); font-size: var(--size-text-xs, 10px); z-index: 10; } .loading-spinner { width: 16px; height: 16px; border: 2px solid var(--color-bg-border, rgba(0,32,51,0.1)); border-top-color: var(--color-control-bg-primary, #0071b2); border-radius: 50%; animation: spinner-rotate 1s linear infinite; } @keyframes spinner-rotate { to { transform: rotate(360deg); } } .error-body { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--color-typo-alert); font-size: var(--size-text-xs, 10px); text-align: center; padding: var(--space-xs, 8px); } </style> <div class="widget-header"><span class="title"><span class="header-icon">${Table}</span>${titleText}</span></div> <div class="table-wrapper"> <data-grid-toolbar align="right"></data-grid-toolbar> <data-grid embedded row-height="20"></data-grid> <div class="loading-overlay"> <div class="loading-spinner"></div> <span class="loading-text">Загрузка...</span> </div> </div> `; this.#dataGrid = null; this.#toolbar = null; } /** * @private */ #renderError() { const wrapper = this.shadowRoot?.querySelector(".table-wrapper"); if (!wrapper) return; wrapper.innerHTML = ` <div class="error-body">Ошибка загрузки</div> `; } /** * @param {string} text * @returns {string} */ _escapeHtml(text) { if (!text) return ""; return String(text) .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """); } } customElements.define("volume-table-widget", VolumeTableWidget);