/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
js/components/ProjectSummaryPanel.js
889 строк
29 KB
Starolat Sergei
chore: sync project state for Gitverse
16 июн 2026, 16:06
16 июн 2026, 16:06
1cb951e
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview ProjectSummaryPanel — Главный компонент панели «АНАЛИТИКА» (COMP-020, Этап 4) * @version 1.0.0 * @element project-summary-panel * @deprecated С v2.1.0 используйте LevelLayoutPanel + autonomous summary widgets с LayoutPresetService. * Компонент сохранён для backward compatibility и будет удалён в v3.0.0. * * Объединяет summary-виджеты, вызывает SummaryDataService, * слушает события БД/фильтров, генерирует summary-data-loaded / summary-data-error. * * @fires summary-data-loaded — данные успешно загружены { count, databaseId } * @fires summary-data-error — ошибка загрузки { error, databaseId } * @fires summary-widget-modified — пользователь изменил виджет { widgetType } */ // Side-effect: регистрация всех summary custom elements import './summary/index.js'; import summaryDataService from '../services/SummaryDataService.js'; import config from '../config.js'; /** * @typedef {import('../types.js').SummaryWidgetData} SummaryWidgetData * @typedef {import('../types.js').ProgressCategory} ProgressCategory * @typedef {import('../types.js').SCurveData} SCurveData * @typedef {import('../types.js').MilestoneData} MilestoneData */ class ProjectSummaryPanel extends HTMLElement { static get observedAttributes() { return ['database-id', 'report-date', 'control-milestone', 'animation-disabled', 'normalization-mode']; } constructor() { super(); this.attachShadow({ mode: 'open' }); /** @type {SummaryWidgetData|null} */ this._data = null; /** @type {boolean} */ this._isLoading = false; /** @type {boolean} */ this._hasError = false; /** @type {string|null} */ this._databaseId = null; /** @type {string|null} */ this._reportDate = null; /** @type {string} */ this._controlMilestone = 'ВехаСМР'; /** @type {'mixed'|'baseline'|'current'|'absolute'} */ this._normalizationMode = config.DEFAULT_NORMALIZATION_MODE; /** @type {boolean} */ this._animationDisabled = false; /** @type {{ startIdx: number|null, endIdx: number|null }} */ this._weekRange = { startIdx: null, endIdx: null }; /** @type {import('../types.js').CodeFilterItem[]} */ this._codeFilters = []; /** @type {number[]} */ this._wbsFilterIds = []; /** @type {HTMLElement|null} */ this._contentContainer = null; /** @type {import('../types.js').DrillLevelConfig|null} */ this.levelConfig = null; /** @type {Object[]|null} */ this.levelData = null; /** @type {IntersectionObserver|null} */ this._visibilityObserver = null; /** @type {ResizeObserver|null} */ this._resizeObserver = null; /** @type {string|null} */ this._pendingLoadTrigger = null; /** @type {number|null} */ this._reloadTimeout = null; /** @type {import('../types.js').LevelContext|null} */ this._levelContext = null; /** @type {import('../types.js').Filter[]} */ this._privateFilters = []; // Bound handlers this._boundHandleDatabaseReady = this._handleDatabaseReady.bind(this); this._boundHandleDatabaseChanged = this._handleDatabaseChanged.bind(this); this._boundHandleFilterStateChange = this._handleFilterStateChange.bind(this); this._boundHandleCompactionChange = this._handleCompactionChange.bind(this); this._boundHandleNormalizationChange = this._handleNormalizationChange.bind(this); } connectedCallback() { console.warn('[ProjectSummaryPanel] DEPRECATED: ProjectSummaryPanel is deprecated since v2.1.0. Use LevelLayoutPanel + autonomous summary widgets instead. This component will be removed in v3.0.0.'); this._databaseId = this.getAttribute('database-id') || null; this._reportDate = this.getAttribute('report-date') || null; this._controlMilestone = this.getAttribute('control-milestone') || 'ВехаСМР'; this._normalizationMode = this.getAttribute('normalization-mode') || config.DEFAULT_NORMALIZATION_MODE; this._animationDisabled = this.hasAttribute('animation-disabled'); // Синхронизация с центральным фильтром (FilterStateManager) const fsm = window.FilterStateManager; if (fsm) { const state = fsm.getState(); this._codeFilters = state.codeFilters || []; this._wbsFilterIds = state.wbsFilterIds || []; this._weekRange = state.weekRange || { startIdx: null, endIdx: null }; } this._renderSkeleton(); this._setupEventListeners(); this._applyCompactionClass(); this._initVisibilityObserver(); this._initResizeObserver(); // Если БД уже активна — сразу загружаем (если видим) if (this._databaseId) { if (this._isElementVisible()) { this._loadData('connectedCallback'); } else { this._pendingLoadTrigger = 'connectedCallback'; } } else { this._showWaitingState(); } } disconnectedCallback() { this._removeEventListeners(); if (this._visibilityObserver) { this._visibilityObserver.disconnect(); this._visibilityObserver = null; } if (this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = null; } if (this._reloadTimeout) { clearTimeout(this._reloadTimeout); this._reloadTimeout = null; } } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; switch (name) { case 'database-id': this._databaseId = newValue || null; if (this.isConnected && this._databaseId) { this._loadData('attributeChangedCallback:database-id'); } else if (this.isConnected) { this._showWaitingState(); } break; case 'report-date': this._reportDate = newValue || null; if (this.isConnected && this._databaseId) { this._loadData('attributeChangedCallback:report-date'); } break; case 'control-milestone': this._controlMilestone = newValue || 'ВехаСМР'; if (this.isConnected && this._databaseId) { this._loadData('attributeChangedCallback:control-milestone'); } break; case 'animation-disabled': this._animationDisabled = this.hasAttribute('animation-disabled'); this._toggleAnimationDisabled(); break; case 'normalization-mode': this._normalizationMode = newValue || config.DEFAULT_NORMALIZATION_MODE; if (this.isConnected && this._databaseId) { this._loadData('attributeChangedCallback:normalization-mode'); } break; } } // ==================== Event Listeners ==================== _setupEventListeners() { document.addEventListener('database-ready', this._boundHandleDatabaseReady); document.addEventListener('database-changed', this._boundHandleDatabaseChanged); document.addEventListener('filter-state-change', this._boundHandleFilterStateChange); document.addEventListener('compaction-change', this._boundHandleCompactionChange); document.addEventListener('normalization-mode-change', this._boundHandleNormalizationChange); } _removeEventListeners() { document.removeEventListener('database-ready', this._boundHandleDatabaseReady); document.removeEventListener('database-changed', this._boundHandleDatabaseChanged); document.removeEventListener('filter-state-change', this._boundHandleFilterStateChange); document.removeEventListener('compaction-change', this._boundHandleCompactionChange); document.removeEventListener('normalization-mode-change', this._boundHandleNormalizationChange); } /** * @param {CustomEvent} event * @private */ _handleDatabaseReady(event) { const detail = event.detail; if (detail && detail.databaseId) { this._databaseId = detail.databaseId; this.setAttribute('database-id', detail.databaseId); // _loadData() вызывается автоматически из attributeChangedCallback console.log('[ProjectSummaryPanel] database-ready -> setAttribute triggered'); } } /** * @param {CustomEvent} event * @private */ _handleDatabaseChanged(event) { const detail = event.detail; if (detail && detail.databaseId) { this._databaseId = detail.databaseId; this.setAttribute('database-id', detail.databaseId); // _loadData() вызывается автоматически из attributeChangedCallback console.log('[ProjectSummaryPanel] database-changed -> setAttribute triggered'); } } /** * @param {CustomEvent} event * @private */ _handleFilterStateChange(event) { const detail = event.detail; if (!detail) return; const action = detail.action; let shouldReload = false; if (action === 'week-filter-change') { this._weekRange = { startIdx: detail.startIdx ?? null, endIdx: detail.endIdx ?? null }; shouldReload = true; } if (action && action.startsWith('code-filter-')) { this._codeFilters = detail.state?.codeFilters || []; shouldReload = true; } if (action === 'wbs-filter-change') { this._wbsFilterIds = detail.state?.wbsFilterIds || []; shouldReload = true; } if (shouldReload && this._databaseId && this.isConnected) { console.log(`[ProjectSummaryPanel] filter-state-change -> schedule reload (action=${action})`); this._scheduleLoadData('filter-state-change'); } } /** * Debounce wrapper for _loadData to batch rapid filter events. * @param {string} source * @private */ _scheduleLoadData(source) { if (this._reloadTimeout) { clearTimeout(this._reloadTimeout); } this._reloadTimeout = setTimeout(() => { this._reloadTimeout = null; this._loadData(source); }, 50); } /** * @param {CustomEvent} event * @private */ _handleCompactionChange(event) { if (!this.isConnected) return; this._applyCompactionClass(event.detail?.mode); } /** * @param {CustomEvent} event * @private */ _handleNormalizationChange(event) { if (!this.isConnected) return; const mode = event.detail?.mode; if (mode && mode !== this._normalizationMode) { this._normalizationMode = mode; this.setAttribute('normalization-mode', mode); // _loadData вызывается автоматически из attributeChangedCallback } } /** * @param {string} [mode] * @private */ _applyCompactionClass(mode) { const root = this.shadowRoot?.querySelector('.project-summary-panel'); if (!root) return; root.classList.remove('layout-loose', 'layout-compact', 'layout-short'); const effectiveMode = mode || document.querySelector('.main-content')?.getAttribute('data-compaction') || 'loose'; root.classList.add(`layout-${effectiveMode}`); } // ==================== Visibility / Lazy Execution ==================== _isElementVisible() { return this.isConnected && this.offsetParent !== null; } _initVisibilityObserver() { if (this._visibilityObserver) return; this._visibilityObserver = new IntersectionObserver((entries) => { const isVisible = entries.some((e) => e.isIntersecting); if (isVisible && this._pendingLoadTrigger && this._databaseId) { const trigger = this._pendingLoadTrigger; this._pendingLoadTrigger = null; this._loadData(trigger); } }, { threshold: 0 }); this._visibilityObserver.observe(this); } _initResizeObserver() { if (this._resizeObserver) return; this._resizeObserver = new ResizeObserver(() => { if (this._pendingLoadTrigger && this._databaseId && this._isElementVisible()) { const trigger = this._pendingLoadTrigger; this._pendingLoadTrigger = null; this._loadData(trigger); } }); this._resizeObserver.observe(this); } // ==================== Data Loading ==================== async _loadData(source = 'unknown') { if (!this._databaseId || this._isLoading) { console.log(`[ProjectSummaryPanel] _loadData skipped (source=${source}, isLoading=${this._isLoading})`); return; } if (!this._isElementVisible()) { this._pendingLoadTrigger = source; console.log(`[ProjectSummaryPanel] _loadData deferred (source=${source}, hidden)`); return; } this._pendingLoadTrigger = null; this._isLoading = true; this._hasError = false; this._showLoadingState(); const loadKey = `summary-panel-load[${source}]`; console.time(loadKey); console.log(`[ProjectSummaryPanel] _loadData START source=${source}, db=${this._databaseId}`); try { const options = { reportDate: this._reportDate || undefined, controlMilestone: this._controlMilestone, codeFilters: this._codeFilters, wbsFilterIds: this._wbsFilterIds, weekRange: this._weekRange, normalizationMode: this._normalizationMode, levelContext: this._levelContext || undefined, privateFilters: this._privateFilters.length > 0 ? this._privateFilters : undefined, }; const widgetData = await summaryDataService.buildSummaryData(this._databaseId, options); this._data = widgetData; // Проверка empty state: нет данных отчётности const hasAnyData = widgetData.progress?.overall || (widgetData.sCurve?.xAxis?.length ?? 0) > 0; if (!hasAnyData) { this._showEmptyState(); this.dispatchEvent(new CustomEvent('summary-data-loaded', { bubbles: true, composed: true, detail: { count: 0, databaseId: this._databaseId }, })); } else { const renderKey = `summary-panel-render[${source}]`; console.time(renderKey); this._renderWidgets(widgetData); console.timeEnd(renderKey); this.dispatchEvent(new CustomEvent('summary-data-loaded', { bubbles: true, composed: true, detail: { count: this._countWidgets(widgetData), databaseId: this._databaseId }, })); } console.timeEnd(loadKey); console.log(`[ProjectSummaryPanel] _loadData END source=${source}`); } catch (error) { console.error('[ProjectSummaryPanel] Failed to load summary data:', error); this._hasError = true; this._showErrorState(error instanceof Error ? error.message : String(error)); this.dispatchEvent(new CustomEvent('summary-data-error', { bubbles: true, composed: true, detail: { error: error instanceof Error ? error.message : String(error), databaseId: this._databaseId }, })); } finally { this._isLoading = false; } } // ==================== Level-Based Drill-Down Support (Phase 1) ==================== /** * Контекст уровня от LevelLayoutPanel (pull-модель). * @param {import('../types.js').LevelContext|null} ctx */ set levelContext(ctx) { this._levelContext = ctx; if (ctx && this._databaseId && this.isConnected) { this._loadData('levelContext-set'); } } get levelContext() { return this._levelContext; } /** * Приватные фильтры виджета. * @param {import('../types.js').Filter[]} filters */ set privateFilters(filters) { this._privateFilters = filters || []; if (this._levelContext && this._databaseId && this.isConnected) { this._loadData('privateFilters-set'); } } get privateFilters() { return this._privateFilters; } /** * @param {SummaryWidgetData} data * @returns {number} * @private */ _countWidgets(data) { let count = 0; if (data.progress) count += Object.keys(data.progress).length; if (data.sCurve?.xAxis?.length) count++; // chart return count; } // ==================== Rendering ==================== _renderSkeleton() { this.shadowRoot.innerHTML = ` <style> ${this._getStyles()} </style> <div class="project-summary-panel" role="region" aria-label="Сводка проекта"> <div class="project-summary-content" id="content-container"></div> </div> `; this._contentContainer = this.shadowRoot.getElementById('content-container'); } _getStyles() { return ` :host { display: block; width: 100%; height: 100%; font-family: var(--font-primary, Inter, sans-serif); } .project-summary-panel { display: flex; flex-direction: column; gap: var(--space-2xs, 4px); padding: var(--space-2xs, 4px); overflow: hidden; height: 100%; box-sizing: border-box; background: var(--color-bg-default, #fff); } .project-summary-header { display: flex; align-items: center; gap: var(--space-xs, 8px); padding: var(--space-2xs, 4px) 0; flex-shrink: 0; } .project-summary-header-name { font-size: var(--size-text-l, 16px); font-weight: 700; color: var(--color-typo-primary, #212121); text-transform: uppercase; } .project-summary-header-badge { background: var(--color-bg-secondary, #e0e0e0); color: var(--color-typo-primary, #212121); font-size: var(--size-text-xs, 10px); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; padding: var(--space-2xs, 4px) var(--space-xs, 8px); border-radius: var(--radius-s, 4px); } .project-summary-section-header { font-size: var(--size-text-xs, 10px); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-typo-secondary, #757575); padding: var(--space-2xs, 4px) 0; border-bottom: 1px solid var(--color-bg-border, #e0e0e0); margin-top: var(--space-xs, 8px); flex-shrink: 0; } .project-summary-progress-cards { display: grid; grid-template-columns: repeat(5, 1fr); gap: var(--space-xs, 8px); width: 100%; } .project-summary-scurve-container { display: flex; flex-direction: column; gap: var(--space-xs, 8px); width: 100%; flex: 1 1 auto; min-height: 0; } /* Compaction modes */ .layout-compact .project-summary-progress-cards { gap: var(--space-2xs, 4px); } .layout-short .project-summary-progress-cards { gap: var(--space-3xs, 2px); } .layout-compact .project-summary-scurve-container { gap: var(--space-2xs, 4px); } .layout-short .project-summary-scurve-container { gap: var(--space-3xs, 2px); } .project-summary-content { display: flex; flex-direction: column; gap: var(--space-xs, 8px); width: 100%; flex: 1 1 auto; min-height: 0; } /* States */ .state-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; min-height: 200px; gap: var(--space-xs, 8px); color: var(--color-typo-ghost, #999); font-size: var(--size-text-s, 11px); text-align: center; } .state-icon { font-size: 32px; line-height: 1; opacity: 0.6; } .state-text { font-size: var(--size-text-s, 14px); color: var(--color-typo-secondary, #666); } .retry-btn { padding: 4px 12px; border: none; border-radius: var(--radius-xs, 2px); background: var(--color-control-bg-primary, #0071b2); color: #fff; font-size: var(--size-text-xs, 10px); cursor: pointer; font-family: inherit; } .retry-btn:hover { background: var(--color-control-bg-primary-hover, #005f94); } /* Loading spinner */ .spinner { width: 24px; height: 24px; border: 2px solid var(--color-bg-border, #e0e0e0); border-top-color: var(--color-control-bg-primary, #0071b2); border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } `; } // ==================== State Displays ==================== _showWaitingState() { if (!this._contentContainer) return; this._contentContainer.innerHTML = ` <div class="state-container"> <span class="state-icon">🗃️</span> <span class="state-text">Ожидание загрузки данных</span> <span style="font-size:var(--size-text-xs,10px);color:var(--color-typo-ghost,#999);">Загрузите проект для отображения сводки</span> </div> `; } _showLoadingState() { if (!this._contentContainer) return; this._contentContainer.innerHTML = ` <div class="state-container"> <div class="spinner"></div> <span class="state-text">Подготовка аналитики...</span> </div> `; } _showEmptyState() { if (!this._contentContainer) return; this._contentContainer.innerHTML = ` <div class="state-container"> <span class="state-icon">📭</span> <span class="state-text">Нет данных для отчётности</span> <span style="font-size:var(--size-text-xs,10px);color:var(--color-typo-ghost,#999);">В базе отсутствуют работы с кодом отчётности 'S'</span> </div> `; } /** * @param {string} message * @private */ _showErrorState(message) { if (!this._contentContainer) return; this._contentContainer.innerHTML = ` <div class="state-container"> <span class="state-icon">⚠️</span> <span class="state-text">Ошибка загрузки сводки</span> <span style="font-size:var(--size-text-xs,10px);color:var(--color-typo-secondary,#666);max-width:400px;">${message}</span> <button class="retry-btn" id="retry-btn">Повторить</button> </div> `; const retryBtn = this._contentContainer.querySelector('#retry-btn'); if (retryBtn) { retryBtn.addEventListener('click', () => this._loadData()); } } // ==================== Widget Rendering ==================== /** * @param {SummaryWidgetData} data * @private */ _renderWidgets(data) { if (!this._contentContainer) return; // Обновляем заголовок проекта const projectNameEl = this.shadowRoot.getElementById('project-name'); if (projectNameEl && data.projectName) { projectNameEl.textContent = data.projectName; } const animAttr = this._animationDisabled ? 'animation-disabled' : ''; this._contentContainer.innerHTML = ` <!-- Progress + S-Curve --> <div class="project-summary-progress-cards"> ${this._renderProgressWidgets(data.progress, animAttr, data.reportDate)} </div> <div class="project-summary-scurve-container"> <summary-s-curve-widget report-date="${this._reportDate || data.reportDate || ''}" ${animAttr} ></summary-s-curve-widget> </div> `; // После вставки DOM — прокидываем данные через свойства (для массивов/объектов) this._syncWidgetProperties(data); this._attachDrillDownListeners(); } /** * @param {{overall:ProgressCategory,pir:ProgressCategory,mto:ProgressCategory,smr:ProgressCategory,pnr:ProgressCategory}|undefined} progress * @param {string} animAttr * @param {string} [reportDate] * @returns {string} * @private */ _renderProgressWidgets(progress, animAttr, reportDate) { if (!progress) return ''; const cats = [ { key: 'overall', title: 'ОБЩИЙ' }, { key: 'pir', title: 'ПИР' }, { key: 'mto', title: 'МТО' }, { key: 'smr', title: 'СМР' }, { key: 'pnr', title: 'ПНР' }, ]; return cats.map(({ key, title }) => { const p = progress[key]; if (!p) return ''; const weeklyDataStr = p.weeklyDataTable ? JSON.stringify(p.weeklyDataTable) : '[]'; return ` <summary-progress-widget title="${title}" accumulated-plan-prev-week="${p.accumulatedPlanPrevWeek ?? 0}" accumulated-fact-prev-week="${p.accumulatedFactPrevWeek ?? 0}" period-plan-prev-week="${p.periodPlanPrevWeek ?? ''}" period-fact-prev-week="${p.periodFactPrevWeek ?? ''}" delta="${p.delta ?? 0}" trend="${p.trend ?? ''}" weekly-data='${weeklyDataStr}' start-date="${p.startDate ?? ''}" end-date="${p.endDate ?? ''}" target-start-date="${p.targetStartDate ?? ''}" target-end-date="${p.targetEndDate ?? ''}" total-plan="${p.totalPlan ?? ''}" total-fact="${p.totalFact ?? ''}" report-date="${this._reportDate || reportDate || ''}" normalization-mode="${this._normalizationMode}" ></summary-progress-widget> `; }).join(''); } /** * @param {SummaryWidgetData} data * @private */ _syncWidgetProperties(data) { // S-Curve Chart const sCurveChart = this._contentContainer.querySelector('summary-s-curve-widget'); if (sCurveChart && data.sCurve) { sCurveChart.xAxisData = data.sCurve.xAxis || []; sCurveChart.baseline = data.sCurve.baseline || []; sCurveChart.actual = data.sCurve.actual || []; sCurveChart.forecast = data.sCurve.forecast || []; sCurveChart.trend = data.sCurve.trend || []; sCurveChart.trendForecast = data.sCurve.trendForecast || []; sCurveChart.outliers = data.sCurve.outliers || []; sCurveChart.confidenceUpper = data.sCurve.confidenceUpper || []; sCurveChart.confidenceLower = data.sCurve.confidenceLower || []; sCurveChart.theilSen = data.sCurve.theilSen || null; sCurveChart.normalizationMode = data.sCurve.normalizationMode || this._normalizationMode; const milestones = [data.techLaunchMilestone, data.smrMilestone].filter(Boolean); sCurveChart.milestones = milestones; } } /** * Attach drill-down listeners to clickable widgets. * Dispatches drill-down-request events for SummaryDrillNavigator. * @private */ _attachDrillDownListeners() { if (!this._contentContainer) return; // Progress widgets: drill-down by category const progressWidgets = this._contentContainer.querySelectorAll('summary-progress-widget'); progressWidgets.forEach(widget => { widget.style.cursor = 'pointer'; widget.addEventListener('click', () => { const title = widget.getAttribute('title') || ''; if (!title) return; const isMto = title === 'МТО'; this.dispatchEvent(new CustomEvent('drill-down-request', { bubbles: true, composed: true, detail: { groupField: 'code:Вид работ', groupValue: title, groupValueLabel: title, chartType: isMto ? 'mto-summary-panel' : 'pie', normalizationMode: this._normalizationMode, }, })); }); }); } _toggleAnimationDisabled() { if (!this._contentContainer) return; const widgets = this._contentContainer.querySelectorAll( 'summary-s-curve-widget' ); widgets.forEach(w => { if (this._animationDisabled) { w.setAttribute('animation-disabled', ''); } else { w.removeAttribute('animation-disabled'); } }); } /** * @param {string} text * @returns {string} * @private */ _escapeHtml(text) { if (!text) return ''; return String(text) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"'); } // ==================== Public API ==================== /** * Принудительная перезагрузка данных * @returns {Promise<void>} */ async refresh() { await this._loadData(); } /** * Установить диапазон недель * @param {{ startIdx: number|null, endIdx: number|null }} range */ setWeekRange(range) { this._weekRange = { startIdx: range.startIdx ?? null, endIdx: range.endIdx ?? null }; if (this._databaseId) { this._loadData(); } } /** @returns {SummaryWidgetData|null} */ get data() { return this._data; } /** @returns {boolean} */ get isLoading() { return this._isLoading; } /** @returns {boolean} */ get hasError() { return this._hasError; } } if (!customElements.get('project-summary-panel')) { customElements.define('project-summary-panel', ProjectSummaryPanel); } export { ProjectSummaryPanel }; export default ProjectSummaryPanel;