/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/components/ProjectStatsModal.js
976 строк
32 KB
Starolat Sergei
feat: добавлено модальное окно статистики по проекту
04 июл 2026, 13:30
04 июл 2026, 13:30
6a0de2f
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview ProjectStatsModal — Модальное окно статистики по загруженному проекту * @version 0.1.0 * * Показывает перечень проектов и статистику в разрезе current/target: * - количество позиций WBS * - количество работ * - количество назначенных кодов работ * - количество назначенных ресурсов */ import dataService from '../services/DataService.js'; import { BarChart, X, RefreshCw, Inbox, AlertTriangle } from './ConstantinIcons.js'; /** * @typedef {import('../types.js').ProjectStatsResult} ProjectStatsResult * @typedef {import('../types.js').ProjectStatsItem} ProjectStatsItem * @typedef {import('../types.js').ProjectScheduleStats} ProjectScheduleStats */ /** * Создать SVG-элемент из строки ConstantinIcons * @param {string} svgString * @param {string} [className] * @returns {string} */ function icon(svgString, className = '') { const cls = className ? ` class="${className}"` : ''; return svgString.replace('<svg', `<svg${cls}`); } /** * Склонение числительных (русский) * @param {number} n * @param {string} one * @param {string} two * @param {string} many * @returns {string} */ function pluralize(n, one, two, many) { const mod10 = n % 10; const mod100 = n % 100; if (mod10 === 1 && mod100 !== 11) return one; if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return two; return many; } /** * @element project-stats-modal * @attr {boolean} open - Открыто ли модальное окно */ class ProjectStatsModal extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); /** @type {ProjectStatsResult|null} */ this.stats = null; /** @type {string|null} */ this.error = null; /** @type {boolean} */ this.isLoading = false; /** @type {'init'|'loading'|'data'|'empty'|'error'} */ this._status = 'init'; /** @type {HTMLElement|null} */ this._triggerElement = null; // Bind handlers this._handleKeyDown = this._handleKeyDown.bind(this); this._handleOverlayClick = this._handleOverlayClick.bind(this); this._handleCloseClick = this._handleCloseClick.bind(this); this._handleRefreshClick = this._handleRefreshClick.bind(this); this._handleRetryClick = this._handleRetryClick.bind(this); } static get observedAttributes() { return ['open']; } connectedCallback() { this._renderBase(); this._setupEventListeners(); this._syncTheme(); this._themeObserver = new MutationObserver(() => this._syncTheme()); this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); this._themeChangedHandler = () => this._syncTheme(); document.addEventListener('theme-changed', this._themeChangedHandler); } disconnectedCallback() { document.removeEventListener('keydown', this._handleKeyDown); if (this._themeObserver) { this._themeObserver.disconnect(); } if (this._themeChangedHandler) { document.removeEventListener('theme-changed', this._themeChangedHandler); } const dialog = this.shadowRoot?.querySelector('dialog'); if (dialog) { dialog.querySelector('.psm-close')?.removeEventListener('click', this._handleCloseClick); dialog.querySelector('.psm-refresh')?.removeEventListener('click', this._handleRefreshClick); dialog.querySelector('.psm-retry')?.removeEventListener('click', this._handleRetryClick); dialog.querySelector('.psm-backdrop')?.removeEventListener('click', this._handleOverlayClick); } } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; if (name === 'open') { const isOpen = newValue !== null; if (isOpen) { this.open(); } else { this.close(); } } } // ==================== Public API ==================== /** * Открыть модалку и загрузить статистику * @public * @returns {Promise<void>} */ async open() { if (this.isLoading) return; this._triggerElement = /** @type {HTMLElement|null} */ (document.activeElement); this.setAttribute('open', ''); this._setStatus('loading'); this._announceToScreenReader('Статистика по проекту открыта. Загрузка данных.'); this.dispatchEvent(new CustomEvent('modal-open', { detail: { timestamp: Date.now() }, bubbles: true, composed: true })); await this._loadStats(); } /** * Закрыть модалку * @public */ close() { this.removeAttribute('open'); this.stats = null; this.error = null; this._setStatus('init'); this.dispatchEvent(new CustomEvent('modal-close', { detail: { timestamp: Date.now() }, bubbles: true, composed: true })); if (this._triggerElement) { this._triggerElement.focus(); this._triggerElement = null; } } /** * Обновить статистику * @public * @returns {Promise<void>} */ async refresh() { if (this.isLoading) return; this.stats = null; await this._loadStats(); } // ==================== Private Methods ==================== /** * Загрузить статистику через DataService * @private */ async _loadStats() { this._setStatus('loading'); const databaseId = this._resolveDatabaseId(); if (!databaseId) { this._setError('Загрузите базу данных'); return; } try { // Небольшая задержка, чтобы skeleton не моргал на быстрых данных await new Promise(resolve => setTimeout(resolve, 100)); const result = dataService.getProjectStatistics(databaseId); if (!result) { this._setError('Не удалось получить статистику'); return; } this.stats = result; if (result.projects.length === 0) { this._setStatus('empty'); this._announceToScreenReader('Статистика загружена. Проекты не найдены.'); } else { this._setStatus('data'); const projectCount = result.projects.length; this._announceToScreenReader( `Статистика загружена. ${projectCount} ${pluralize(projectCount, 'проект', 'проекта', 'проектов')}.` ); } } catch (err) { console.error('[ProjectStatsModal] Failed to load stats:', err); this._setError(err instanceof Error ? err.message : 'Ошибка загрузки статистики'); } } /** * Определить ID базы данных * @private * @returns {string|null} */ _resolveDatabaseId() { return dataService.primaryDatabaseId || dataService.getActiveDatabaseId?.() || null; } /** * Установить состояние ошибки * @private * @param {string} message */ _setError(message) { this.error = message; this._setStatus('error'); this._announceToScreenReader(`Ошибка загрузки статистики: ${message}`); this.dispatchEvent(new CustomEvent('stats-error', { detail: { error: message }, bubbles: false, composed: true })); } /** * Переключить UI-состояние * @private * @param {'init'|'loading'|'data'|'empty'|'error'} status */ _setStatus(status) { this._status = status; this.isLoading = status === 'loading'; this._updateVisibility(); } // ==================== Rendering ==================== /** * Базовый рендер структуры модалки * @private */ _renderBase() { const style = document.createElement('style'); style.textContent = this._getStyles(); const dialog = document.createElement('dialog'); dialog.className = 'project-stats-modal'; dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); dialog.setAttribute('aria-labelledby', 'psm-title'); dialog.innerHTML = ` <div class="psm-backdrop"></div> <div class="psm-container"> <header class="psm-header"> <h2 id="psm-title" tabindex="-1"> ${icon(BarChart, 'psm-title-icon')} СТАТИСТИКА ПО ПРОЕКТУ </h2> <button class="psm-close" aria-label="Закрыть"> ${icon(X, 'psm-close-icon')} </button> </header> <main class="psm-body" aria-live="polite" aria-busy="false"> <div class="psm-loading" hidden> <div class="psm-skeleton"> <div class="psm-skeleton-block" style="animation-delay:0ms"></div> <div class="psm-skeleton-block" style="animation-delay:150ms"></div> <div class="psm-skeleton-block" style="animation-delay:300ms"></div> <div class="psm-skeleton-block" style="animation-delay:450ms"></div> </div> </div> <div class="psm-empty" hidden> ${icon(Inbox, 'psm-empty-icon')} <p>Проекты не найдены.</p> </div> <div class="psm-error" hidden> ${icon(AlertTriangle, 'psm-error-icon')} <p class="psm-error-text"></p> <button class="compact-button psm-retry"> ${icon(RefreshCw)} Повторить </button> </div> <div class="psm-data" hidden> <div class="psm-summary"></div> <div class="psm-table-wrapper"> <table class="psm-table"> <thead> <tr> <th class="psm-col-project">Проект</th> <th class="psm-col-schedule">График</th> <th class="psm-col-number">WBS</th> <th class="psm-col-number">Работы</th> <th class="psm-col-number">Коды</th> <th class="psm-col-number">Ресурсы</th> </tr> </thead> <tbody class="psm-table-body"></tbody> <tfoot class="psm-table-foot"></tfoot> </table> </div> </div> </main> <footer class="psm-footer"> <button class="compact-button psm-refresh" disabled> ${icon(RefreshCw)} Обновить </button> </footer> <div class="psm-announcer" aria-live="polite" aria-atomic="true"></div> </div> `; this.shadowRoot?.appendChild(style); this.shadowRoot?.appendChild(dialog); } /** * Обновить видимость состояний * @private */ _updateVisibility() { const dialog = this.shadowRoot?.querySelector('dialog'); if (!dialog) return; const loadingEl = dialog.querySelector('.psm-loading'); const emptyEl = dialog.querySelector('.psm-empty'); const errorEl = dialog.querySelector('.psm-error'); const dataEl = dialog.querySelector('.psm-data'); const refreshBtn = dialog.querySelector('.psm-refresh'); const body = dialog.querySelector('.psm-body'); if (this.hasAttribute('open')) { if (!dialog.open) { dialog.showModal(); const title = dialog.querySelector('#psm-title'); if (title) title.focus(); } } else { if (dialog.open) dialog.close(); } if (loadingEl) loadingEl.hidden = this._status !== 'loading'; if (emptyEl) emptyEl.hidden = this._status !== 'empty'; if (errorEl) errorEl.hidden = this._status !== 'error'; if (dataEl) dataEl.hidden = this._status !== 'data'; if (refreshBtn) refreshBtn.disabled = this._status === 'loading'; if (body) body.setAttribute('aria-busy', String(this._status === 'loading')); if (this._status === 'data' && this.stats) { this._renderData(); } else if (this._status === 'error' && this.error && errorEl) { const textEl = errorEl.querySelector('.psm-error-text'); if (textEl) textEl.textContent = this.error; } } /** * Отрисовать содержимое статистики * @private */ _renderData() { if (!this.stats) return; const dialog = this.shadowRoot?.querySelector('dialog'); if (!dialog) return; const summaryEl = dialog.querySelector('.psm-summary'); const tbody = dialog.querySelector('.psm-table-body'); const tfoot = dialog.querySelector('.psm-table-foot'); if (summaryEl) { const headerProject = document.getElementById('app-header-project')?.textContent || this.stats.databaseName; summaryEl.innerHTML = ` <span class="psm-database-name">${this._escapeHtml(headerProject)}</span> <span class="psm-database-meta">· ${this.stats.projects.length} ${pluralize(this.stats.projects.length, 'проект', 'проекта', 'проектов')}</span> `; } if (tbody) { const sortedProjects = [...this.stats.projects].sort((a, b) => { const aCurrent = a.current.activityCount > 0 ? 1 : 0; const bCurrent = b.current.activityCount > 0 ? 1 : 0; if (aCurrent !== bCurrent) return bCurrent - aCurrent; return String(a.name).localeCompare(String(b.name)); }); tbody.innerHTML = sortedProjects.map((project) => this._renderProjectRows(project)).join(''); } if (tfoot) { tfoot.innerHTML = this._renderTotalRow(this.stats.total); } } /** * Проверить, что статистика графика не пустая * @private * @param {ProjectScheduleStats} stats * @returns {boolean} */ _hasScheduleStats(stats) { return stats.activityCount > 0 || stats.wbsCount > 0; } /** * Отрисовать строки одного проекта * @private * @param {ProjectStatsItem} project * @returns {string} */ _renderProjectRows(project) { const projectName = this._escapeHtml(project.name); const projectId = this._escapeHtml(String(project.projectId)); const rows = []; if (this._hasScheduleStats(project.current)) { rows.push(this._renderScheduleRow(project.current, 'Текущий', projectName, projectId, rows.length === 0)); } if (this._hasScheduleStats(project.target)) { rows.push(this._renderScheduleRow(project.target, 'Целевой', projectName, projectId, rows.length === 0)); } if (rows.length === 0) { rows.push(this._renderScheduleRow(project.current, 'Текущий', projectName, projectId, true)); } return rows.join(''); } /** * Отрисовать строку графика * @private * @param {ProjectScheduleStats} stats * @param {string} label * @param {string} projectName * @param {string} projectId * @param {boolean} isFirst * @returns {string} */ _renderScheduleRow(stats, label, projectName, projectId, isFirst) { const projectCell = isFirst ? `<td class="psm-cell-project"> <span class="psm-project-name" title="${projectId}">${projectName}</span> <span class="psm-project-id">${projectId}</span> </td>` : ''; return ` <tr> ${projectCell} <td class="psm-cell-schedule">${label}</td> <td class="psm-cell-number">${this._formatNumber(stats.wbsCount)}</td> <td class="psm-cell-number">${this._formatNumber(stats.activityCount)}</td> <td class="psm-cell-number">${this._formatNumber(stats.codeAssignmentCount)}</td> <td class="psm-cell-number">${this._formatNumber(stats.resourceAssignmentCount)}</td> </tr> `; } /** * Отрисовать итоговую строку * @private * @param {ProjectScheduleStats} total * @returns {string} */ _renderTotalRow(total) { return ` <tr class="psm-total-row"> <td class="psm-cell-project" colspan="2">Итого</td> <td class="psm-cell-number">${this._formatNumber(total.wbsCount)}</td> <td class="psm-cell-number">${this._formatNumber(total.activityCount)}</td> <td class="psm-cell-number">${this._formatNumber(total.codeAssignmentCount)}</td> <td class="psm-cell-number">${this._formatNumber(total.resourceAssignmentCount)}</td> </tr> `; } /** * Форматировать число * @private * @param {number} value * @returns {string} */ _formatNumber(value) { if (value == null || !Number.isFinite(value)) return '0'; return value.toLocaleString('ru-RU'); } /** * Экранировать HTML * @private * @param {string} text * @returns {string} */ _escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // ==================== Event Handling ==================== /** * Навесить обработчики событий * @private */ _setupEventListeners() { const dialog = this.shadowRoot?.querySelector('dialog'); if (!dialog) return; dialog.querySelector('.psm-close')?.addEventListener('click', this._handleCloseClick); dialog.querySelector('.psm-refresh')?.addEventListener('click', this._handleRefreshClick); dialog.querySelector('.psm-retry')?.addEventListener('click', this._handleRetryClick); dialog.querySelector('.psm-backdrop')?.addEventListener('click', this._handleOverlayClick); document.addEventListener('keydown', this._handleKeyDown); } /** * Обработчик клавиатуры * @private * @param {KeyboardEvent} e */ _handleKeyDown(e) { if (!this.hasAttribute('open')) return; if (e.key === 'Escape') { e.preventDefault(); this.close(); return; } if (e.key === 'Tab') { this._trapFocus(e); } } /** * Trap focus inside modal * @private * @param {KeyboardEvent} e */ _trapFocus(e) { const dialog = this.shadowRoot?.querySelector('dialog'); if (!dialog) return; const focusable = dialog.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; const activeEl = document.activeElement; const isInside = activeEl ? dialog.contains(activeEl) : false; if (e.shiftKey && (!isInside || activeEl === first)) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && (!isInside || activeEl === last)) { e.preventDefault(); first.focus(); } } /** * Клик по кнопке закрытия * @private */ _handleCloseClick() { this.close(); } /** * Клик по кнопке обновления * @private */ _handleRefreshClick() { this.refresh(); } /** * Клик по кнопке повтора * @private */ _handleRetryClick() { this.refresh(); } /** * Клик по бэкдропу закрывает модалку * @private * @param {MouseEvent} e */ _handleOverlayClick(e) { if (e.target === e.currentTarget) { this.close(); } } /** * Синхронизировать тему * @private */ _syncTheme() { const theme = document.documentElement.getAttribute('data-theme'); if (theme) { this.setAttribute('data-theme', theme); } } /** * Анонсировать сообщение для скринридера * @private * @param {string} text */ _announceToScreenReader(text) { const announcer = this.shadowRoot?.querySelector('.psm-announcer'); if (announcer) { announcer.textContent = ''; void announcer.offsetHeight; announcer.textContent = text; } } // ==================== Styles ==================== /** * CSS стили компонента * @private * @returns {string} */ _getStyles() { return ` :host { display: contents; } [hidden] { display: none !important; } .project-stats-modal { padding: 0; border: none; background: transparent; max-width: none; max-height: none; width: auto; height: auto; overflow: visible; } .project-stats-modal::backdrop { background: rgba(0, 0, 0, 0.5); } .project-stats-modal[open] { display: flex; align-items: center; justify-content: center; } .psm-backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); z-index: -1; } .psm-container { width: 720px; max-width: 92vw; max-height: 85vh; display: flex; flex-direction: column; background: var(--surface-bg, #fff); border-radius: var(--border-radius, 4px); box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.2)); overflow: hidden; } /* Header */ .psm-header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-xs, 8px) 12px; min-height: 32px; border-bottom: 1px solid var(--border-color, #e0e0e0); flex-shrink: 0; } .psm-header h2 { font-size: var(--font-size-xs, 10px); text-transform: uppercase; letter-spacing: 0.05em; margin: 0; display: flex; align-items: center; gap: var(--space-2xs, 4px); color: var(--text-primary, #333); font-weight: 600; } .psm-title-icon { width: 16px; height: 16px; flex-shrink: 0; } .psm-close { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; background: transparent; border: none; cursor: pointer; color: var(--text-secondary, #666); border-radius: var(--border-radius, 4px); } .psm-close:hover { background: rgba(0, 0, 0, 0.04); color: var(--text-primary, #333); } .psm-close-icon { width: 16px; height: 16px; } /* Body */ .psm-body { flex: 1; overflow-y: auto; padding: var(--space-xs, 8px); display: flex; flex-direction: column; gap: var(--space-xs, 8px); min-height: 0; } .psm-body::-webkit-scrollbar { width: 6px; } .psm-body::-webkit-scrollbar-track { background: transparent; } .psm-body::-webkit-scrollbar-thumb { background: var(--border-color, #e0e0e0); border-radius: 3px; } /* Summary */ .psm-summary { display: flex; align-items: baseline; gap: var(--space-2xs, 4px); padding: 0 4px; min-height: 16px; } .psm-database-name { font-size: var(--font-size-sm, 12px); font-weight: 600; color: var(--text-primary, #333); } .psm-database-meta { font-size: var(--font-size-xs, 10px); color: var(--text-secondary, #666); } /* Loading */ .psm-skeleton { display: flex; flex-direction: column; gap: var(--space-xs, 8px); } .psm-skeleton-block { height: 32px; border-radius: var(--border-radius, 4px); background: linear-gradient(90deg, var(--border-color, #e0e0e0) 25%, var(--surface-bg, #fff) 50%, var(--border-color, #e0e0e0) 75%); background-size: 200% 100%; animation: psm-skeleton-shimmer 1.5s infinite; } @keyframes psm-skeleton-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } /* Empty / Error */ .psm-empty, .psm-error { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-xs, 8px); padding: 24px; text-align: center; color: var(--text-secondary, #666); font-size: var(--font-size-sm, 12px); } .psm-empty-icon, .psm-error-icon { width: 32px; height: 32px; color: var(--text-hint, #999); } .psm-error-text { margin: 0; color: var(--color-alert, #d33); } /* Table */ .psm-table-wrapper { overflow-x: auto; border: 1px solid var(--border-color, #e0e0e0); border-radius: var(--border-radius, 4px); } .psm-table { width: 100%; border-collapse: collapse; font-size: var(--font-size-xs, 10px); color: var(--text-primary, #333); } .psm-table th, .psm-table td { padding: 4px 8px; border-bottom: 1px solid var(--border-color, #e0e0e0); text-align: left; vertical-align: middle; height: 20px; } .psm-table thead th { background: var(--header-bg, #f5f5f5); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary, #666); white-space: nowrap; } .psm-table tbody tr:last-child td { border-bottom: none; } .psm-col-number, .psm-cell-number { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .psm-col-schedule, .psm-cell-schedule { white-space: nowrap; } .psm-cell-project { min-width: 160px; } .psm-project-name { display: block; font-weight: 500; color: var(--text-primary, #333); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; } .psm-project-id { display: block; font-size: var(--font-size-2xs, 9px); color: var(--text-hint, #999); } .psm-total-row td { font-weight: 600; background: var(--header-bg, #f5f5f5); border-top: 2px solid var(--border-color, #e0e0e0); } /* Footer */ .psm-footer { display: flex; justify-content: flex-end; padding: var(--space-xs, 8px) 12px; border-top: 1px solid var(--border-color, #e0e0e0); flex-shrink: 0; } .compact-button { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2xs, 4px); height: 24px; padding: 0 12px; font-size: var(--font-size-xs, 10px); font-weight: 500; border-radius: var(--border-radius, 4px); border: 1px solid var(--border-color, #e0e0e0); background: var(--surface-bg, #fff); color: var(--text-primary, #333); cursor: pointer; } .compact-button:hover:not(:disabled) { background: var(--hover-bg, rgba(0,0,0,0.04)); } .compact-button:disabled { opacity: 0.5; cursor: not-allowed; } .compact-button svg { width: 14px; height: 14px; } .psm-announcer { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; } `; } } customElements.define('project-stats-modal', ProjectStatsModal); export default ProjectStatsModal;