/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/components/MaterialVolumePanel.js
1 018 строк
38 KB
Starolat Sergei
feat: добавлено версионирование, cache busting и автоматизация CHANGELOG
03 июл 2026, 13:21
03 июл 2026, 13:21
707cbcd
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview MaterialVolumePanel — Standalone панель физических объёмов (COMP-030) * @version 1.0.0 * * Standalone компонент для DockableLayoutManager. * - Режимы: По работам (expandable) / По материалам (flat aggregated) * - Локальные переключатели ТГ/ЦП * - Экспорт CSV * - Слушает filter-state-change, database-ready * * @see specs/material-volume-refactoring-plan.md §5 */ import dataService from '../services/DataService.js'; import filterState from '../services/FilterStateManager.js'; import { Draggable, ArrowDown, Layout, Download, Package, Table as TableIcon, Loader, AlertTriangle, ChevronRight, ChevronDown } from './ConstantinIcons.js'; /** * @typedef {import('../types.js').MaterialVolumeItem} MaterialVolumeItem */ // ============================================================================ // Constants // ============================================================================ const GROUP_BY_ACTIVITY = 'activity'; const GROUP_BY_MATERIAL = 'material'; const VIRTUAL_SCROLL_THRESHOLD = 1000; const ROW_HEIGHT = 20; const VIRTUAL_BUFFER = 10; // ============================================================================ // Component // ============================================================================ class MaterialVolumePanel extends HTMLElement { constructor() { super(); /** @type {string|null} */ this._databaseId = null; /** @type {'activity'|'material'} */ this._groupBy = GROUP_BY_ACTIVITY; /** @type {Set<'current'|'target'>} */ this._scheduleTypes = new Set(['current']); /** @type {MaterialVolumeItem[]} */ this._items = []; /** @type {any[]} */ this._rows = []; /** @type {boolean} */ this._isLoading = false; /** @type {string|null} */ this._error = null; /** @type {Set<number>} */ this._expandedActivities = new Set(); /** @type {string|null} */ this._currentDbId = null; /** @type {boolean} */ this._virtualScrollEnabled = false; // Bound handlers this._onDatabaseReady = this._onDatabaseReady.bind(this); this._onDatabaseRemoved = this._onDatabaseRemoved.bind(this); this._onFilterStateChange = this._onFilterStateChange.bind(this); this._onToolbarClick = this._onToolbarClick.bind(this); this._onTableClick = this._onTableClick.bind(this); this._onExportClick = this._onExportClick.bind(this); this._disableHostDrag = this._disableHostDrag.bind(this); this._onTableScroll = this._onTableScroll.bind(this); } // ==================== Lifecycle ==================== connectedCallback() { const wasRendered = this.querySelector('.mvp-panel-body') !== null; if (!wasRendered) { this._render(); } document.addEventListener('database-ready', this._onDatabaseReady); document.addEventListener('database-removed', this._onDatabaseRemoved); document.addEventListener('filter-state-change', this._onFilterStateChange); this.addEventListener('mousedown', this._disableHostDrag); this.addEventListener('pointerdown', this._disableHostDrag); // If DB already active const activeDb = dataService.getActiveDatabaseId(); if (activeDb) { this._currentDbId = activeDb; } } disconnectedCallback() { document.removeEventListener('database-ready', this._onDatabaseReady); document.removeEventListener('database-removed', this._onDatabaseRemoved); document.removeEventListener('filter-state-change', this._onFilterStateChange); this.removeEventListener('mousedown', this._disableHostDrag); this.removeEventListener('pointerdown', this._disableHostDrag); const wrap = this.querySelector('.mvp-table-wrap'); if (wrap) { wrap.removeEventListener('scroll', this._onTableScroll); } } // ==================== Attributes ==================== static get observedAttributes() { return ['database-id']; } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; if (name === 'database-id') { this._databaseId = newValue; this._currentDbId = newValue || dataService.getActiveDatabaseId(); this._loadData(); } } // ==================== Public API ==================== get isLoading() { return this._isLoading; } get groupBy() { return this._groupBy; } set groupBy(value) { const valid = value === GROUP_BY_MATERIAL ? GROUP_BY_MATERIAL : GROUP_BY_ACTIVITY; if (this._groupBy !== valid) { this._groupBy = valid; this._updateToolbarUI(); this._processRows(); this._renderTable(); } } async refresh() { await this._loadData(); } clear() { this._items = []; this._rows = []; this._error = null; this._expandedActivities.clear(); this._showWaitingState(); } expand() { this.classList.remove('collapsed'); const arrow = this.querySelector('.arrow-icon'); if (arrow) arrow.classList.remove('collapsed'); } collapse() { this.classList.add('collapsed'); const arrow = this.querySelector('.arrow-icon'); if (arrow) arrow.classList.add('collapsed'); } // ==================== Event Handlers ==================== _onDatabaseReady(e) { this._currentDbId = e.detail?.databaseId || dataService.getActiveDatabaseId(); this._loadData(); } _onDatabaseRemoved() { this._currentDbId = null; this.clear(); } _onFilterStateChange(e) { // Reload on any filter change relevant to material volumes const relevantActions = [ 'code-filter-set', 'code-filter-remove', 'code-filter-toggle', 'code-filter-clear', 'code-filter-replace', 'wbs-filter-change', 'week-filter-change', 'resource-filter-change', ]; if (relevantActions.includes(e.detail?.action)) { this._loadData(); } } _disableHostDrag(e) { // Prevent dockable host drag when interacting with internal controls if (e.target.closest('.mvp-toolbar, .mvp-table, .mvp-export-btn, .mvp-row-toggle')) { e.stopPropagation(); } } // ==================== Data Loading ==================== _buildQueryOptions() { const state = filterState.getState(); const scheduleType = this._scheduleTypes.has('current') && this._scheduleTypes.has('target') ? 'both' : this._scheduleTypes.has('target') ? 'target' : 'current'; return { scheduleType, codeFilters: (state.codeFilters || []).filter(f => f.enabled), wbsFilterIds: state.wbsFilterIds || [], weekRange: state.weekRange, resourceFilterIds: state.resourceFilterIds || [], groupBy: 'activity', }; } async _loadData() { const dbId = this._databaseId || this._currentDbId || dataService.getActiveDatabaseId(); if (!dbId) { this._showWaitingState(); return; } this._isLoading = true; this._error = null; this._showLoadingState(); try { const options = this._buildQueryOptions(); this._items = await dataService.getMaterialVolumes(dbId, options); this._processRows(); this._renderTable(); } catch (err) { console.error('[MaterialVolumePanel] Load error:', err); this._error = err.message || 'Ошибка загрузки данных'; this._showErrorState(this._error); } finally { this._isLoading = false; } } _processRows() { if (this._groupBy === GROUP_BY_MATERIAL) { this._rows = this._aggregateByMaterial(this._items); } else { this._rows = this._groupByActivity(this._items); } } /** * Группировка по работам с expandable rows. * @param {MaterialVolumeItem[]} items * @returns {any[]} */ _groupByActivity(items) { const activityMap = new Map(); for (const item of items) { const key = item.activityId; if (!activityMap.has(key)) { activityMap.set(key, { type: 'master', activityId: item.activityId, activityCode: item.activityCode, taskName: item.taskName, startDate: item.startDate, endDate: item.endDate, wbsCode: item.wbsCode, wbsName: item.wbsName, materials: [], }); } activityMap.get(key).materials.push(item); } /** @type {any[]} */ const rows = []; for (const master of activityMap.values()) { rows.push(master); if (this._expandedActivities.has(master.activityId)) { for (const mat of master.materials) { rows.push({ type: 'detail', ...mat }); } } } return rows; } /** * Агрегация по материалам (flat). * @param {MaterialVolumeItem[]} items * @returns {any[]} */ _aggregateByMaterial(items) { const map = new Map(); for (const item of items) { const key = item.resourceId; if (!map.has(key)) { map.set(key, { type: 'material', resourceId: item.resourceId, resourceName: item.resourceName, unitName: item.unitName, planQty: 0, actualQty: 0, remainingQty: 0, activityCount: 0, seenActivities: new Set(), }); } const agg = map.get(key); agg.planQty += Number(item.planQty) || 0; agg.actualQty += Number(item.actualQty) || 0; agg.remainingQty += Number(item.remainingQty) || 0; if (!agg.seenActivities.has(item.activityId)) { agg.seenActivities.add(item.activityId); agg.activityCount++; } } return Array.from(map.values()).map(agg => ({ type: 'material', resourceId: agg.resourceId, resourceName: agg.resourceName, unitName: agg.unitName, planQty: agg.planQty, actualQty: agg.actualQty, remainingQty: agg.remainingQty, percentComplete: agg.planQty > 0 ? parseFloat(((agg.actualQty / agg.planQty) * 100).toFixed(2)) : 0, activityCount: agg.activityCount, })); } // ==================== Rendering ==================== _render() { this.innerHTML = ` <style> material-volume-panel, material-volume-panel.module { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; } .mvp-panel-body { flex: 1 1 auto; min-height: 0; overflow: hidden; display: flex; flex-direction: column; } .mvp-toolbar { display: flex; align-items: center; gap: var(--space-xs, 8px); padding: var(--space-2xs, 4px) var(--space-xs, 8px); border-bottom: 1px solid var(--color-bg-border); flex-shrink: 0; background: var(--color-bg-secondary); } .mvp-toggle-group { display: flex; align-items: center; gap: 0; border: 1px solid var(--color-bg-border); border-radius: var(--radius-s, 4px); overflow: hidden; } .mvp-toggle-btn { height: 22px; padding: 0 8px; font-size: var(--font-size-xs, 10px); font-family: inherit; background: var(--color-bg-default); color: var(--color-typo-secondary); border: none; cursor: pointer; transition: background-color var(--transition-fast), color var(--transition-fast); } .mvp-toggle-btn:hover { background: var(--color-control-bg-ghost-hover); } .mvp-toggle-btn.active { background: var(--color-control-bg-primary); color: #fff; } .mvp-toggle-btn + .mvp-toggle-btn { border-left: 1px solid var(--color-bg-border); } .mvp-schedule-group { display: flex; align-items: center; gap: var(--space-2xs, 4px); margin-left: auto; } .mvp-schedule-label { font-size: var(--font-size-xs, 10px); color: var(--color-typo-secondary); display: flex; align-items: center; gap: 2px; cursor: pointer; user-select: none; } .mvp-schedule-label input[type="checkbox"] { width: 12px; height: 12px; margin: 0; cursor: pointer; } .mvp-export-btn { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; background: transparent; border: none; color: var(--color-typo-ghost); cursor: pointer; border-radius: var(--radius-s, 4px); transition: color var(--transition-fast), background-color var(--transition-fast); } .mvp-export-btn:hover { color: var(--color-typo-primary); background: var(--color-control-bg-ghost-hover); } .mvp-table-wrap { flex: 1 1 auto; min-height: 0; overflow: auto; } .mvp-table { width: 100%; border-collapse: collapse; font-size: var(--font-size-xs, 10px); } .mvp-table thead { position: sticky; top: 0; z-index: 1; } .mvp-table th { text-align: left; padding: 4px 6px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; font-size: var(--font-size-2xs, 9px); color: var(--color-typo-ghost); background: var(--color-bg-secondary); border-bottom: 1px solid var(--color-bg-border); white-space: nowrap; } .mvp-table th.number, .mvp-table th.percent { text-align: right; } .mvp-table td { padding: 3px 6px; border-bottom: 1px solid var(--color-bg-border); white-space: nowrap; color: var(--color-typo-primary); } .mvp-table td.number, .mvp-table td.percent { text-align: right; font-variant-numeric: tabular-nums; } .mvp-table tbody tr:hover { background: var(--color-control-bg-ghost-hover); } .mvp-master-row { font-weight: 500; cursor: pointer; } .mvp-master-row td { background: var(--color-bg-secondary); } .mvp-master-row:hover td { background: var(--color-control-bg-ghost-hover); } .mvp-row-toggle { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; margin-right: 2px; vertical-align: middle; color: var(--color-typo-ghost); transition: transform var(--transition-fast); } .mvp-master-row.expanded .mvp-row-toggle { transform: rotate(90deg); } .mvp-detail-row td { padding-left: 22px; color: var(--color-typo-secondary); } .mvp-detail-row .mvp-resource-name { font-style: italic; } .mvp-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 14px; padding: 0 4px; font-size: var(--font-size-2xs, 9px); font-weight: 600; border-radius: var(--radius-s, 4px); background: var(--color-control-bg-ghost); color: var(--color-typo-ghost); margin-left: 4px; } .mvp-empty-state, .mvp-loading, .mvp-error { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-md, 16px) var(--space-xs, 8px); gap: var(--space-xs, 8px); text-align: center; color: var(--color-typo-ghost); font-size: var(--font-size-xs, 10px); flex: 1; } .mvp-loading svg { animation: spin 1s linear infinite; width: 16px; height: 16px; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .mvp-error { color: var(--color-typo-alert); } .mvp-retry-btn { margin-top: 4px; padding: 4px 8px; font-size: 10px; cursor: pointer; background: var(--color-control-bg-primary); color: #fff; border: none; border-radius: var(--radius-s, 4px); } </style> <div class="module-header"> <h3> <span class="draggable-icon" style="display: inline-flex; width: 20px; height: 20px; vertical-align: middle; margin-right: 2px; color: var(--text-tertiary);">${Draggable}</span> ФИЗИЧЕСКИЕ ОБЪЁМЫ </h3> <div class="module-actions"> <button class="mvp-export-btn" title="Экспорт CSV">${Download}</button> <button class="collapse-btn icon-button" title="Свернуть/Развернуть"> <span class="arrow-icon">${ArrowDown}</span> </button> <button class="dock-toggle-btn icon-button" data-target="material-volume-panel" title="Открепить/Закрепить"> ${Layout} </button> </div> </div> <div class="mvp-panel-body"> <div class="mvp-toolbar"> <div class="mvp-toggle-group" role="radiogroup" aria-label="Группировка"> <button class="mvp-toggle-btn ${this._groupBy === GROUP_BY_ACTIVITY ? 'active' : ''}" data-group-by="${GROUP_BY_ACTIVITY}" title="По работам"> <span style="display:inline-flex;width:12px;height:12px;vertical-align:middle;margin-right:2px;">${TableIcon}</span>По работам </button> <button class="mvp-toggle-btn ${this._groupBy === GROUP_BY_MATERIAL ? 'active' : ''}" data-group-by="${GROUP_BY_MATERIAL}" title="По материалам"> <span style="display:inline-flex;width:12px;height:12px;vertical-align:middle;margin-right:2px;">${Package}</span>По материалам </button> </div> <div class="mvp-schedule-group"> <label class="mvp-schedule-label"> <input type="checkbox" data-schedule="current" ${this._scheduleTypes.has('current') ? 'checked' : ''}> ТГ </label> <label class="mvp-schedule-label"> <input type="checkbox" data-schedule="target" ${this._scheduleTypes.has('target') ? 'checked' : ''}> ЦП </label> </div> </div> <div class="mvp-table-wrap"> <div class="mvp-content-area"></div> </div> </div> `; this._attachToolbarListeners(); this._attachActionListeners(); this._showWaitingState(); } _attachToolbarListeners() { const toolbar = this.querySelector('.mvp-toolbar'); if (toolbar) { toolbar.addEventListener('click', this._onToolbarClick); toolbar.addEventListener('change', this._onToolbarClick); } } _attachActionListeners() { const exportBtn = this.querySelector('.mvp-export-btn'); if (exportBtn) { exportBtn.addEventListener('click', this._onExportClick); } const collapseBtn = this.querySelector('.collapse-btn'); if (collapseBtn) { collapseBtn.addEventListener('click', () => { this.classList.toggle('collapsed'); const arrow = this.querySelector('.arrow-icon'); if (arrow) arrow.classList.toggle('collapsed'); }); } const dockBtn = this.querySelector('.dock-toggle-btn'); if (dockBtn) { dockBtn.addEventListener('click', (e) => { e.stopPropagation(); import('../ui.js?__BUILD_ID__').then(({ toggleDock }) => { toggleDock(this); }); }); } } _onToolbarClick(e) { // Group-by toggle buttons const groupBtn = e.target.closest('[data-group-by]'); if (groupBtn) { const mode = /** @type {'activity'|'material'} */ (groupBtn.dataset.groupBy); if (mode && mode !== this._groupBy) { this._groupBy = mode; this._updateToolbarUI(); this._processRows(); this._renderTable(); } return; } // Schedule checkboxes const checkbox = e.target.closest('[data-schedule]'); if (checkbox && checkbox.tagName === 'INPUT') { const type = /** @type {'current'|'target'} */ (checkbox.dataset.schedule); if (type) { if (checkbox.checked) { this._scheduleTypes.add(type); } else { this._scheduleTypes.delete(type); } // Ensure at least one is selected if (this._scheduleTypes.size === 0) { this._scheduleTypes.add('current'); checkbox.checked = true; } this._loadData(); } } } _updateToolbarUI() { this.querySelectorAll('[data-group-by]').forEach(btn => { btn.classList.toggle('active', btn.dataset.groupBy === this._groupBy); }); } _onTableClick(e) { const masterRow = e.target.closest('.mvp-master-row'); if (masterRow) { const activityId = Number(masterRow.dataset.activityId); if (this._expandedActivities.has(activityId)) { this._expandedActivities.delete(activityId); masterRow.classList.remove('expanded'); } else { this._expandedActivities.add(activityId); masterRow.classList.add('expanded'); } this._processRows(); this._renderTable(); } } _getContentArea() { return this.querySelector('.mvp-content-area'); } _showWaitingState() { const area = this._getContentArea(); if (!area) return; area.innerHTML = ` <div class="mvp-empty-state"> <span style="display: inline-flex; width: 24px; height: 24px;">${Package}</span> <span>Загрузите базу данных,<br>чтобы увидеть физические объёмы</span> </div> `; } _showLoadingState() { const area = this._getContentArea(); if (!area) return; area.innerHTML = ` <div class="mvp-loading"> <span style="display: inline-flex; width: 16px; height: 16px;">${Loader}</span> <span>Загрузка объёмов…</span> </div> `; } _showErrorState(message) { const area = this._getContentArea(); if (!area) return; area.innerHTML = ` <div class="mvp-error"> <span style="display: inline-flex; width: 24px; height: 24px;">${AlertTriangle}</span> <span>${this._escapeHtml(message)}</span> <button class="mvp-retry-btn">Повторить</button> </div> `; const retryBtn = area.querySelector('.mvp-retry-btn'); if (retryBtn) { retryBtn.addEventListener('click', () => this._loadData()); } } _showEmptyState() { const area = this._getContentArea(); if (!area) return; area.innerHTML = ` <div class="mvp-empty-state"> <span style="display: inline-flex; width: 24px; height: 24px;">${Package}</span> <span>Нет данных по физическим объёмам<br>для выбранных фильтров</span> </div> `; } _renderTable() { const area = this._getContentArea(); if (!area) return; if (this._rows.length === 0) { this._showEmptyState(); return; } // Detach previous scroll listener const oldWrap = this.querySelector('.mvp-table-wrap'); if (oldWrap) { oldWrap.removeEventListener('scroll', this._onTableScroll); } this._virtualScrollEnabled = this._rows.length > VIRTUAL_SCROLL_THRESHOLD; if (this._virtualScrollEnabled) { area.innerHTML = '<div class="mvp-virtual-viewport" style="position:relative;"></div>'; this._updateVirtualScroll(); const wrap = this.querySelector('.mvp-table-wrap'); if (wrap) { wrap.addEventListener('scroll', this._onTableScroll); } } else { if (this._groupBy === GROUP_BY_MATERIAL) { area.innerHTML = this._buildMaterialTableHTML(); } else { area.innerHTML = this._buildActivityTableHTML(); } const table = area.querySelector('.mvp-table'); if (table) { table.addEventListener('click', this._onTableClick); } } } _onTableScroll() { if (!this._virtualScrollEnabled) return; requestAnimationFrame(() => this._updateVirtualScroll()); } _updateVirtualScroll() { const wrap = this.querySelector('.mvp-table-wrap'); const viewport = this.querySelector('.mvp-virtual-viewport'); if (!wrap || !viewport) return; const scrollTop = wrap.scrollTop; const viewportHeight = wrap.clientHeight; const totalRows = this._rows.length; const totalHeight = totalRows * ROW_HEIGHT; viewport.style.height = `${totalHeight}px`; let startIdx = Math.floor(scrollTop / ROW_HEIGHT) - VIRTUAL_BUFFER; startIdx = Math.max(0, startIdx); let endIdx = Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + VIRTUAL_BUFFER; endIdx = Math.min(totalRows, endIdx); const paddingTop = startIdx * ROW_HEIGHT; const slice = this._rows.slice(startIdx, endIdx); let html = ''; if (this._groupBy === GROUP_BY_MATERIAL) { html = this._buildMaterialTableHTML(slice, startIdx, paddingTop, totalRows); } else { html = this._buildActivityTableHTML(slice, startIdx, paddingTop, totalRows); } viewport.innerHTML = html; const table = viewport.querySelector('.mvp-table'); if (table) { table.addEventListener('click', this._onTableClick); } } /** * @param {any[]} [rows] * @param {number} [startIdx] * @param {number} [paddingTop] * @param {number} [totalRows] */ _buildMaterialTableHTML(rows = this._rows, startIdx = 0, paddingTop = 0, totalRows = this._rows.length) { const hasMixedUnits = new Set(this._rows.map(r => r.unitName)).size > 1; const isVirtual = this._virtualScrollEnabled; const bottomPad = isVirtual ? (totalRows - startIdx - rows.length) * ROW_HEIGHT : 0; return ` <table class="mvp-table"> <thead> <tr> <th>Материал</th> <th>Ед.</th> <th class="number">План</th> <th class="number">Факт</th> <th class="number">Остаток</th> <th class="percent">%</th> <th class="number">Работ</th> </tr> </thead> <tbody> ${isVirtual && paddingTop > 0 ? `<tr style="height:${paddingTop}px"><td colspan="7"></td></tr>` : ''} ${rows.map(r => ` <tr> <td>${this._escapeHtml(r.resourceName)}</td> <td>${this._escapeHtml(r.unitName)}</td> <td class="number">${this._fmtNum(r.planQty)}</td> <td class="number">${this._fmtNum(r.actualQty)}</td> <td class="number">${this._fmtNum(r.remainingQty)}</td> <td class="percent">${r.percentComplete}%</td> <td class="number">${r.activityCount}</td> </tr> `).join('')} ${isVirtual && bottomPad > 0 ? `<tr style="height:${bottomPad}px"><td colspan="7"></td></tr>` : ''} </tbody> </table> ${hasMixedUnits ? `<div style="padding:4px 8px;font-size:9px;color:var(--color-typo-alert);background:var(--color-bg-secondary);border-top:1px solid var(--color-bg-border);"> <span style="display:inline-flex;width:12px;height:12px;vertical-align:middle;margin-right:4px;">${AlertTriangle}</span> Разные единицы измерения — суммарные значения некорректны </div>` : ''} `; } /** * @param {any[]} [rows] * @param {number} [startIdx] * @param {number} [paddingTop] * @param {number} [totalRows] */ _buildActivityTableHTML(rows = this._rows, startIdx = 0, paddingTop = 0, totalRows = this._rows.length) { const isVirtual = this._virtualScrollEnabled; const bottomPad = isVirtual ? (totalRows - startIdx - rows.length) * ROW_HEIGHT : 0; return ` <table class="mvp-table"> <thead> <tr> <th style="width:18px;"></th> <th>Код</th> <th>Название</th> <th>Материал</th> <th>Ед.</th> <th class="number">План</th> <th class="number">Факт</th> <th class="number">Остаток</th> <th class="percent">%</th> </tr> </thead> <tbody> ${isVirtual && paddingTop > 0 ? `<tr style="height:${paddingTop}px"><td colspan="9"></td></tr>` : ''} ${rows.map(r => { if (r.type === 'master') { const isExpanded = this._expandedActivities.has(r.activityId); const count = r.materials?.length || 0; return ` <tr class="mvp-master-row ${isExpanded ? 'expanded' : ''}" data-activity-id="${r.activityId}"> <td> <span class="mvp-row-toggle">${isExpanded ? ChevronDown : ChevronRight}</span> </td> <td>${this._escapeHtml(r.activityCode)}</td> <td>${this._escapeHtml(r.taskName)}<span class="mvp-badge">${count}</span></td> <td colspan="6"></td> </tr> `; } else { return ` <tr class="mvp-detail-row"> <td></td> <td></td> <td></td> <td class="mvp-resource-name">${this._escapeHtml(r.resourceName)}</td> <td>${this._escapeHtml(r.unitName)}</td> <td class="number">${this._fmtNum(r.planQty)}</td> <td class="number">${this._fmtNum(r.actualQty)}</td> <td class="number">${this._fmtNum(r.remainingQty)}</td> <td class="percent">${r.percentComplete}%</td> </tr> `; } }).join('')} ${isVirtual && bottomPad > 0 ? `<tr style="height:${bottomPad}px"><td colspan="9"></td></tr>` : ''} </tbody> </table> `; } // ==================== Export ==================== _onExportClick(e) { e.stopPropagation(); this._exportCSV(); } _exportCSV() { if (this._rows.length === 0) return; let csv = ''; if (this._groupBy === GROUP_BY_MATERIAL) { csv = 'Материал,Единица,План,Факт,Остаток,%,Работ\n'; for (const r of this._rows) { csv += `${this._csvEscape(r.resourceName)},${this._csvEscape(r.unitName)},${r.planQty},${r.actualQty},${r.remainingQty},${r.percentComplete},${r.activityCount}\n`; } } else { csv = 'Тип,Код,Название,Материал,Единица,План,Факт,Остаток,%\n'; for (const r of this._rows) { if (r.type === 'master') { csv += `Работа,${this._csvEscape(r.activityCode)},${this._csvEscape(r.taskName)},,,,,\n`; } else { csv += `Материал,,,${this._csvEscape(r.resourceName)},${this._csvEscape(r.unitName)},${r.planQty},${r.actualQty},${r.remainingQty},${r.percentComplete}\n`; } } } const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `material_volumes_${this._groupBy}_${new Date().toISOString().slice(0, 10)}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // ==================== Helpers ==================== _escapeHtml(str) { if (str == null) return ''; return String(str) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"'); } _csvEscape(str) { if (str == null) return ''; const s = String(str); if (s.includes(',') || s.includes('"') || s.includes('\n')) { return '"' + s.replace(/"/g, '""') + '"'; } return s; } _fmtNum(n) { if (n == null) return ''; const num = Number(n); if (Number.isInteger(num)) return String(num); return num.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); } } // Register custom element customElements.define('material-volume-panel', MaterialVolumePanel); export default MaterialVolumePanel;