/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/components/AppSettingsModal.js
4 564 строки
169 KB
Starolat Sergei
refactor(auth): перевёл аутентификацию на Bearer JWT во всех средах
16 июл 2026, 11:49
16 июл 2026, 11:49
43a857b
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview AppSettingsModal - Модальное окно настроек приложения * @version 1.0.0 * * Модальное окно для управления проектами (контрактами/графиками КСГ) приложения DeepDive Analytics. * * @version 1.0.0 - Production Ready * - Убраны мок-данные, все данные загружаются из localStorage * - Реальные API запросы к Primavera для получения описаний * * Структура данных v1.0.0: * Project (DeepDive) — пользовательская сущность * ├── projectId — внутренний ID (uuid) * ├── name — название от пользователя * ├── expanded — развёрнута ли карточка (boolean) * └── schedulePairs: SchedulePair[] * ├── pairId — ID пары * ├── currentScheduleId — идентификатор текущего графика (KSG ID = short_name Primavera) * ├── targetScheduleId — идентификатор целевого графика (KSG ID = short_name Primavera) * ├── currentDescription — из поля Description * ├── targetDescription — из поля Description * ├── currentKsgLoaded — загружен ли КСГ для текущего * ├── targetKsgLoaded — загружен ли КСГ для целевого * ├── status — 'pending' | 'loading' | 'completed' | 'error' * └── isValid — true если оба scheduleId заполнены * * Features v0.7.0: * - KSG ID и short_name объединены в одно поле * - Разворачиваемые карточки проектов с Sankey-диаграммами * - Цветовая индикация: 🟢 текущий / 🟣 целевой * - Индикаторы загрузки КСГ с валидацией * * @see specs/widgets/AppSettingsModal.md */ import config from '../config.js'; import { fetchWithAuth } from '../services/auth/index.js'; import { tokenService } from '../services/auth/token-service.js'; import { Settings, X, Plus, Download, Upload, PlusSquare, ChevronRight, RefreshCw, Edit, Trash, ArrowRight, AlertTriangle, XCircle, Check, Info, Layout, Save, Play, FileText, Maximize, Minimize } from './ConstantinIcons.js'; import { ToastManager } from './ToastManager.js'; import { LayoutPresetService } from '../services/LayoutPresetService.js'; import { UserSettingsService } from '../services/UserSettingsService.js'; import './PresetBuilderCanvas.js'; /** * @typedef {Object} Project * @property {string} projectId - Уникальный внутренний ID проекта (uuid) * @property {string} name - Название проекта (задаёт пользователь) * @property {SchedulePair[]} schedulePairs - Пары графиков КСГ * @property {boolean} expanded - Развёрнута ли карточка проекта * @property {string} createdAt - Дата создания проекта (ISO 8601) * @property {string} [updatedAt] - Дата последнего обновления (ISO 8601) */ /** * @typedef {Object} SchedulePair * @property {string} pairId - Уникальный ID пары (uuid) * @property {string} projectId - Ссылка на проект DeepDive * @property {string} currentScheduleId - Идентификатор текущего графика (KSG ID = short_name Primavera) * @property {string} [targetScheduleId] - Идентификатор целевого графика (KSG ID = short_name Primavera) * @property {string} [currentDescription] - Описание текущего графика (поле Description таблицы PROJECT) * @property {string} [targetDescription] - Описание целевого графика (поле Description таблицы PROJECT) * @property {boolean} [currentNotFound] - Не найден ли текущий график в Primavera * @property {boolean} [targetNotFound] - Не найден ли целевой график в Primavera * @property {boolean} currentKsgLoaded - Загружен ли КСГ для текущего графика * @property {boolean} targetKsgLoaded - Загружен ли КСГ для целевого графика * @property {string} [currentDataDate] - Дата актуальности текущего графика * @property {string} [targetDataDate] - Дата актуальности целевого графика * @property {'pending'|'loading'|'completed'|'error'|'cancelled'|'unloaded'} status - Статус загрузки пары * @property {string} [errorMessage] - Сообщение об ошибке загрузки * @property {boolean} isValid - Валидна ли пара (has targetScheduleId) */ /** * @typedef {Object} SankeyData * @property {SankeyNode[]} nodes - Узлы диаграммы * @property {SankeyLink[]} links - Связи между узлами */ /** * @typedef {Object} SankeyNode * @property {string} id - Уникальный ID узла * @property {string} name - Отображаемое имя * @property {string} [color] - Цвет узла * @property {number} [value] - Значение для размера * @property {'current'|'target'} [type] - Тип узла */ /** * @typedef {Object} SankeyLink * @property {string} source - ID исходного узла * @property {string} target - ID целевого узла * @property {number} value - Вес/значение связи * @property {string} [color] - Цвет связи */ /** * @typedef {Object} ValidationError * @property {string} code - Код ошибки ('MISSING_TARGET', 'NO_PAIRS', 'EMPTY_SCHEDULE_ID') * @property {string} message - Описание ошибки * @property {string} [pairId] - ID пары (если ошибка связана с парой) */ /** * @typedef {Object} AppSettings * @property {string} version - Версия формата настроек * @property {string} exportedAt - Дата экспорта (ISO 8601) * @property {string} [applicationVersion] - Версия приложения * @property {Object} data - Данные настроек * @property {Project[]} data.projects - Список проектов DeepDive * @property {Object} [data.ui] - Настройки UI * @property {string} [data.ui.theme] - Тема оформления */ /** * @typedef {Object} SettingsImportResult * @property {boolean} success - Успешно ли импортировано * @property {string} [error] - Сообщение об ошибке * @property {number} importedProjects - Количество импортированных проектов * @property {number} importedPairs - Количество импортированных пар графиков * @property {string[]} warnings - Предупреждения при импорте */ /** * Генерация UUID v4 * @returns {string} UUID */ function generateUUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } /** * Форматирование даты для имени файла * @param {string|Date} date - Дата * @returns {string} YYYY-MM-DD */ function formatDateForFilename(date) { const d = new Date(date); const year = d.getFullYear(); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } /** * Санитизация имени файла * @param {string} name - Исходное имя * @returns {string} Очищенное имя */ function sanitizeFilename(name) { return name.replace(/[^a-zA-Zа-яА-Я0-9\s-_]/g, '').trim(); } /** * Дебаунс функция * @template T * @param {(...args: T[]) => void} func - Функция * @param {number} wait - Задержка в мс * @returns {(...args: T[]) => void} */ function debounce(func, wait) { /** @type {ReturnType<typeof setTimeout>|null} */ let timeout = null; return function(...args) { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); }; } class AppSettingsModal extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); /** @type {Project[]} */ this.projects = []; /** @type {Set<string>} */ this.expandedProjectIds = new Set(); /** @type {Map<string, ValidationError[]>} */ this.validationErrors = new Map(); /** @type {Map<string, any>} */ this.sankeyCharts = new Map(); /** @type {Set<string>} */ this.editingProjectNames = new Set(); /** @type {boolean} */ this.isOpen = false; /** @type {AbortController|null} */ this.loadingAbortController = null; /** @type {Project[]|null} */ this.originalProjects = null; /** @type {boolean} */ this.hasUnsavedChanges = false; /** @type {'presets'|'projects'} */ this._activeTab = 'projects'; /** @type {'list'|'builder'} */ this._presetsView = 'list'; /** @type {boolean} */ this.isFullscreen = false; // Debounced handlers this.debouncedScheduleInput = debounce(this._handleScheduleInput.bind(this), 300); // Bind methods this.open = this.open.bind(this); this.close = this.close.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); } static get observedAttributes() { return ['open', 'data-source']; } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; switch (name) { case 'open': this.isOpen = newValue !== null; this.updateVisibility(); break; case 'data-source': // Handle data source change if needed break; } } connectedCallback() { console.log('[AppSettingsModal] v1.0.0 initialized - Production Mode'); this.render(); this.setupEventListeners(); this.syncTheme(); // Загрузка данных из localStorage (production mode) this.loadProjects(); if (this.isOpen) { this.updateVisibility(); } // Слушать изменения темы this.themeObserver = new MutationObserver(() => this.syncTheme()); this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); // Слушать события изменения статуса загрузки КСГ от COMP-009 this._ksgStatusHandler = (e) => { const { projectId, pairId, type, loaded } = e.detail; this.setKsgLoaded(projectId, pairId, type, loaded); }; window.addEventListener('ksg-status-change', this._ksgStatusHandler); // Load ECharts if needed this.loadECharts(); // Listen for preset changes from other components this._presetSavedHandler = () => { if (this.isOpen) this._loadAndRenderPresets(); }; this._presetDeletedHandler = () => { if (this.isOpen) this._loadAndRenderPresets(); }; document.addEventListener('preset-saved', this._presetSavedHandler); document.addEventListener('preset-deleted', this._presetDeletedHandler); // Listen for preset cancelled from inline builder to return to list view this._presetCancelledHandler = (e) => { if (this.isOpen && this._activeTab === 'presets' && this._presetsView === 'builder') { // Only handle if source is our inline builder const builder = this.shadowRoot?.querySelector('#preset-builder'); if (builder && (e.target === builder || e.composedPath().includes(builder))) { this._showPresetsList(); } } }; document.addEventListener('preset-cancelled', this._presetCancelledHandler); // Listen for preset saved from inline builder — save to LayoutPresetService and activate this._presetSavedFromBuilderHandler = async (e) => { const builder = this.shadowRoot?.querySelector('#preset-builder'); if (!builder || !(e.target === builder || e.composedPath().includes(builder))) return; const { preset } = e.detail || {}; if (!preset) return; try { const lps = LayoutPresetService.getInstance(); await lps.init(); await lps.savePreset(preset); await lps.setActivePreset(preset.presetId); ToastManager.getInstance().success(`Макет "${preset.name}" сохранён и применён`, 4000); await this._loadAndRenderPresets(); } catch (err) { console.error('[AppSettingsModal] Failed to save preset from builder:', err); ToastManager.getInstance().error('Ошибка сохранения макета', 5000); } }; document.addEventListener('preset-saved', this._presetSavedFromBuilderHandler); // Listen for preset preview from inline builder — activate without saving this._presetPreviewFromBuilderHandler = async (e) => { const builder = this.shadowRoot?.querySelector('#preset-builder'); if (!builder || !(e.target === builder || e.composedPath().includes(builder))) return; const { preset } = e.detail || {}; if (!preset) return; try { const lps = LayoutPresetService.getInstance(); await lps.init(); await lps.previewPreset(preset); ToastManager.getInstance().success(`Макет "${preset.name}" применён (предпросмотр)`, 3000); } catch (err) { console.error('[AppSettingsModal] Failed to preview preset from builder:', err); ToastManager.getInstance().error('Ошибка применения макета', 5000); } }; document.addEventListener('preset-preview-requested', this._presetPreviewFromBuilderHandler); // Listen for UserSettingsService initialization/ready event. // AppSettingsModal is created in AppController._initUI() BEFORE // UserSettingsService.init() completes, so the initial loadProjects() // falls back to localStorage. Reload from USS once it is ready // to pick up server-stored projects. this._userSettingsLoadedHandler = () => { if (this._useUserSettings() && !this.hasUnsavedChanges) { console.log('[AppSettingsModal] UserSettingsService ready, reloading projects from server'); this.loadProjects(); } }; document.addEventListener('user-settings-loaded', this._userSettingsLoadedHandler); } disconnectedCallback() { this.cleanupEventListeners(); this.cleanupSankeyCharts(); if (this.loadingAbortController) { this.loadingAbortController.abort(); } if (this.themeObserver) { this.themeObserver.disconnect(); } // Удаляем слушатель события ksg-status-change if (this._ksgStatusHandler) { window.removeEventListener('ksg-status-change', this._ksgStatusHandler); } if (this._presetSavedHandler) { document.removeEventListener('preset-saved', this._presetSavedHandler); } if (this._presetDeletedHandler) { document.removeEventListener('preset-deleted', this._presetDeletedHandler); } if (this._presetCancelledHandler) { document.removeEventListener('preset-cancelled', this._presetCancelledHandler); } if (this._presetSavedFromBuilderHandler) { document.removeEventListener('preset-saved', this._presetSavedFromBuilderHandler); } if (this._presetPreviewFromBuilderHandler) { document.removeEventListener('preset-preview-requested', this._presetPreviewFromBuilderHandler); } if (this._userSettingsLoadedHandler) { document.removeEventListener('user-settings-loaded', this._userSettingsLoadedHandler); } } /** * Загрузить ECharts * @private */ loadECharts() { if (window.echarts) return; const script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js'; script.async = true; document.head.appendChild(script); } /** * Синхронизировать тему с document.documentElement * @private */ syncTheme() { const theme = document.documentElement.getAttribute('data-theme'); if (theme) { this.setAttribute('data-theme', theme); } } /** * Очистить Sankey диаграммы * @private */ cleanupSankeyCharts() { this.sankeyCharts.forEach(chart => { if (chart && typeof chart.dispose === 'function') { chart.dispose(); } }); this.sankeyCharts.clear(); } /** * Ключ для хранения проектов в localStorage (legacy fallback) * @private * @constant {string} */ get STORAGE_KEY() { return 'deepdive_projects'; } /** * Использовать ли UserSettingsService для хранения проектов. * @private * @returns {boolean} */ _useUserSettings() { return !!config.FEATURE_USER_SETTINGS_SERVICE && UserSettingsService.getInstance().isReady(); } /** * Прочитать сохранённые проекты из актуального хранилища. * @private * @returns {Project[]|null} */ _loadProjectsFromStorage() { if (this._useUserSettings()) { const projects = UserSettingsService.getInstance().get('projects', null); return Array.isArray(projects) ? projects : null; } try { const stored = localStorage.getItem(this.STORAGE_KEY); if (stored) { return JSON.parse(stored); } } catch (e) { console.error('[AppSettingsModal] Failed to read projects from localStorage:', e); } return null; } /** * Сохранить проекты в актуальное хранилище. * @private * @returns {Promise<void>} */ async _saveProjectsToStorage() { if (this._useUserSettings()) { await UserSettingsService.getInstance().set('projects', this.projects); console.log(`[AppSettingsModal] Saved ${this.projects.length} projects to UserSettingsService`); return; } localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.projects)); console.log(`[AppSettingsModal] Saved ${this.projects.length} projects to localStorage`); } /** * Загрузка проектов из актуального хранилища (production mode) * @private */ loadProjects() { const stored = this._loadProjectsFromStorage(); this.projects = Array.isArray(stored) ? stored : []; if (this.projects.length > 0) { console.log(`[AppSettingsModal] Loaded ${this.projects.length} projects`); } else { console.log('[AppSettingsModal] No saved projects, starting empty'); } // Валидация всех проектов this.projects.forEach(project => { if (project.expanded) { this.expandedProjectIds.add(project.projectId); } this.validateProject(project.projectId); }); this.renderProjectsList(); } /** * Сохранение проектов в актуальное хранилище (production mode) * @private * @returns {Promise<void>} */ async saveProjects() { try { await this._saveProjectsToStorage(); } catch (error) { console.error('[AppSettingsModal] Failed to save projects:', error); ToastManager.getInstance().error('Ошибка сохранения настроек', 5000); } } // ==================== Public API Methods ==================== /** * Открыть модальное окно * @public */ open() { // Сохраняем исходное состояние для возможности отмены this.originalProjects = this._deepClone(this.projects); this.hasUnsavedChanges = false; this.setAttribute('open', ''); this.dispatchEvent(new CustomEvent('modal-open', { bubbles: false, composed: true })); // Обновляем состояние кнопки ОК this.updateOkButtonState(); // Загрузить макеты аналитики this._loadAndRenderPresets(); // Показать список макетов если вкладка Макеты активна if (this._activeTab === 'presets') { this._showPresetsList(); } // Установить фокус на первый интерактивный элемент setTimeout(() => { const closeBtn = this.shadowRoot?.querySelector('.modal__close'); if (closeBtn instanceof HTMLElement) { closeBtn.focus(); } }, 0); } /** * Переключить полноэкранный режим модального окна * @public */ toggleFullscreen() { this.isFullscreen = !this.isFullscreen; const modal = this.shadowRoot?.querySelector('.modal'); const fullscreenBtn = this.shadowRoot?.querySelector('.modal__fullscreen'); if (modal instanceof HTMLElement) { modal.classList.toggle('modal--fullscreen', this.isFullscreen); } if (fullscreenBtn instanceof HTMLElement) { fullscreenBtn.innerHTML = this.isFullscreen ? Minimize : Maximize; fullscreenBtn.setAttribute('aria-label', this.isFullscreen ? 'Свернуть окно' : 'Развернуть на весь экран'); } // При разворачивании пересчитать размер Sankey диаграмм если они есть if (this.isFullscreen) { setTimeout(() => { this.sankeyCharts.forEach(chart => { if (chart && typeof chart.resize === 'function') { chart.resize(); } }); }, 260); // Длительность CSS transition } } /** * Переключить вкладку * @private * @param {'presets'|'projects'} tab */ _switchTab(tab) { if (this._activeTab === tab) return; this._activeTab = tab; const shadow = this.shadowRoot; if (!shadow) return; // Обновить табы shadow.querySelectorAll('.modal__tab').forEach(t => { t.classList.toggle('modal__tab--active', t.getAttribute('data-tab') === tab); }); // Обновить панели shadow.querySelectorAll('.tab-panel').forEach(p => { p.classList.toggle('tab-panel--active', p.getAttribute('data-tab') === tab); }); // Управление видом вкладки Макеты if (tab === 'presets') { this._showPresetsList(); // Ensure presets list is loaded when switching to this tab this._loadAndRenderPresets(); } else { const builder = shadow.querySelector('#preset-builder'); if (builder && builder.hasAttribute('open')) { builder.close(); } // Reset presets view to list for next time this._presetsView = 'list'; shadow.querySelector('.presets-builder-view')?.classList.remove('presets-builder-view--active'); shadow.querySelector('.presets-list-view')?.classList.add('presets-list-view--active'); } } /** * Глубокое клонирование массива проектов * @private * @param {Project[]} projects * @returns {Project[]} */ _deepClone(projects) { return JSON.parse(JSON.stringify(projects)); } /** * Закрыть модальное окно * @public */ close() { // Закрыть inline конструктор макетов если открыт const builder = this.shadowRoot?.querySelector('#preset-builder'); if (builder && builder.hasAttribute('open')) { builder.close(); } // Сбросить полноэкранный режим if (this.isFullscreen) { this.toggleFullscreen(); } this.removeAttribute('open'); this.dispatchEvent(new CustomEvent('modal-close', { bubbles: false, composed: true })); } /** * Применить изменения и закрыть модальное окно * @public */ async applyAndClose() { console.log('[AppSettingsModal] Applying settings and closing modal'); if (!this.isFormValid()) { console.warn('[AppSettingsModal] Form is invalid, cannot apply'); return; } // Save preset from inline builder if it has unsaved changes const builder = this.shadowRoot?.querySelector('#preset-builder'); if (builder && builder.isDirty) { try { const preset = builder.getPreset(); if (preset) { const lps = LayoutPresetService.getInstance(); await lps.init(); await lps.savePreset(preset); await lps.setActivePreset(preset.presetId); ToastManager.getInstance().success(`Макет "${preset.name}" сохранён и применён`, 4000); } } catch (err) { console.error('[AppSettingsModal] Failed to save preset from builder:', err); ToastManager.getInstance().error('Ошибка сохранения макета', 5000); } } // Сохраняем в актуальное хранилище (production mode) await this.saveProjects(); console.log('[AppSettingsModal] Settings applied successfully'); this.hasUnsavedChanges = false; this.originalProjects = null; // Отправляем событие с текущими настройками this.dispatchEvent(new CustomEvent('settings-applied', { detail: { projects: this.projects }, bubbles: true, composed: true })); this.close(); } /** * Отменить изменения и закрыть модальное окно * @public */ cancelAndClose() { console.log('[AppSettingsModal] Cancelling changes and closing modal'); if (this.originalProjects) { this.projects = this.originalProjects; this.validationErrors.clear(); this.expandedProjectIds.clear(); // Восстанавливаем expanded состояние this.projects.forEach(project => { if (project.expanded) { this.expandedProjectIds.add(project.projectId); } }); this.renderProjectsList(); } this.hasUnsavedChanges = false; this.originalProjects = null; this.close(); } /** * Проверить валидность всей формы * @public * @returns {boolean} */ isFormValid() { // Пустой список проектов — валидное состояние (все проекты удалены) if (this.projects.length === 0) { return true; } // Каждый проект должен быть валиден for (const project of this.projects) { // Проект должен иметь название if (!project.name.trim()) { return false; } // Проект должен иметь хотя бы одну пару if (project.schedulePairs.length === 0) { return false; } // Каждая пара должна быть валидна for (const pair of project.schedulePairs) { if (!pair.currentScheduleId.trim() || !pair.targetScheduleId.trim()) { return false; } } } return true; } /** * Обновить состояние кнопки ОК * @private */ updateOkButtonState() { const okBtn = this.shadowRoot?.querySelector('.modal__ok-btn'); if (okBtn instanceof HTMLButtonElement) { const isValid = this.isFormValid(); okBtn.disabled = !isValid; okBtn.title = isValid ? 'Сохранить изменения' : 'Заполните все обязательные поля'; } } /** * Создать новый проект DeepDive * @public * @param {string} name - Название проекта * @returns {Project} */ createProject(name) { console.log(`[AppSettingsModal] Creating new project: "${name || 'Untitled'}"`); /** @type {Project} */ const project = { projectId: generateUUID(), name: name || '', schedulePairs: [], expanded: true, createdAt: new Date().toISOString() }; console.log(`[AppSettingsModal] Project created with ID: ${project.projectId}`); this.projects.push(project); this.expandedProjectIds.add(project.projectId); this.hasUnsavedChanges = true; this.renderProjectsList(); this.updateOkButtonState(); this.dispatchEvent(new CustomEvent('project-create', { detail: { project }, bubbles: true, composed: true })); return project; } /** * Обновить данные проекта * @public * @param {string} projectId - ID проекта * @param {Partial<Project>} updates - Обновления */ updateProject(projectId, updates) { const project = this.projects.find(p => p.projectId === projectId); if (!project) return; Object.assign(project, updates, { updatedAt: new Date().toISOString() }); this.hasUnsavedChanges = true; this.renderProjectsList(); this.updateOkButtonState(); this.dispatchEvent(new CustomEvent('project-update', { detail: { projectId, updates }, bubbles: true, composed: true })); } /** * Показать диалог подтверждения удаления проекта * @private * @param {string} projectId - ID проекта * @param {string} projectName - Название проекта * @returns {Promise<boolean>} - Подтверждено ли удаление */ showDeleteConfirmDialog(projectId, projectName) { return new Promise((resolve) => { // Создаем overlay для диалога const overlay = document.createElement('div'); overlay.className = 'confirm-dialog-overlay'; overlay.innerHTML = ` <div class="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="confirm-dialog-title"> <div class="confirm-dialog__header"> <div class="confirm-dialog__icon"> ${AlertTriangle} </div> <h3 id="confirm-dialog-title" class="confirm-dialog__title">Удалить проект?</h3> </div> <div class="confirm-dialog__body"> <p class="confirm-dialog__message"> Проект <strong>"${this.escapeHtml(projectName)}"</strong> будет удалён вместе со всеми парами графиков. Это действие нельзя отменить. </p> </div> <div class="confirm-dialog__footer"> <button class="btn btn--secondary" data-action="cancel">Отмена</button> <button class="btn btn--danger" data-action="confirm">Удалить</button> </div> </div> `; // Добавляем в shadow root this.shadowRoot?.appendChild(overlay); // Обработчики кнопок const cancelBtn = overlay.querySelector('[data-action="cancel"]'); const confirmBtn = overlay.querySelector('[data-action="confirm"]'); const cleanup = () => { overlay.remove(); }; cancelBtn?.addEventListener('click', () => { cleanup(); resolve(false); }); confirmBtn?.addEventListener('click', () => { cleanup(); resolve(true); }); // Закрытие по клику на overlay overlay.addEventListener('click', (e) => { if (e.target === overlay) { cleanup(); resolve(false); } }); // Закрытие по Escape const handleEscape = (e) => { if (e.key === 'Escape') { cleanup(); resolve(false); document.removeEventListener('keydown', handleEscape); } }; document.addEventListener('keydown', handleEscape); }); } /** * Удалить проект * @public * @param {string} projectId - ID проекта */ deleteProject(projectId) { console.log(`[AppSettingsModal] Deleting project: ${projectId}`); const index = this.projects.findIndex(p => p.projectId === projectId); if (index === -1) { console.warn(`[AppSettingsModal] Project not found: ${projectId}`); return; } this.projects.splice(index, 1); this.expandedProjectIds.delete(projectId); this.validationErrors.delete(projectId); this.hasUnsavedChanges = true; this.renderProjectsList(); this.updateOkButtonState(); this.dispatchEvent(new CustomEvent('project-delete', { detail: { projectId }, bubbles: true, composed: true })); } /** * Переключить развёрнутость проекта * @public * @param {string} projectId - ID проекта */ toggleProjectExpanded(projectId) { const project = this.projects.find(p => p.projectId === projectId); if (!project) { console.warn(`[AppSettingsModal] Project not found for toggle: ${projectId}`); return; } console.log(`[AppSettingsModal] Toggling project ${projectId}: ${project.expanded ? 'collapsing' : 'expanding'}`); project.expanded = !project.expanded; if (project.expanded) { this.expandedProjectIds.add(projectId); // Sankey будет отрисован при следующем рендере } else { this.expandedProjectIds.delete(projectId); // Очистить Sankey для этого проекта const chart = this.sankeyCharts.get(projectId); if (chart) { chart.dispose(); this.sankeyCharts.delete(projectId); } } this.renderProjectsList(); this.dispatchEvent(new CustomEvent('project-expand', { detail: { projectId, expanded: project.expanded }, bubbles: true, composed: true })); } /** * Добавить пару графиков в проект * @public * @param {string} projectId - ID проекта * @returns {SchedulePair} */ addSchedulePair(projectId) { console.log(`[AppSettingsModal] Adding schedule pair to project: ${projectId}`); const project = this.projects.find(p => p.projectId === projectId); if (!project) { console.warn(`[AppSettingsModal] Project not found: ${projectId}`); return null; } /** @type {SchedulePair} */ const pair = { pairId: generateUUID(), projectId, currentScheduleId: '', targetScheduleId: '', currentDescription: '', targetDescription: '', currentKsgLoaded: false, targetKsgLoaded: false, status: 'pending', isValid: false }; project.schedulePairs.push(pair); this.validateProject(projectId); this.hasUnsavedChanges = true; // Точечное обновление DOM вместо полной перерисовки this.addPairToDOM(projectId, pair); this.updateProjectMeta(projectId); this.updateOkButtonState(); this.dispatchEvent(new CustomEvent('pair-add', { detail: { projectId, pair }, bubbles: true, composed: true })); return pair; } /** * Удалить пару графиков из проекта * @public * @param {string} projectId - ID проекта * @param {string} pairId - ID пары */ removeSchedulePair(projectId, pairId) { console.log(`[AppSettingsModal] Removing schedule pair: ${pairId} from project: ${projectId}`); const project = this.projects.find(p => p.projectId === projectId); if (!project) { console.warn(`[AppSettingsModal] Project not found: ${projectId}`); return; } const index = project.schedulePairs.findIndex(p => p.pairId === pairId); if (index === -1) return; project.schedulePairs.splice(index, 1); this.validateProject(projectId); this.hasUnsavedChanges = true; // Точечное обновление DOM вместо полной перерисовки this.removePairFromDOM(projectId, pairId); this.updateProjectMeta(projectId); this.updateOkButtonState(); // Если пар не осталось, показываем пустое состояние if (project.schedulePairs.length === 0) { this.showEmptyPairsState(projectId); } this.dispatchEvent(new CustomEvent('pair-remove', { detail: { projectId, pairId }, bubbles: true, composed: true })); } /** * Обновить идентификатор текущего графика * @public * @param {string} projectId - ID проекта * @param {string} pairId - ID пары * @param {string} scheduleId - Идентификатор графика (KSG ID = short_name) * @param {boolean} [skipRender=false] - Пропустить перерисовку (для реактивного ввода) */ updateCurrentScheduleId(projectId, pairId, scheduleId, skipRender = false) { console.log(`[AppSettingsModal] Updating current schedule ID for pair ${pairId}: "${scheduleId}"`); const pair = this.getPair(projectId, pairId); if (!pair) { console.warn(`[AppSettingsModal] Pair not found: ${pairId}`); return; } pair.currentScheduleId = scheduleId; pair.currentKsgLoaded = !!scheduleId.trim(); this.validatePair(pair); this.hasUnsavedChanges = true; if (!skipRender) { this.renderProjectsList(); } this.updateOkButtonState(); this.dispatchEvent(new CustomEvent('pair-update', { detail: { projectId, pairId, field: 'current', value: scheduleId }, bubbles: true, composed: true })); } /** * Обновить идентификатор целевого графика * @public * @param {string} projectId - ID проекта * @param {string} pairId - ID пары * @param {string} scheduleId - Идентификатор графика (KSG ID = short_name) * @param {boolean} [skipRender=false] - Пропустить перерисовку (для реактивного ввода) */ updateTargetScheduleId(projectId, pairId, scheduleId, skipRender = false) { console.log(`[AppSettingsModal] Updating target schedule ID for pair ${pairId}: "${scheduleId}"`); const pair = this.getPair(projectId, pairId); if (!pair) { console.warn(`[AppSettingsModal] Pair not found: ${pairId}`); return; } pair.targetScheduleId = scheduleId; pair.targetKsgLoaded = !!scheduleId.trim(); this.validatePair(pair); this.hasUnsavedChanges = true; if (!skipRender) { this.renderProjectsList(); } this.updateOkButtonState(); this.dispatchEvent(new CustomEvent('pair-update', { detail: { projectId, pairId, field: 'target', value: scheduleId }, bubbles: true, composed: true })); } /** * Установить статус загрузки КСГ * Вызывается при получении события 'ksg-status-change' от COMP-009 (PrimaveraDataLoader) * @public * @param {string} projectId - ID проекта * @param {string} pairId - ID пары * @param {'current'|'target'} type - Тип графика * @param {boolean} loaded - Загружен ли КСГ */ setKsgLoaded(projectId, pairId, type, loaded) { const pair = this.getPair(projectId, pairId); if (!pair) return; if (type === 'current') { pair.currentKsgLoaded = loaded; } else { pair.targetKsgLoaded = loaded; } this.renderProjectsList(); // NOTE: Этот метод НЕ генерирует событие 'ksg-status-change'. // Событие генерируется только COMP-009 (PrimaveraDataLoader). // COMP-008 только слушает это событие и обновляет свое состояние. } /** * Получить auth token из конфигурации, UserSettingsService или localStorage. * @private * @returns {Promise<string|null>} */ async _getAuthToken() { // Приоритет: in-memory JWT из единого auth flow const bearerToken = tokenService.valueStr(); if (bearerToken) { console.log('[AppSettingsModal] Using Bearer token from auth flow'); return bearerToken; } // Fallback для локальной разработки: DEV_BEARER_TOKEN из env.js if (config.DEV_BEARER_TOKEN) { console.log('[AppSettingsModal] Using DEV_BEARER_TOKEN from config'); return config.DEV_BEARER_TOKEN; } // Приоритет: зашифрованный токен в UserSettingsService (legacy) if (this._useUserSettings()) { try { const token = await UserSettingsService.getInstance().getToken(); if (token) { console.log('[AppSettingsModal] Using auth token from UserSettingsService'); return token; } } catch (e) { console.warn('[AppSettingsModal] Failed to get token from UserSettingsService:', e); } } // Fallback: legacy localStorage const token = localStorage.getItem('primavera_auth_token'); if (token) { console.log('[AppSettingsModal] Using auth token from localStorage'); } else { console.warn('[AppSettingsModal] No auth token found'); } return token || null; } /** * Выполнить SQL запрос к Primavera API * @private * @param {string} sql - SQL запрос * @returns {Promise<any[]>} */ async _executePrimaveraQuery(sql) { console.log('[AppSettingsModal] Executing Primavera query...'); console.log(`[AppSettingsModal] API URL: ${config.USE_LOCAL_API ? 'LOCAL' : 'PRODUCTION'}`); const authToken = await this._getAuthToken(); if (!authToken) { console.error('[AppSettingsModal] Cannot execute query: No auth token available'); throw new Error('No auth token available'); } const baseUrl = config.USE_LOCAL_API ? config.API_CONFIG.local.SQL : config.API_CONFIG.production.SQL; console.log(`[AppSettingsModal] Request URL: ${baseUrl}`); const response = await fetchWithAuth(baseUrl, { method: 'POST', body: JSON.stringify({ sql }) }); if (!response.ok) { if (response.status === 404) { throw new Error('404: Not found'); } throw new Error(`HTTP ${response.status}: ${await response.text()}`); } const data = await response.json(); console.log('[AppSettingsModal] Query response:', JSON.stringify(data)); // API возвращает [rows, metadata], возвращаем только rows if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0])) { return data[0]; } return Array.isArray(data) ? data : []; } /** * Обновить описание графика по API (production mode) * @public * @param {string} projectId - ID проекта * @param {string} pairId - ID пары * @param {string} field - Поле ('current' или 'target') * @param {HTMLElement} [button] - Кнопка обновления для анимации загрузки (опционально) */ async refreshScheduleDescription(projectId, pairId, field, button) { console.log(`[AppSettingsModal] Refreshing ${field} schedule description for pair: ${pairId}`); const pair = this.getPair(projectId, pairId); if (!pair) { console.warn(`[AppSettingsModal] Pair not found: ${pairId}`); return; } const scheduleId = field === 'current' ? pair.currentScheduleId : pair.targetScheduleId; if (!scheduleId.trim()) { console.warn(`[AppSettingsModal] Schedule ID is empty for ${field} field`); return; } console.log(`[AppSettingsModal] Looking up schedule ID: "${scheduleId}"`); // Проверяем наличие auth token const authToken = await this._getAuthToken(); if (!authToken) { console.error('[AppSettingsModal] No auth token available for API request'); this.showNotification('Требуется авторизация. Пожалуйста, войдите в систему.', 'error'); return; } // Добавляем класс загрузки (если кнопка передана) if (button) { button.classList.add('pair__refresh-btn--loading'); button.disabled = true; } try { // Сбрасываем флаг notFound перед запросом if (field === 'current') { pair.currentNotFound = false; } else { pair.targetNotFound = false; } // Реальный API запрос к Primavera // Оптимизированный запрос: сначала находим PROJ_ID по PROJ_SHORT_NAME, // затем ищем в PROJWBS только строки с этим PROJ_ID const sql = `SELECT w.WBS_SHORT_NAME, w.WBS_NAME FROM ADMUSER.PROJWBS w JOIN ADMUSER.PROJECT p ON w.PROJ_ID = p.PROJ_ID WHERE p.PROJ_SHORT_NAME = '${scheduleId.replace(/'/g, "''")}' AND w.WBS_SHORT_NAME = '${scheduleId.replace(/'/g, "''")}'`; console.log(`[AppSettingsModal] Executing SQL query for schedule: ${scheduleId}`); console.log(`[AppSettingsModal] SQL: ${sql}`); const rows = await this._executePrimaveraQuery(sql); console.log(`[AppSettingsModal] Rows returned: ${rows.length}`); if (rows.length === 0) { console.warn(`[AppSettingsModal] Schedule "${scheduleId}" not found in Primavera`); // Проект не найден в Primavera if (field === 'current') { pair.currentNotFound = true; pair.currentDescription = ''; } else { pair.targetNotFound = true; pair.targetDescription = ''; } // Обновляем отображение const descElement = this.shadowRoot.getElementById(`${field}-desc-${pairId}`); if (descElement) { descElement.innerHTML = `<span class="ksg-status__icon">${XCircle}</span><span class="ksg-status__text">Идентификатор не найден в Primavera</span>`; descElement.className = 'ksg-status ksg-status--error'; descElement.removeAttribute('title'); } } else { // Проект найден, обновляем описание const projectData = rows[0]; console.log('[AppSettingsModal] Raw project data:', JSON.stringify(projectData)); // Проверяем все возможные варианты имен полей (Oracle возвращает в верхнем регистре) const description = projectData.WBS_NAME || projectData.wbs_name || ''; console.log(`[AppSettingsModal] Found schedule "${scheduleId}", description: "${description}"`); if (field === 'current') { pair.currentDescription = description; pair.currentNotFound = false; } else { pair.targetDescription = description; pair.targetNotFound = false; } // Обновляем отображение const descElement = this.shadowRoot.getElementById(`${field}-desc-${pairId}`); if (descElement) { descElement.innerHTML = `<span class="ksg-status__icon">${Check}</span><span class="ksg-status__text">${this.escapeHtml(description)}</span>`; descElement.className = 'ksg-status ksg-status--success'; descElement.title = description; } console.log(`[AppSettingsModal] Description updated for ${field} schedule (will be saved on Apply)`); } } catch (error) { console.error('[AppSettingsModal] Failed to fetch schedule description:', error); if (error instanceof Error && error.message.includes('404')) { // Проект не найден if (field === 'current') { pair.currentNotFound = true; pair.currentDescription = ''; } else { pair.targetNotFound = true; pair.targetDescription = ''; } const descElement = this.shadowRoot.getElementById(`${field}-desc-${pairId}`); if (descElement) { descElement.innerHTML = `<span class="ksg-status__icon">${XCircle}</span><span class="ksg-status__text">Идентификатор не найден в Primavera</span>`; descElement.className = 'ksg-status ksg-status--error'; descElement.removeAttribute('title'); } } else { ToastManager.getInstance().error('Ошибка получения данных из Primavera', 5000); } } finally { // Убираем класс загрузки (если кнопка передана) if (button) { button.classList.remove('pair__refresh-btn--loading'); button.disabled = false; } } } /** * Обновить описания всех графиков проекта * @public * @param {string} projectId - ID проекта * @param {HTMLElement} button - Кнопка обновления для анимации загрузки */ async refreshAllProjectDescriptions(projectId, button) { console.log(`[AppSettingsModal] Refreshing all descriptions for project: ${projectId}`); const project = this.projects.find(p => p.projectId === projectId); if (!project) { console.warn(`[AppSettingsModal] Project not found: ${projectId}`); return; } if (project.schedulePairs.length === 0) { console.log(`[AppSettingsModal] No pairs to refresh for project: ${projectId}`); return; } console.log(`[AppSettingsModal] Refreshing ${project.schedulePairs.length} pairs`); // Добавляем класс загрузки button.classList.add('pair__refresh-btn--loading'); button.disabled = true; try { // Обновляем все пары последовательно for (const pair of project.schedulePairs) { // Обновляем текущий график если есть if (pair.currentScheduleId.trim()) { const currentBtn = this.shadowRoot.querySelector( `.pair__refresh-btn[data-pair-id="${pair.pairId}"][data-field="current"]` ); // Передаём найденную кнопку или null (если проект свёрнут) await this.refreshScheduleDescription(projectId, pair.pairId, 'current', currentBtn); } // Обновляем целевой график если есть if (pair.targetScheduleId.trim()) { const targetBtn = this.shadowRoot.querySelector( `.pair__refresh-btn[data-pair-id="${pair.pairId}"][data-field="target"]` ); // Передаём найденную кнопку или null (если проект свёрнут) await this.refreshScheduleDescription(projectId, pair.pairId, 'target', targetBtn); } } // Если проект был свёрнут, данные обновились но UI не отразил изменения // Перерисовываем список проектов чтобы показать обновлённые статусы if (!project.expanded) { this.renderProjectsList(); } } finally { // Убираем класс загрузки с кнопки проекта button.classList.remove('pair__refresh-btn--loading'); button.disabled = false; } } /** * Добавить пару в DOM без полной перерисовки * @private * @param {string} projectId * @param {SchedulePair} pair */ addPairToDOM(projectId, pair) { const pairsList = this.shadowRoot?.querySelector(`.project[data-project-id="${projectId}"] .pairs-list`); if (!pairsList) return; // Удаляем сообщение о пустом состоянии если есть const emptyState = pairsList.querySelector('.pairs-empty'); if (emptyState) { emptyState.remove(); } // Создаём элемент пары const pairHTML = this.renderPairRow(projectId, pair); const tempDiv = document.createElement('div'); tempDiv.innerHTML = pairHTML; const pairElement = tempDiv.firstElementChild; if (!pairElement) return; // Анимация появления pairElement.style.opacity = '0'; pairElement.style.transform = 'translateY(-10px)'; pairElement.style.transition = 'opacity 0.2s ease, transform 0.2s ease'; pairsList.appendChild(pairElement); // Навешиваем обработчики на новую пару this.attachPairEventListeners(pairElement, projectId, pair.pairId); // Запускаем анимацию requestAnimationFrame(() => { pairElement.style.opacity = '1'; pairElement.style.transform = 'translateY(0)'; }); } /** * Удалить пару из DOM без полной перерисовки * @private * @param {string} projectId * @param {string} pairId */ removePairFromDOM(projectId, pairId) { const pairRow = this.shadowRoot?.querySelector(`.project[data-project-id="${projectId}"] .pair-row[data-pair-id="${pairId}"]`); if (!pairRow) return; // Анимация исчезновения pairRow.style.transition = 'opacity 0.2s ease, transform 0.2s ease'; pairRow.style.opacity = '0'; pairRow.style.transform = 'translateX(20px)'; // Удаляем после анимации setTimeout(() => { pairRow.remove(); }, 200); } /** * Обновить мета-информацию проекта (счётчик графиков) * @private * @param {string} projectId */ updateProjectMeta(projectId) { const project = this.projects.find(p => p.projectId === projectId); if (!project) return; const projectEl = this.shadowRoot?.querySelector(`.project[data-project-id="${projectId}"]`); if (!projectEl) return; // Обновляем счётчик графиков const metaEl = projectEl.querySelector('.project__meta'); if (metaEl) { metaEl.textContent = this.getScheduleWord(project.schedulePairs.length); } // Обновляем статусы КСГ в свёрнутом виде const header = projectEl.querySelector('.project__header'); if (header && !project.expanded) { // Удаляем старые статусы если есть const oldStatuses = header.querySelector('.project__ksg-statuses'); if (oldStatuses) { oldStatuses.remove(); } // Добавляем новые статусы if (project.schedulePairs.length > 0) { const statusesHTML = this.renderProjectKsgStatuses(project); const tempDiv = document.createElement('div'); tempDiv.innerHTML = statusesHTML; const statusesEl = tempDiv.firstElementChild; if (statusesEl) { const actionsEl = header.querySelector('.project__actions'); if (actionsEl) { header.insertBefore(statusesEl, actionsEl); } } } } } /** * Показать пустое состояние для списка пар * @private * @param {string} projectId */ showEmptyPairsState(projectId) { const pairsList = this.shadowRoot?.querySelector(`.project[data-project-id="${projectId}"] .pairs-list`); if (!pairsList) return; pairsList.innerHTML = ` <div class="pairs-empty"> Нет пар графиков. Нажмите "+ КСГ" чтобы добавить. </div> `; } /** * Навесить обработчики событий на конкретную пару * @private * @param {HTMLElement} pairElement * @param {string} projectId * @param {string} pairId */ attachPairEventListeners(pairElement, projectId, pairId) { // Кнопка удаления const removeBtn = pairElement.querySelector('.pair__remove-btn'); if (removeBtn) { removeBtn.addEventListener('click', () => { this.removeSchedulePair(projectId, pairId); }); } // Кнопка обновления описания const refreshBtns = pairElement.querySelectorAll('.pair__refresh-btn'); refreshBtns.forEach(btn => { btn.addEventListener('click', () => { const field = btn.getAttribute('data-field'); if (field) { this.refreshScheduleDescription(projectId, pairId, field, btn); } }); }); // Ввод в поля const inputs = pairElement.querySelectorAll('.pair__input'); inputs.forEach(input => { input.addEventListener('input', (e) => { const target = /** @type {HTMLInputElement} */ (e.target); const field = target.getAttribute('data-field'); if (!field) return; this.debouncedScheduleInput(projectId, pairId, field, target.value); }); }); } /** * Валидировать проект * @public * @param {string} projectId - ID проекта * @returns {ValidationError[]} */ validateProject(projectId) { console.log(`[AppSettingsModal] Validating project: ${projectId}`); const project = this.projects.find(p => p.projectId === projectId); if (!project) { console.warn(`[AppSettingsModal] Project not found for validation: ${projectId}`); return []; } /** @type {ValidationError[]} */ const errors = []; // Проверка наличия пар if (project.schedulePairs.length === 0) { errors.push({ code: 'NO_PAIRS', message: 'Проект должен содержать минимум одну пару графиков' }); } // Валидация каждой пары project.schedulePairs.forEach(pair => { this.validatePair(pair); if (!pair.isValid) { errors.push({ code: 'MISSING_TARGET', message: `Пара "${pair.currentScheduleId || 'Новая'}" не имеет целевого графика`, pairId: pair.pairId }); } }); this.validationErrors.set(projectId, errors); if (errors.length > 0) { this.dispatchEvent(new CustomEvent('validation-error', { detail: { projectId, errors }, bubbles: true, composed: true })); } return errors; } /** * Показать уведомление * @private * @param {string} message - Сообщение * @param {'success'|'warning'|'error'} type - Тип уведомления */ showNotification(message, type = 'info') { const statusMap = { success: 'success', warning: 'warning', error: 'alert', info: 'normal' }; ToastManager.getInstance().show(message, { status: statusMap[type] || 'normal', duration: 4000 }); } /** * Экспортировать настройки * @public * @returns {Promise<void>} */ async exportSettings() { console.log('[AppSettingsModal] Exporting settings...'); /** @type {AppSettings} */ const settings = { version: '1.2.0', exportedAt: new Date().toISOString(), applicationVersion: '2.0.0', data: { projects: this.projects, ui: { theme: document.documentElement.getAttribute('data-theme') || 'consta-light' } } }; const filename = `deepdive-settings_${formatDateForFilename(new Date())}_${new Date().getHours()}-${new Date().getMinutes()}.json`; const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this.dispatchEvent(new CustomEvent('settings-export', { detail: { filename, settings }, bubbles: true, composed: true })); ToastManager.getInstance().success(`Настройки экспортированы: ${filename}`, 4000); console.log(`[AppSettingsModal] Settings exported to: ${filename}`); } /** * Импортировать настройки * @public * @param {File} file - JSON файл * @returns {Promise<SettingsImportResult>} */ async importSettings(file) { console.log(`[AppSettingsModal] Importing settings from file: ${file.name}`); try { const text = await file.text(); const settings = JSON.parse(text); // Валидация if (!this.validateSettings(settings)) { throw new Error('Неверный формат файла настроек'); } // Показать диалог подтверждения const projectCount = settings.data.projects?.length || 0; const pairCount = settings.data.projects?.reduce((sum, p) => sum + (p.schedulePairs?.length || 0), 0) || 0; const confirmed = confirm( `Импортировать настройки?\n\n` + `Проекты: ${projectCount}\n` + `Пары графиков: ${pairCount}\n\n` + `Текущие настройки будут заменены.` ); if (!confirmed) { return { success: false, importedProjects: 0, importedPairs: 0, warnings: ['Импорт отменён пользователем'] }; } // Применить настройки this.projects = settings.data.projects || []; this.expandedProjectIds.clear(); this.validationErrors.clear(); // Валидация всех проектов this.projects.forEach(project => { this.validateProject(project.projectId); if (project.expanded) { this.expandedProjectIds.add(project.projectId); } }); this.hasUnsavedChanges = true; // Обновить UI this.renderProjectsList(); this.updateOkButtonState(); const result = { success: true, importedProjects: projectCount, importedPairs: pairCount, warnings: [] }; console.log(`[AppSettingsModal] Settings imported successfully: ${projectCount} projects, ${pairCount} pairs`); this.dispatchEvent(new CustomEvent('settings-import', { detail: { result }, bubbles: true, composed: true })); ToastManager.getInstance().success( `Настройки импортированы: ${result.importedProjects} проектов, ${result.importedPairs} пар`, 4000 ); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error(`[AppSettingsModal] Import failed: ${errorMessage}`); ToastManager.getInstance().error(`Ошибка импорта настроек: ${errorMessage}`, 5000); this.dispatchEvent(new CustomEvent('settings-import-error', { detail: { error: errorMessage, fileName: file.name }, bubbles: true, composed: true })); return { success: false, importedProjects: 0, importedPairs: 0, warnings: [errorMessage] }; } } /** * Проверить валидность файла настроек * @public * @param {unknown} settings * @returns {boolean} */ validateSettings(settings) { console.log('[AppSettingsModal] Validating settings file...'); if (!settings || typeof settings !== 'object') { console.warn('[AppSettingsModal] Invalid settings: not an object'); return false; } const s = /** @type {AppSettings} */ (settings); // Проверка обязательных полей if (!s.version || !s.data) return false; // Проверка версии (поддерживаем 1.x) const majorVersion = s.version.split('.')[0]; if (majorVersion !== '1') { console.warn('[AppSettingsModal] Settings version not supported:', s.version); } else { console.log(`[AppSettingsModal] Settings version: ${s.version}`); } // Проверка данных if (!Array.isArray(s.data.projects)) { console.warn('[AppSettingsModal] Invalid settings: projects is not an array'); return false; } console.log(`[AppSettingsModal] Settings validation passed: ${s.data.projects.length} projects`); return true; } // ==================== Private Helper Methods ==================== /** * Получить пару по ID * @private * @param {string} projectId * @param {string} pairId * @returns {SchedulePair|undefined} */ getPair(projectId, pairId) { const project = this.projects.find(p => p.projectId === projectId); if (!project) return undefined; return project.schedulePairs.find(p => p.pairId === pairId); } /** * Валидировать пару * @private * @param {SchedulePair} pair */ validatePair(pair) { // Пара валидна только если есть и current, и target scheduleId pair.isValid = !!( pair.currentScheduleId?.trim() && pair.targetScheduleId?.trim() ); } /** * Генерировать данные для Sankey * @public * @param {string} projectId * @returns {SankeyData} */ generateSankeyData(projectId) { const project = this.projects.find(p => p.projectId === projectId); if (!project) return { nodes: [], links: [] }; /** @type {SankeyNode[]} */ const nodes = []; /** @type {SankeyLink[]} */ const links = []; project.schedulePairs.forEach((pair) => { if (!pair.currentScheduleId) return; const currentId = `current-${pair.pairId}`; const targetId = `target-${pair.pairId}`; // Current node (зелёный) nodes.push({ id: currentId, name: pair.currentDescription || pair.currentScheduleId, color: '#22C38E', type: 'current' }); // Target node (фиолетовый) - только если есть target if (pair.targetScheduleId) { nodes.push({ id: targetId, name: pair.targetDescription || pair.targetScheduleId, color: '#9A7EE3', type: 'target' }); // Link between them links.push({ source: currentId, target: targetId, value: 1, color: 'rgba(154, 126, 227, 0.3)' }); } }); return { nodes, links }; } /** * Отрисовать Sankey диаграмму * @public * @param {string} projectId */ renderSankeyDiagram(projectId) { if (!window.echarts) return; const container = this.shadowRoot?.querySelector(`#sankey-${projectId}`); if (!container) return; // Очистить предыдущую диаграмму const existingChart = this.sankeyCharts.get(projectId); if (existingChart) { existingChart.dispose(); } const sankeyData = this.generateSankeyData(projectId); if (sankeyData.nodes.length === 0) { container.innerHTML = ` <div class="sankey-empty"> <span>Добавьте пары графиков для визуализации</span> </div> `; return; } const chart = window.echarts.init(container); this.sankeyCharts.set(projectId, chart); const isDark = this.getAttribute('data-theme') === 'consta-dark'; const textColor = getComputedStyle(this).getPropertyValue('--text-primary').trim() || (isDark ? '#fff' : '#002033'); const option = { backgroundColor: 'transparent', tooltip: { trigger: 'item', triggerOn: 'mousemove' }, series: [{ type: 'sankey', layout: 'none', emphasis: { focus: 'adjacency' }, nodeAlign: 'left', data: sankeyData.nodes.map(n => ({ ...n, itemStyle: { color: n.color, borderColor: n.color }, label: { color: textColor, fontSize: 11 } })), links: sankeyData.links.map(l => ({ ...l, lineStyle: { color: l.color, opacity: 0.4 } })), lineStyle: { color: 'gradient', curveness: 0.5 }, label: { position: 'right', fontFamily: 'Inter, sans-serif' } }] }; chart.setOption(option); this.dispatchEvent(new CustomEvent('sankey-render', { detail: { projectId, data: sankeyData }, bubbles: true, composed: true })); } // ==================== Event Handlers ==================== /** * Обработчик ввода идентификатора графика * @private * @param {string} projectId * @param {string} pairId * @param {'current'|'target'} type * @param {string} value */ _handleScheduleInput(projectId, pairId, type, value) { console.log(`[AppSettingsModal] Schedule input changed [${type}]: "${value}"`); // Сбрасываем статусы при изменении значения const pair = this.getPair(projectId, pairId); if (pair) { if (type === 'current') { pair.currentNotFound = false; pair.currentDescription = ''; } else { pair.targetNotFound = false; pair.targetDescription = ''; } } if (type === 'current') { this.updateCurrentScheduleId(projectId, pairId, value, true); } else { this.updateTargetScheduleId(projectId, pairId, value, true); } // Обновляем только статус-подпись под полем (без перерисовки всей карточки) this.updateFieldStatus(projectId, pairId, type); } /** * Обновить только статус-подпись под полем ввода (реактивно, без перерисовки карточки) * @private * @param {string} projectId * @param {string} pairId * @param {'current'|'target'} field */ updateFieldStatus(projectId, pairId, field) { const pair = this.getPair(projectId, pairId); if (!pair) return; const descId = `${field}-desc-${pairId}`; const statusEl = this.shadowRoot?.querySelector(`#${descId}`); if (!statusEl) return; const scheduleId = field === 'current' ? pair.currentScheduleId : pair.targetScheduleId; const description = field === 'current' ? pair.currentDescription : pair.targetDescription; const notFound = field === 'current' ? pair.currentNotFound : pair.targetNotFound; // Определяем новый статус и контент let newClass = ''; let newContent = ''; let newTitle = ''; if (!scheduleId.trim()) { newClass = 'ksg-status ksg-status--required'; newContent = `<span class="ksg-status__icon">${AlertTriangle}</span><span class="ksg-status__text">Необходимо ввести идентификатор КСГ</span>`; } else if (notFound) { newClass = 'ksg-status ksg-status--error'; newContent = `<span class="ksg-status__icon">${XCircle}</span><span class="ksg-status__text">Идентификатор не найден в Primavera</span>`; } else if (description) { newClass = 'ksg-status ksg-status--success'; newContent = `<span class="ksg-status__icon">${Check}</span><span class="ksg-status__text">${this.escapeHtml(description)}</span>`; newTitle = this.escapeHtml(description); } else { newClass = 'ksg-status ksg-status--hint'; newContent = `<span class="ksg-status__icon">${Info}</span><span class="ksg-status__text">Обновите чтобы получить описание проекта</span>`; } // Обновляем классы и содержимое без замены элемента (чтобы не терять фокус) statusEl.className = newClass; statusEl.innerHTML = newContent; if (newTitle) { statusEl.setAttribute('title', newTitle); } else { statusEl.removeAttribute('title'); } } /** * Обновить видимость модалки * @private */ updateVisibility() { const overlay = this.shadowRoot?.querySelector('.modal-overlay'); if (overlay instanceof HTMLElement) { overlay.style.display = this.isOpen ? 'flex' : 'none'; if (this.isOpen) { document.addEventListener('keydown', this.handleKeyDown); this.renderProjectsList(); } else { document.removeEventListener('keydown', this.handleKeyDown); } } } /** * Обработчик нажатия клавиш * @private * @param {KeyboardEvent} e */ handleKeyDown(e) { if (e.key === 'Escape' && this.isOpen) { this.cancelAndClose(); } } /** * Настроить обработчики событий * @private */ setupEventListeners() { const shadow = this.shadowRoot; if (!shadow) return; // Tab switching shadow.querySelectorAll('.modal__tab').forEach(tab => { tab.addEventListener('click', () => { const tabName = tab.getAttribute('data-tab'); if (tabName) this._switchTab(tabName); }); }); // Кнопка полноэкранного режима shadow.querySelector('.modal__fullscreen')?.addEventListener('click', () => { this.toggleFullscreen(); }); // Кнопка закрытия (X) - работает как Отмена shadow.querySelector('.modal__close')?.addEventListener('click', () => { this.cancelAndClose(); }); shadow.querySelector('.modal-overlay')?.addEventListener('click', (e) => { if (e.target === e.currentTarget) this.cancelAndClose(); }); // Кнопки экспорта/импорта настроек shadow.querySelector('.settings-btn--export')?.addEventListener('click', () => { this.exportSettings(); }); const importInput = shadow.querySelector('.settings-import-input'); // Label сам активирует input при клике через стандартное поведие браузера // Просто останавливаем всплытие чтобы не конфликтовать с другими обработчиками shadow.querySelector('.settings-btn--import')?.addEventListener('click', (e) => { e.stopPropagation(); }); importInput?.addEventListener('change', async (e) => { const target = /** @type {HTMLInputElement} */ (e.target); const file = target.files?.[0]; if (file) { await this.importSettings(file); // Сброс input для повторной загрузки того же файла target.value = ''; } }); // Layout presets: Save Current shadow.querySelector('.layouts-save-current-btn')?.addEventListener('click', () => { this._saveCurrentPreset(); }); // Layout presets: Import const layoutImportInput = shadow.querySelector('.layouts-import-input'); shadow.querySelector('.layouts-import-btn')?.addEventListener('click', (e) => { e.stopPropagation(); }); layoutImportInput?.addEventListener('change', async (e) => { const target = /** @type {HTMLInputElement} */ (e.target); const file = target.files?.[0]; if (file) { await this._importPreset(file); target.value = ''; } }); // Presets: Create new (open builder) shadow.querySelector('.presets-create-btn')?.addEventListener('click', () => { this._createPreset(); }); // Кнопка добавления проекта shadow.querySelector('.add-project-btn')?.addEventListener('click', () => { this.openCreateProjectDialog(); }); // Кнопка ОК shadow.querySelector('.modal__ok-btn')?.addEventListener('click', async () => { await this.applyAndClose(); }); // Кнопка Отмена shadow.querySelector('.modal__cancel-btn')?.addEventListener('click', () => { this.cancelAndClose(); }); } /** * Очистить обработчики событий * @private */ cleanupEventListeners() { document.removeEventListener('keydown', this.handleKeyDown); } /** * Открыть диалог создания проекта * @private */ openCreateProjectDialog() { const project = this.createProject(''); // Активировать режим редактирования названия для нового проекта this.editingProjectNames.add(project.projectId); this.renderProjectsList(); // Установить фокус на input после рендера setTimeout(() => { const input = this.shadowRoot?.querySelector(`.project__name-input[data-project-id="${project.projectId}"]`); if (input instanceof HTMLInputElement) { input.focus(); } }, 0); } /** * Переключить режим редактирования названия проекта * @private * @param {string} projectId - ID проекта */ toggleProjectNameEdit(projectId) { this.editingProjectNames.add(projectId); this.renderProjectsList(); // Установить фокус на input после рендера setTimeout(() => { const input = this.shadowRoot?.querySelector(`.project__name-input[data-project-id="${projectId}"]`); if (input instanceof HTMLInputElement) { input.focus(); input.select(); } }, 0); } /** * Сохранить название проекта * @private * @param {string} projectId - ID проекта * @param {string} name - Новое название */ saveProjectName(projectId, name) { const trimmedName = name.trim(); if (trimmedName) { this.editingProjectNames.delete(projectId); this.updateProject(projectId, { name: trimmedName }); } else { // Не сохраняем пустое имя, оставляем в режиме редактирования // Пользователь должен ввести название или удалить проект this.editingProjectNames.add(projectId); this.updateOkButtonState(); } } // ==================== Layout Presets Methods ==================== /** * Загрузить и отрисовать список макетов * @private */ async _loadAndRenderPresets() { const container = this.shadowRoot?.querySelector('#layouts-list'); if (!container) return; try { const lps = LayoutPresetService.getInstance(); await lps.init(); const presets = await lps.listPresets(); const active = lps.getActivePreset(); this._renderPresetsList(presets, active?.presetId || ''); } catch (e) { console.error('[AppSettingsModal] Failed to load presets:', e); container.innerHTML = `<div class="layouts-empty">Ошибка загрузки макетов</div>`; } } /** * Отрисовать список макетов * @private * @param {import('../services/LayoutPresetService.js').LevelPreset[]} presets * @param {string} activePresetId */ _renderPresetsList(presets, activePresetId) { const container = this.shadowRoot?.querySelector('#layouts-list'); if (!container) return; if (presets.length === 0) { container.innerHTML = ` <div class="layouts-empty"> ${PlusSquare} <p>Нет сохранённых макетов</p> <span>Сохраните текущий макет или импортируйте из файла</span> </div> `; return; } container.innerHTML = presets.map(preset => { const isActive = preset.presetId === activePresetId; const dateStr = preset.updatedAt ? new Date(preset.updatedAt).toLocaleDateString('ru-RU') : ''; const levelCount = preset.levels?.length || 0; return ` <div class="layout-item ${isActive ? 'layout-item--active' : ''}" data-preset-id="${preset.presetId}"> <div class="layout-item__icon">${Layout}</div> <div class="layout-item__info"> <div class="layout-item__name">${this.escapeHtml(preset.name)}</div> <div class="layout-item__meta"> ${preset.isBuiltIn ? '<span class="layout-item__badge layout-item__badge--builtin">Встроенный</span>' : ''} ${isActive ? '<span class="layout-item__badge">Активный</span>' : ''} <span>${levelCount} ур.</span> ${dateStr ? `<span>${dateStr}</span>` : ''} </div> </div> <div class="layout-item__actions"> <button class="layout-item__btn layout-item__btn--activate" data-action="activate-preset" data-preset-id="${preset.presetId}" title="Активировать макет" aria-label="Активировать"> ${Play} </button> <button class="layout-item__btn layout-item__btn--edit" data-action="edit-preset" data-preset-id="${preset.presetId}" title="Редактировать макет" aria-label="Редактировать"> ${Edit} </button> <button class="layout-item__btn layout-item__btn--export" data-action="export-preset" data-preset-id="${preset.presetId}" title="Экспортировать в файл" aria-label="Экспорт"> ${Download} </button> ${!preset.isBuiltIn ? ` <button class="layout-item__btn layout-item__btn--delete" data-action="delete-preset" data-preset-id="${preset.presetId}" data-preset-name="${this.escapeHtml(preset.name)}" title="Удалить макет" aria-label="Удалить"> ${Trash} </button> ` : ''} </div> </div> `; }).join(''); // Attach delegated listeners container.querySelectorAll('[data-action="activate-preset"]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const presetId = btn.getAttribute('data-preset-id'); if (presetId) this._activatePreset(presetId); }); }); container.querySelectorAll('[data-action="export-preset"]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const presetId = btn.getAttribute('data-preset-id'); if (presetId) this._exportPreset(presetId); }); }); container.querySelectorAll('[data-action="delete-preset"]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const presetId = btn.getAttribute('data-preset-id'); const presetName = btn.getAttribute('data-preset-name') || ''; if (presetId) this._deletePreset(presetId, presetName); }); }); container.querySelectorAll('[data-action="edit-preset"]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const presetId = btn.getAttribute('data-preset-id'); if (presetId) this._editPreset(presetId); }); }); } /** * Сохранить текущий макет из SummaryDrillNavigator * @private */ async _saveCurrentPreset() { try { const lps = LayoutPresetService.getInstance(); await lps.init(); // Find the active drill navigator const navigator = document.querySelector('summary-drill-navigator'); const preset = lps.serializeFromDrillNavigator(navigator); if (!preset) { ToastManager.getInstance().warning('Нет активного макета для сохранения', 4000); return; } const name = prompt('Название макета:', preset.name); if (!name || !name.trim()) return; preset.name = name.trim(); await lps.savePreset(preset); ToastManager.getInstance().success(`Макет "${preset.name}" сохранён`, 4000); await this._loadAndRenderPresets(); } catch (e) { console.error('[AppSettingsModal] Failed to save preset:', e); ToastManager.getInstance().error('Ошибка сохранения макета', 5000); } } /** * Активировать выбранный макет * @private * @param {string} presetId */ async _activatePreset(presetId) { try { const lps = LayoutPresetService.getInstance(); await lps.init(); await lps.setActivePreset(presetId); ToastManager.getInstance().success('Макет активирован', 3000); await this._loadAndRenderPresets(); // Notify app to re-apply preset to navigator this.dispatchEvent(new CustomEvent('preset-activated', { detail: { presetId }, bubbles: true, composed: true })); } catch (e) { console.error('[AppSettingsModal] Failed to activate preset:', e); ToastManager.getInstance().error('Ошибка активации макета', 5000); } } /** * Экспортировать макет в файл * @private * @param {string} presetId */ async _exportPreset(presetId) { try { const lps = LayoutPresetService.getInstance(); await lps.init(); const json = lps.exportPresetToJSON(presetId); const preset = await lps.getPreset(presetId); const safeName = sanitizeFilename(preset?.name || 'preset'); const filename = `deepdive-preset_${safeName}_${formatDateForFilename(new Date())}.json`; const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); ToastManager.getInstance().success(`Макет экспортирован: ${filename}`, 4000); } catch (e) { console.error('[AppSettingsModal] Failed to export preset:', e); ToastManager.getInstance().error('Ошибка экспорта макета', 5000); } } /** * Импортировать макет из файла * @private * @param {File} file */ async _importPreset(file) { try { const text = await file.text(); const lps = LayoutPresetService.getInstance(); await lps.init(); const preset = lps.importPresetFromJSON(text); const name = prompt('Название импортированного макета:', preset.name); if (name && name.trim()) { preset.name = name.trim(); } await lps.savePreset(preset); ToastManager.getInstance().success(`Макет "${preset.name}" импортирован`, 4000); await this._loadAndRenderPresets(); document.dispatchEvent(new CustomEvent('preset-imported', { detail: { preset } })); } catch (e) { console.error('[AppSettingsModal] Failed to import preset:', e); const msg = e instanceof Error ? e.message : 'Unknown error'; ToastManager.getInstance().error(`Ошибка импорта: ${msg}`, 5000); } } /** * Удалить макет с подтверждением * @private * @param {string} presetId * @param {string} presetName */ async _deletePreset(presetId, presetName) { const confirmed = confirm(`Удалить макет "${presetName}"?\n\nЭто действие нельзя отменить.`); if (!confirmed) return; try { const lps = LayoutPresetService.getInstance(); await lps.init(); const deleted = await lps.deletePreset(presetId); if (deleted) { ToastManager.getInstance().success('Макет удалён', 3000); await this._loadAndRenderPresets(); } } catch (e) { console.error('[AppSettingsModal] Failed to delete preset:', e); ToastManager.getInstance().error('Ошибка удаления макета', 5000); } } /** * Редактировать макет в конструкторе * @private * @param {string} presetId */ async _editPreset(presetId) { try { const lps = LayoutPresetService.getInstance(); await lps.init(); const preset = await lps.getPreset(presetId); if (!preset) { ToastManager.getInstance().error('Макет не найден', 4000); return; } const builder = this.shadowRoot?.querySelector('#preset-builder'); if (builder) { builder.open(preset); this._showPresetsBuilder(); } } catch (e) { console.error('[AppSettingsModal] Failed to edit preset:', e); ToastManager.getInstance().error('Ошибка загрузки макета', 5000); } } /** * Создать новый макет в конструкторе * @private */ _createPreset() { const builder = this.shadowRoot?.querySelector('#preset-builder'); if (builder) { builder.open(); this._showPresetsBuilder(); } } /** * Показать конструктор макетов (скрыть список) * @private */ _showPresetsBuilder() { this._presetsView = 'builder'; const shadow = this.shadowRoot; if (!shadow) return; shadow.querySelector('.presets-list-view')?.classList.remove('presets-list-view--active'); shadow.querySelector('.presets-builder-view')?.classList.add('presets-builder-view--active'); } /** * Показать список макетов (скрыть конструктор) * @private */ _showPresetsList() { this._presetsView = 'list'; const shadow = this.shadowRoot; if (!shadow) return; shadow.querySelector('.presets-builder-view')?.classList.remove('presets-builder-view--active'); shadow.querySelector('.presets-list-view')?.classList.add('presets-list-view--active'); } // ==================== Rendering Methods ==================== /** * Отрисовать компонент * @private */ render() { if (!this.shadowRoot) return; this.shadowRoot.innerHTML = ` <style>${this.getStyles()}</style> <div class="modal-overlay" style="display: none;"> <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title"> <!-- Header --> <div class="modal__header"> <h2 id="modal-title" class="modal__title"> <span class="modal__title-icon">${Settings}</span> Настройки приложения </h2> <div class="modal__header-actions"> <button class="modal__fullscreen" aria-label="Развернуть на весь экран"> ${Maximize} </button> <button class="modal__close" aria-label="Закрыть"> ${X} </button> </div> </div> <!-- Tabs --> <div class="modal__tabs"> <button class="modal__tab ${this._activeTab === 'projects' ? 'modal__tab--active' : ''}" data-tab="projects"> Проекты </button> <button class="modal__tab ${this._activeTab === 'presets' ? 'modal__tab--active' : ''}" data-tab="presets"> Макеты </button> </div> <!-- Body --> <div class="modal__body"> <!-- Projects Tab Panel --> <div class="tab-panel ${this._activeTab === 'projects' ? 'tab-panel--active' : ''}" data-tab="projects"> <div class="projects-section"> <div class="section__header"> <h3 class="section__title">Определите проекты и КСГ для них</h3> <button class="btn btn--primary add-project-btn"> ${Plus} Проект </button> </div> <!-- Projects List --> <div class="projects-list"> <!-- Projects will be rendered here --> </div> </div> </div> <!-- Presets Tab Panel --> <div class="tab-panel ${this._activeTab === 'presets' ? 'tab-panel--active' : ''}" data-tab="presets"> <!-- Presets List View --> <div class="presets-list-view ${this._presetsView === 'list' ? 'presets-list-view--active' : ''}"> <div class="presets-list-compact"> <div class="section__header"> <h3 class="section__title">Макеты аналитики</h3> <button class="btn btn--primary layouts-save-current-btn" title="Сохранить текущий макет"> ${Save} Сохранить текущий </button> </div> <div class="layouts-list" id="layouts-list"> <div class="layouts-empty">Загрузка макетов...</div> </div> <div class="layouts-actions"> <label class="btn btn--secondary layouts-import-btn"> ${Upload} Импорт из файла <input type="file" class="layouts-import-input" accept=".json" hidden> </label> <button class="btn btn--primary presets-create-btn" title="Создать новый макет"> ${Plus} Конструктор </button> </div> </div> </div> <!-- Presets Builder View --> <div class="presets-builder-view ${this._presetsView === 'builder' ? 'presets-builder-view--active' : ''}"> <preset-builder-canvas inline id="preset-builder"></preset-builder-canvas> </div> </div> </div> <!-- Footer --> <div class="modal__footer"> <div class="modal__footer-left"> <button class="btn btn--secondary settings-btn--export"> ${Download} Экспорт настроек </button> <label class="btn btn--secondary settings-btn--import"> ${Upload} Импорт настроек <input type="file" class="settings-import-input" accept=".json" hidden> </label> </div> <div class="modal__footer-right"> <button class="btn btn--secondary modal__cancel-btn">Отмена</button> <button class="btn btn--primary modal__ok-btn" disabled>Применить</button> </div> </div> </div> </div> `; } /** * Отрисовать список проектов * @private */ renderProjectsList() { const container = this.shadowRoot?.querySelector('.projects-list'); if (!container) return; if (this.projects.length === 0) { container.innerHTML = ` <div class="empty-state"> ${PlusSquare} <p>Нет проектов</p> <span>Нажмите "+ Проект" чтобы добавить первый проект</span> </div> `; return; } container.innerHTML = this.projects.map(project => this.renderProject(project)).join(''); // Навесить обработчики this.attachProjectEventListeners(container); // Отрисовать Sankey для развёрнутых проектов this.projects.forEach(project => { if (project.expanded) { setTimeout(() => this.renderSankeyDiagram(project.projectId), 0); } }); } /** * Отрисовать проект * @private * @param {Project} project * @returns {string} */ renderProject(project) { const isExpanded = project.expanded; const errors = this.validationErrors.get(project.projectId) || []; const hasErrors = errors.length > 0; return ` <div class="project ${isExpanded ? 'project--expanded' : ''}" data-project-id="${project.projectId}"> <!-- Project Header --> <div class="project__header" data-action="toggle-expand"> <span class="project__expand-icon ${isExpanded ? 'project__expand-icon--expanded' : ''}"> ${ChevronRight} </span> <div class="project__info"> ${this.renderProjectName(project)} <span class="project__meta"> ${this.getScheduleWord(project.schedulePairs.length)} </span> </div> ${!isExpanded ? this.renderProjectKsgStatuses(project) : ''} <div class="project__actions"> <button class="project__action-btn project__action-btn--refresh" data-project-id="${project.projectId}" data-action="refresh-all" aria-label="Обновить описания всех графиков проекта" title="Обновить все описания"> ${RefreshCw} </button> <button class="project__action-btn" data-project-id="${project.projectId}" data-action="edit" aria-label="Редактировать проект" title="Редактировать"> ${Edit} </button> <button class="project__action-btn project__action-btn--delete" data-project-id="${project.projectId}" data-action="delete" aria-label="Удалить проект" title="Удалить"> ${Trash} </button> </div> </div> ${isExpanded ? this.renderProjectContent(project) : ''} </div> `; } /** * Отрисовать статусы КСГ для свёрнутой карточки проекта * @private * @param {Project} project * @returns {string} */ renderProjectKsgStatuses(project) { if (project.schedulePairs.length === 0) { return ''; } return ` <div class="project__ksg-statuses"> ${project.schedulePairs.map(pair => this.renderKsgPairStatus(pair)).join('')} </div> `; } /** * Отрисовать статус одной пары КСГ (для свёрнутого вида) * @private * @param {SchedulePair} pair * @returns {string} */ renderKsgPairStatus(pair) { const currentStatus = this.getKsgStatusType(pair, 'current'); const targetStatus = this.getKsgStatusType(pair, 'target'); return ` <div class="ksg-pair-status"> <div class="ksg-pair-status__item ksg-pair-status__item--current"> <span class="ksg-pair-status__dot ksg-pair-status__dot--current ksg-pair-status__dot--${currentStatus}"></span> <span class="ksg-pair-status__text ksg-pair-status__text--${currentStatus}"> ${pair.currentScheduleId || 'не указан ID проекта'} </span> </div> <div class="ksg-pair-status__arrow"> ${ArrowRight} </div> <div class="ksg-pair-status__item ksg-pair-status__item--target"> <span class="ksg-pair-status__dot ksg-pair-status__dot--target ksg-pair-status__dot--${targetStatus}"></span> <span class="ksg-pair-status__text ksg-pair-status__text--${targetStatus}"> ${pair.targetScheduleId || 'не указан ID проекта'} </span> </div> </div> `; } /** * Получить тип статуса КСГ для цветовой индикации * @private * @param {SchedulePair} pair * @param {'current'|'target'} type * @returns {'success'|'error'|'warning'|'empty'} */ getKsgStatusType(pair, type) { const scheduleId = type === 'current' ? pair.currentScheduleId : pair.targetScheduleId; const notFound = type === 'current' ? pair.currentNotFound : pair.targetNotFound; const description = type === 'current' ? pair.currentDescription : pair.targetDescription; if (!scheduleId.trim()) { return 'warning'; // empty field = yellow } if (notFound) { return 'error'; } if (description) { return 'success'; } return 'empty'; // needs description refresh = gray } /** * Отрисовать название проекта (input или текст с иконкой редактирования) * @private * @param {Project} project * @returns {string} */ renderProjectName(project) { const isEditing = this.editingProjectNames.has(project.projectId); if (isEditing) { return ` <input type="text" class="project__name-input" value="${this.escapeHtml(project.name)}" placeholder="КППГ" data-project-id="${project.projectId}" aria-label="Название проекта"> `; } return ` <div class="project__name-wrapper"> <span class="project__name">${this.escapeHtml(project.name) || '<span class="project__name-placeholder">Название проекта</span>'}</span> </div> `; } /** * Отрисовать содержимое проекта (когда развёрнут) * @private * @param {Project} project * @returns {string} */ renderProjectContent(project) { return ` <div class="project__content"> <!-- Schedule Pairs Section --> <div class="pairs-section"> <div class="pairs__header"> <h4 class="pairs__title">Пары графиков КСГ</h4> <button class="btn btn--small btn--primary add-pair-btn" data-project-id="${project.projectId}"> ${Plus} КСГ </button> </div> <div class="pairs-list"> ${project.schedulePairs.map(pair => this.renderPairRow(project.projectId, pair)).join('')} ${project.schedulePairs.length === 0 ? ` <div class="pairs-empty"> Нет пар графиков. Нажмите "+ КСГ" чтобы добавить. </div> ` : ''} </div> </div> ${this.renderValidationErrors(project.projectId)} </div> `; } /** * Отрисовать строку пары * @private * @param {string} projectId * @param {SchedulePair} pair * @returns {string} */ renderPairRow(projectId, pair) { const isInvalid = !pair.isValid && pair.currentScheduleId; return ` <div class="pair-row ${isInvalid ? 'pair-row--invalid' : ''}" data-pair-id="${pair.pairId}"> <!-- Current Column --> <div class="pair__column"> <div class="pair__input-wrapper pair__input-wrapper--with-action"> <input type="text" class="pair__input pair__input--with-action" value="${this.escapeHtml(pair.currentScheduleId)}" placeholder=" " id="current-${pair.pairId}" data-project-id="${projectId}" data-pair-id="${pair.pairId}" data-field="current" aria-label="Идентификатор текущего графика"> <label class="pair__floating-label" for="current-${pair.pairId}">Текущий КСГ</label> <button class="pair__refresh-btn" data-project-id="${projectId}" data-pair-id="${pair.pairId}" data-field="current" aria-label="Обновить описание текущего графика" title="Получить описание проекта"> ${RefreshCw} </button> </div> ${this.renderFieldStatus(pair, 'current')} </div> <!-- Target Column --> <div class="pair__column"> <div class="pair__input-wrapper pair__input-wrapper--with-action"> <input type="text" class="pair__input pair__input--with-action" value="${this.escapeHtml(pair.targetScheduleId)}" placeholder=" " id="target-${pair.pairId}" data-project-id="${projectId}" data-pair-id="${pair.pairId}" data-field="target" aria-label="Идентификатор целевого графика"> <label class="pair__floating-label pair__floating-label--target" for="target-${pair.pairId}">Целевой КСГ</label> <button class="pair__refresh-btn" data-project-id="${projectId}" data-pair-id="${pair.pairId}" data-field="target" aria-label="Обновить описание целевого графика" title="Получить описание проекта"> ${RefreshCw} </button> </div> ${this.renderFieldStatus(pair, 'target')} </div> <!-- Actions --> <div class="pair__input-wrapper pair__input-wrapper--action"> <button class="pair__remove-btn" data-project-id="${projectId}" data-pair-id="${pair.pairId}" aria-label="Удалить пару" title="Удалить пару"> ${Trash} </button> </div> </div> `; } /** * Отрисовать статус поля ввода * @private * @param {SchedulePair} pair - Пара графиков * @param {'current'|'target'} field - Поле * @returns {string} */ renderFieldStatus(pair, field) { const scheduleId = field === 'current' ? pair.currentScheduleId : pair.targetScheduleId; const description = field === 'current' ? pair.currentDescription : pair.targetDescription; const notFound = field === 'current' ? pair.currentNotFound : pair.targetNotFound; const pairId = pair.pairId; // 1. Поле пустое if (!scheduleId.trim()) { return `<span class="ksg-status ksg-status--required" id="${field}-desc-${pairId}"><span class="ksg-status__icon">${AlertTriangle}</span><span class="ksg-status__text">Необходимо ввести идентификатор КСГ</span></span>`; } // 2. Идентификатор не найден в Primavera if (notFound) { return `<span class="ksg-status ksg-status--error" id="${field}-desc-${pairId}"><span class="ksg-status__icon">${XCircle}</span><span class="ksg-status__text">Идентификатор не найден в Primavera</span></span>`; } // 4. Успешно получено описание if (description) { return `<span class="ksg-status ksg-status--success" id="${field}-desc-${pairId}" title="${this.escapeHtml(description)}"><span class="ksg-status__icon">${Check}</span><span class="ksg-status__text">${this.escapeHtml(description)}</span></span>`; } // 3. Поле заполнено но обновление не проводили return `<span class="ksg-status ksg-status--hint" id="${field}-desc-${pairId}"><span class="ksg-status__icon">${Info}</span><span class="ksg-status__text">Обновите чтобы получить описание проекта</span></span>`; } /** * Отрисовать ошибки валидации * @private * @param {string} projectId * @returns {string} */ renderValidationErrors(projectId) { // Баннер валидации отключен return ''; } /** * Навесить обработчики событий на проекты * @private * @param {HTMLElement} container */ attachProjectEventListeners(container) { // Toggle expand container.querySelectorAll('[data-action="toggle-expand"]').forEach(el => { el.addEventListener('click', (e) => { if (e.target instanceof HTMLElement) { // Don't toggle if clicking action buttons if (e.target.closest('.project__actions')) { return; } } const projectId = el.closest('.project')?.getAttribute('data-project-id'); if (projectId) this.toggleProjectExpanded(projectId); }); }); // Project name input handlers container.querySelectorAll('.project__name-input').forEach(el => { const projectId = el.getAttribute('data-project-id'); if (!projectId) return; // Prevent toggle-expand when clicking input el.addEventListener('click', (e) => { e.stopPropagation(); }); // Save on blur (prevent blur if name is empty) el.addEventListener('blur', (e) => { const target = /** @type {HTMLInputElement} */ (e.target); const trimmedValue = target.value.trim(); // Если имя пустое, не позволяем уйти с поля if (!trimmedValue) { // Добавляем визуальную индикацию ошибки target.style.borderColor = 'var(--alert-color, #d33)'; target.style.boxShadow = '0 0 0 2px rgba(211, 51, 51, 0.2)'; // Отложенный возврат фокуса, чтобы дать браузеру завершить текущее событие blur setTimeout(() => { target.focus(); }, 0); return; } // Сбрасываем стили ошибки target.style.borderColor = ''; target.style.boxShadow = ''; this.saveProjectName(projectId, target.value); }); // Save on Enter, cancel on Escape el.addEventListener('keydown', (e) => { const target = /** @type {HTMLInputElement} */ (e.target); if (e.key === 'Enter') { e.preventDefault(); this.saveProjectName(projectId, target.value); } else if (e.key === 'Escape') { e.preventDefault(); this.editingProjectNames.delete(projectId); this.renderProjectsList(); } }); }); // Refresh all schedule descriptions for project container.querySelectorAll('[data-action="refresh-all"]').forEach(el => { el.addEventListener('click', (e) => { e.stopPropagation(); const projectId = el.getAttribute('data-project-id'); if (projectId) { this.refreshAllProjectDescriptions(projectId, el); } }); }); // Edit project container.querySelectorAll('[data-action="edit"]').forEach(el => { el.addEventListener('click', (e) => { e.stopPropagation(); const projectId = el.getAttribute('data-project-id'); if (projectId) { this.toggleProjectNameEdit(projectId); } }); }); // Delete project container.querySelectorAll('[data-action="delete"]').forEach(el => { el.addEventListener('click', async (e) => { e.stopPropagation(); const projectId = el.getAttribute('data-project-id'); if (projectId) { const project = this.projects.find(p => p.projectId === projectId); if (project) { const confirmed = await this.showDeleteConfirmDialog(projectId, project.name); if (confirmed) { this.deleteProject(projectId); } } } }); }); // Add pair container.querySelectorAll('.add-pair-btn').forEach(el => { el.addEventListener('click', () => { const projectId = el.getAttribute('data-project-id'); if (projectId) this.addSchedulePair(projectId); }); }); // Remove pair container.querySelectorAll('.pair__remove-btn').forEach(el => { el.addEventListener('click', () => { const projectId = el.getAttribute('data-project-id'); const pairId = el.getAttribute('data-pair-id'); if (projectId && pairId) { this.removeSchedulePair(projectId, pairId); } }); }); // Refresh schedule description container.querySelectorAll('.pair__refresh-btn').forEach(el => { el.addEventListener('click', () => { const projectId = el.getAttribute('data-project-id'); const pairId = el.getAttribute('data-pair-id'); const field = el.getAttribute('data-field'); if (projectId && pairId && field) { this.refreshScheduleDescription(projectId, pairId, field, el); } }); }); // Input handlers for schedule ID container.querySelectorAll('.pair__input').forEach(el => { el.addEventListener('input', (e) => { const target = /** @type {HTMLInputElement} */ (e.target); const projectId = target.getAttribute('data-project-id'); const pairId = target.getAttribute('data-pair-id'); const field = target.getAttribute('data-field'); if (!projectId || !pairId || !field) return; this.debouncedScheduleInput(projectId, pairId, field, target.value); }); }); } /** * Склонение слова "график" в зависимости от числа * @private * @param {number} count - Количество * @returns {string} - "1 график", "2 графика", "5 графиков" */ getScheduleWord(count) { const lastDigit = count % 10; const lastTwoDigits = count % 100; if (lastDigit === 1 && lastTwoDigits !== 11) { return `${count} график`; } if ([2, 3, 4].includes(lastDigit) && ![12, 13, 14].includes(lastTwoDigits)) { return `${count} графика`; } return `${count} графиков`; } /** * Экранирование HTML * @private * @param {string} text * @returns {string} */ escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Получить стили компонента * @private * @returns {string} */ getStyles() { return ` /* ===== CSS Variables ===== */ :host { --modal-max-width: 1000px; --modal-max-height: 90vh; --target-color: #9A7EE3; } /* ===== Modal Overlay ===== */ .modal-overlay { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: var(--modal-overlay-bg); z-index: 1000; padding: var(--space-sm); } /* ===== Modal ===== */ .modal { width: 100%; max-width: var(--modal-max-width); max-height: var(--modal-max-height); height: 500px; background: var(--primary-bg); border-radius: var(--border-radius-lg); box-shadow: var(--shadow-md); display: flex; flex-direction: column; overflow: hidden; } /* ===== Header ===== */ .modal__header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-xs) var(--space-sm); border-bottom: 1px solid var(--border-light); min-height: 24px; } .modal__title { font-family: var(--font-family); font-size: var(--font-size-lg); font-weight: var(--font-weight-semibold); color: var(--text-primary); margin: 0; display: flex; align-items: center; gap: var(--space-2xs); } .modal__title-icon { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; } .modal__title-icon svg { width: 20px; height: 20px; color: var(--text-primary); } .modal__close { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; border-radius: var(--border-radius); cursor: pointer; color: var(--text-secondary); transition: all var(--transition); padding: 0; } .modal__header-actions { display: flex; align-items: center; gap: var(--space-2xs); } .modal__close:hover { background: var(--surface-ghost); color: var(--text-primary); } .modal__fullscreen { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; border-radius: var(--border-radius); cursor: pointer; color: var(--text-secondary); transition: all var(--transition); padding: 0; } .modal__fullscreen:hover { background: var(--surface-ghost); color: var(--text-primary); } .modal__fullscreen svg { width: 20px; height: 20px; } .modal--fullscreen { max-width: 100% !important; max-height: 100% !important; width: 100%; height: 100%; border-radius: 0; } /* ===== Tabs ===== */ .modal__tabs { display: flex; gap: 0; padding: 0 var(--space-sm); border-bottom: 1px solid var(--border-light); background: var(--surface-bg); flex-shrink: 0; } .modal__tab { padding: var(--space-xs) var(--space-sm); background: transparent; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; font-family: var(--font-family); font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary); cursor: pointer; transition: all var(--transition); height: 32px; } .modal__tab:hover { color: var(--text-primary); } .modal__tab--active { color: var(--accent-color); border-bottom-color: var(--accent-color); } /* ===== Tab Panels ===== */ .tab-panel { display: none; flex-direction: column; flex: 1; min-height: 0; } .tab-panel--active { display: flex; } .tab-panel--projects { display: flex; flex-direction: column; min-height: 0; } .presets-list-compact { flex-shrink: 0; max-height: 280px; overflow-y: auto; padding-right: var(--space-xs); margin-bottom: var(--space-xs); } .presets-list-view { display: none; flex-direction: column; flex: 1; min-height: 0; } .presets-list-view--active { display: flex; } .presets-builder-view { display: none; flex: 1; min-height: 0; } .presets-builder-view--active { display: flex; } .presets-create-btn { margin-left: auto; } /* ===== Body ===== */ .modal__body { flex: 1; overflow: hidden; padding: var(--space-sm); display: flex; flex-direction: column; min-height: 0; } .modal__body::-webkit-scrollbar { width: 6px; } .modal__body::-webkit-scrollbar-track { background: var(--scroll-bg); } .modal__body::-webkit-scrollbar-thumb { background: var(--scroll-thumb); border-radius: var(--border-radius-sm); } .modal__body::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); } .projects-list::-webkit-scrollbar { width: 6px; } .projects-list::-webkit-scrollbar-track { background: var(--scroll-bg); } .projects-list::-webkit-scrollbar-thumb { background: var(--scroll-thumb); border-radius: var(--border-radius-sm); } .projects-list::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); } /* ===== Footer ===== */ .modal__footer { display: flex; justify-content: space-between; align-items: center; gap: var(--space-xs); padding: var(--space-xs) var(--space-sm); border-top: 1px solid var(--border-light); background: var(--surface-bg); min-height: 24px; } .modal__footer-left { display: flex; gap: var(--space-xs); } .modal__footer-right { display: flex; gap: var(--space-xs); } .modal__ok-btn:disabled { opacity: 0.5; cursor: not-allowed; background: var(--color-control-bg-ghost, var(--text-tertiary)); } .modal__ok-btn:not(:disabled) { background: var(--accent-color); } /* ===== Section ===== */ .section__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-xs); min-height: 20px; } .section__title { font-family: var(--font-family); font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-primary); margin: 0; } /* ===== Buttons ===== */ .btn { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2xs); padding: var(--space-2xs) var(--space-xs); font-family: var(--font-family); font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); text-transform: uppercase; letter-spacing: 0.02em; border-radius: var(--border-radius); cursor: pointer; transition: all var(--transition); border: none; height: 24px; box-sizing: border-box; } .btn:disabled { opacity: 0.5; cursor: not-allowed; } .btn--primary { background: var(--accent-color); color: var(--color-control-typo-primary, #fff); } .btn--primary:hover:not(:disabled) { background: var(--accent-hover); } .btn--secondary { background: transparent; color: var(--accent-color); border: 1px solid var(--accent-color); } .btn--secondary:hover:not(:disabled) { background: var(--surface-bg); color: var(--accent-color); border-color: var(--accent-color); } .btn--small { padding: var(--space-3xs) var(--space-2xs); height: 20px; } .btn--danger { background: var(--error-color); color: var(--color-control-typo-primary, #fff); } .btn--danger:hover:not(:disabled) { background: var(--error-color); filter: brightness(0.9); } /* ===== Projects Section ===== */ .projects-section { background: var(--surface-bg); border-radius: var(--border-radius-lg); padding: var(--space-xs); display: flex; flex-direction: column; flex: 1; min-height: 0; } .projects-list { display: flex; flex-direction: column; gap: var(--space-2xs); flex: 1; min-height: 0; overflow-y: auto; } /* ===== Empty State ===== */ .empty-state { display: flex; flex-direction: column; align-items: center; padding: var(--space-xl); color: var(--text-secondary); text-align: center; } .empty-state svg { width: 48px; height: 48px; margin-bottom: var(--space-sm); opacity: 0.5; } .empty-state p { font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); margin: 0 0 var(--space-2xs); } .empty-state span { font-size: var(--font-size-xs); color: var(--text-tertiary); } /* ===== Project Card ===== */ .project { background: var(--primary-bg); border: 1px solid var(--border-light); border-radius: var(--border-radius); overflow: hidden; transition: border-color var(--transition); flex-shrink: 0; } .project--expanded { border-color: var(--accent-color); } .project--error { border-color: var(--warning-color); } .project__header { display: flex; align-items: center; gap: var(--space-2xs); padding: var(--space-2xs) var(--space-xs); cursor: pointer; transition: background var(--transition); min-height: 20px; } .project__header:hover { background: var(--surface-bg); } .project__expand-icon { width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); transition: transform var(--transition); flex-shrink: 0; } .project__expand-icon--expanded { transform: rotate(90deg); } .project__info { flex: 1; display: flex; flex-direction: column; gap: var(--space-3xs); min-width: 0; } .project__name-wrapper { display: flex; align-items: center; gap: var(--space-2xs); } .project__name { font-family: var(--font-family); font-size: var(--font-size-md); font-weight: var(--font-weight-semibold); color: var(--text-primary); } .project__name-placeholder { color: var(--text-tertiary); font-style: italic; } .project__name-input { font-family: var(--font-family); font-size: var(--font-size-md); font-weight: var(--font-weight-semibold); color: var(--text-primary); background: var(--primary-bg); border: 1px solid var(--border-focus); border-radius: var(--border-radius); padding: 0 var(--space-2xs); width: 100%; max-width: 300px; height: 28px; transition: border-color var(--transition); box-sizing: border-box; } .project__name-input:focus { outline: none; border-color: var(--accent-color); box-shadow: var(--shadow-glow); } .project__name-input::placeholder { color: var(--text-tertiary); font-weight: var(--font-weight-regular); } .project__meta { font-family: var(--font-family); font-size: var(--font-size-xs); color: var(--text-secondary); } .project__actions { display: flex; gap: var(--space-3xs); align-items: center; } /* ===== Project KSG Statuses (Collapsed View) ===== */ .project__ksg-statuses { display: flex; flex-direction: column; gap: var(--space-3xs); flex: 1; margin: 0 var(--space-sm); min-width: 0; } .ksg-pair-status { display: flex; align-items: center; gap: var(--space-2xs); padding: var(--space-3xs) var(--space-xs); background: transparent; border-radius: var(--border-radius); font-size: var(--font-size-xs); } .ksg-pair-status__item { display: flex; align-items: center; gap: var(--space-3xs); flex: 1; min-width: 0; } .ksg-pair-status__item--current { justify-content: flex-start; } .ksg-pair-status__item--target { justify-content: flex-start; } .ksg-pair-status__dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } .ksg-pair-status__dot--current { background: var(--accent-color); } .ksg-pair-status__dot--target { background: var(--target-color); } .ksg-pair-status__dot--error { background: var(--error-color); } .ksg-pair-status__dot--warning { background: var(--warning-color); } .ksg-pair-status__text { font-family: var(--font-family); font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ksg-pair-status__text--success { color: var(--success-color); } .ksg-pair-status__text--error { color: var(--error-color); } .ksg-pair-status__text--warning { color: var(--warning-color); } .ksg-pair-status__text--empty { color: var(--text-secondary); } .ksg-pair-status__arrow { display: none; } .project__action-btn { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; border-radius: var(--border-radius); cursor: pointer; color: var(--text-secondary); transition: background-color var(--transition), color var(--transition); padding: 0; margin: 0; flex-shrink: 0; } .project__action-btn:hover { background: var(--surface-ghost); color: var(--text-primary); } .project__action-btn--delete:hover { color: var(--error-color); } .project__action-btn--refresh:hover:not(:disabled) { color: var(--accent-color); } .project__action-btn svg { display: block; flex-shrink: 0; transform-origin: center center; width: 16px; height: 16px; } .project__action-btn--refresh.pair__refresh-btn--loading { animation: spin-project 1s linear infinite; } .project__action-btn:disabled { opacity: 0.7; cursor: not-allowed; width: 24px !important; height: 24px !important; padding: 0 !important; margin: 0 !important; display: flex !important; align-items: center !important; justify-content: center !important; flex-shrink: 0 !important; box-sizing: border-box !important; border: none !important; background: transparent !important; } @keyframes spin-project { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* ===== Project Content ===== */ .project__content { padding: var(--space-xs); border-top: 1px solid var(--border-light); animation: expandContent 0.2s ease-out; } @keyframes expandContent { from { opacity: 0; } to { opacity: 1; } } /* ===== Pairs Section ===== */ .pairs-section { margin-bottom: var(--space-sm); } .pairs__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-2xs); min-height: 20px; } .pairs__title { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary); margin: 0; } .pairs-list { display: flex; flex-direction: column; gap: var(--space-2xs); margin-left: var(--space-xs); } .pairs-empty { padding: var(--space-sm); text-align: center; color: var(--text-tertiary); font-size: var(--font-size-xs); font-style: italic; } /* ===== Pair Row ===== */ .pair-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: var(--space-xs); padding: var(--space-2xs); background: var(--surface-bg); border-radius: var(--border-radius); position: relative; overflow: hidden; border: 1px solid transparent; } .pair-row--invalid { border-color: var(--warning-color); background: var(--warning-bg); } .pair__column { display: flex; flex-direction: column; min-width: 0; } /* Floating Label Input Wrapper */ .pair__input-wrapper { position: relative; width: 100%; max-width: 100%; box-sizing: border-box; } .pair__floating-label { position: absolute; left: var(--space-xs); top: 50%; transform: translateY(-50%); font-family: var(--font-family); font-size: var(--font-size-xs); color: var(--text-secondary); background: var(--primary-bg); padding: 0 var(--space-3xs); pointer-events: none; transition: all var(--transition); border-radius: var(--border-radius-sm); line-height: 1; } .pair__floating-label--target { color: var(--text-secondary); } .pair__input:focus ~ .pair__floating-label, .pair__input:not(:placeholder-shown) ~ .pair__floating-label { top: 0; transform: translateY(-50%); color: var(--accent-color); } .pair__input:focus ~ .pair__floating-label--target, .pair__input:not(:placeholder-shown) ~ .pair__floating-label--target { color: var(--target-color); } .pair__label { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary); display: flex; align-items: center; gap: var(--space-3xs); } .pair__label--current::before { content: "🟢"; font-size: var(--font-size-xs); } .pair__label--target::before { content: "🟣"; font-size: var(--font-size-xs); } .pair__input { width: 100%; height: var(--input-height); padding: 0 var(--space-xs); font-family: var(--font-family); font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); border: 1px solid var(--border-color); border-radius: var(--border-radius); background: var(--primary-bg); color: var(--text-primary); transition: border-color var(--transition); box-sizing: border-box; min-width: 0; } .pair__input:focus { outline: none; border-color: var(--border-focus); } .pair__input-wrapper--with-action { position: relative; } .pair__input--with-action { padding-right: 28px; } .pair__refresh-btn { position: absolute; right: 2px; top: 50%; transform: translateY(-50%); width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; border-radius: var(--border-radius-sm); cursor: pointer; color: var(--text-secondary); transition: all var(--transition); padding: 0; } .pair__refresh-btn:hover { color: var(--accent-color); background: var(--surface-ghost); } .pair__refresh-btn--loading { animation: spin 1s linear infinite; } @keyframes spin { from { transform: translateY(-50%) rotate(0deg); } to { transform: translateY(-50%) rotate(360deg); } } .pair__input-wrapper--action { display: flex; align-items: flex-start; } .pair__remove-btn { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; border-radius: var(--border-radius); cursor: pointer; color: var(--text-secondary); transition: all var(--transition); padding: 0; } .pair__remove-btn:hover { color: var(--error-color); background: var(--surface-ghost); } /* ===== Field Statuses ===== */ .ksg-status { display: flex; align-items: center; gap: var(--space-3xs); font-size: var(--font-size-xs); min-height: 16px; margin-top: var(--space-3xs); } .ksg-status--required { color: var(--warning-color); } .ksg-status--error { color: var(--error-color); } .ksg-status--hint { color: var(--text-secondary); font-style: italic; } .ksg-status--success { color: var(--success-color); } .ksg-status__icon { flex-shrink: 0; display: flex; align-items: center; width: 12px; height: 12px; } .ksg-status__icon svg { width: 12px; height: 12px; } .ksg-status__text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--font-size-xs); } /* ===== Validation Banner ===== */ .validation-banner { margin-top: var(--space-xs); padding: var(--space-2xs) var(--space-xs); background: var(--warning-bg); border: 1px solid var(--warning-color); border-radius: var(--border-radius); } .validation__title { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); color: var(--warning-color); margin-bottom: var(--space-3xs); } .validation__list { margin: 0; padding-left: var(--space-sm); font-size: var(--font-size-xs); color: var(--text-primary); } .validation__list li { margin-bottom: var(--space-3xs); } /* ===== Confirm Dialog ===== */ .confirm-dialog-overlay { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: var(--modal-overlay-bg); z-index: 2000; padding: var(--space-sm); animation: fadeIn 0.2s ease-out; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } } .confirm-dialog { width: 100%; max-width: 400px; background: var(--primary-bg); border-radius: var(--border-radius-lg); box-shadow: var(--shadow-lg); padding: var(--space-lg); animation: slideUp 0.2s ease-out; } @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } .confirm-dialog__header { display: flex; flex-direction: column; align-items: center; gap: var(--space-xs); margin-bottom: var(--space-sm); text-align: center; } .confirm-dialog__icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: var(--error-bg); color: var(--error-color); border-radius: 50%; } .confirm-dialog__title { font-family: var(--font-family); font-size: var(--font-size-lg); font-weight: var(--font-weight-semibold); color: var(--text-primary); margin: 0; } .confirm-dialog__body { margin-bottom: var(--space-lg); text-align: center; } .confirm-dialog__message { font-family: var(--font-family); font-size: var(--font-size-sm); color: var(--text-secondary); line-height: 1.5; margin: 0; } .confirm-dialog__message strong { color: var(--text-primary); font-weight: var(--font-weight-semibold); } .confirm-dialog__footer { display: flex; gap: var(--space-xs); justify-content: center; } /* ===== Icon sizing for ConstantinIcons ===== */ .modal__close svg { width: 20px; height: 20px; } .btn svg { width: 16px; height: 16px; } .btn--small svg { width: 14px; height: 14px; } .project__expand-icon svg { width: 16px; height: 16px; } .ksg-pair-status__arrow svg { width: 12px; height: 12px; } .pair__refresh-btn svg { width: 14px; height: 14px; } .pair__remove-btn svg { width: 16px; height: 16px; } .confirm-dialog__icon svg { width: 28px; height: 28px; } /* ===== Layouts Section ===== */ .layouts-section { background: var(--surface-bg); border-radius: var(--border-radius-lg); padding: var(--space-xs); margin-bottom: var(--space-xs); } .layouts-list { display: flex; flex-direction: column; gap: var(--space-2xs); margin-bottom: var(--space-xs); } .layouts-empty { padding: var(--space-sm); text-align: center; color: var(--text-tertiary); font-size: var(--font-size-xs); font-style: italic; } .layout-item { display: flex; align-items: center; gap: var(--space-xs); padding: var(--space-2xs) var(--space-xs); background: var(--primary-bg); border: 1px solid var(--border-light); border-radius: var(--border-radius); transition: border-color var(--transition), background var(--transition); min-height: 32px; } .layout-item:hover { border-color: var(--accent-color); background: var(--surface-bg); } .layout-item--active { border-color: var(--accent-color); background: var(--surface-ghost); } .layout-item__icon { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; color: var(--text-secondary); flex-shrink: 0; } .layout-item__icon svg { width: 16px; height: 16px; } .layout-item__info { flex: 1; display: flex; flex-direction: column; gap: var(--space-3xs); min-width: 0; } .layout-item__name { font-family: var(--font-family); font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .layout-item__meta { font-family: var(--font-family); font-size: var(--font-size-xs); color: var(--text-secondary); display: flex; gap: var(--space-xs); align-items: center; } .layout-item__badge { display: inline-flex; align-items: center; padding: 0 var(--space-3xs); font-size: 9px; font-weight: var(--font-weight-medium); text-transform: uppercase; letter-spacing: 0.05em; border-radius: var(--border-radius-sm); background: var(--accent-color); color: var(--color-control-typo-primary, #fff); height: 14px; line-height: 1; } .layout-item__badge--builtin { background: var(--text-tertiary); } .layout-item__actions { display: flex; gap: var(--space-3xs); align-items: center; flex-shrink: 0; } .layout-item__btn { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; border-radius: var(--border-radius); cursor: pointer; color: var(--text-secondary); transition: all var(--transition); padding: 0; } .layout-item__btn:hover { background: var(--surface-ghost); color: var(--text-primary); } .layout-item__btn--activate:hover { color: var(--accent-color); } .layout-item__btn--export:hover { color: var(--accent-color); } .layout-item__btn--edit:hover { color: var(--accent-color); } .layout-item__btn--delete:hover { color: var(--error-color); } .layout-item__btn svg { width: 14px; height: 14px; } .layouts-actions { display: flex; gap: var(--space-xs); align-items: center; } /* ===== Utility ===== */ svg { flex-shrink: 0; } `; } } customElements.define('app-settings-modal', AppSettingsModal); export default AppSettingsModal;