/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/components/PrimaveraDataLoader.js
3 828 строк
127 KB
Starolat Sergei
fix: единый шаблон имени файла экспорта БД DeepDive_{проект}_{ДД.ММ.ГГГГ}
30 июл 2026, 15:04
30 июл 2026, 15:04
c7a6c26
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview PrimaveraDataLoader - UI компонент для batch загрузки данных из Primavera P6 * @module js/components/PrimaveraDataLoader * @version 2.3.0 * * Реализация спецификации COMP-009. * Интеграция с DataService (SYS-001), DataIndexer (JS Data Engine), AppSettingsModal (COMP-008). * * @see specs/widgets/PrimaveraDataLoader.md */ import { X, Check, XCircle, Save, ChevronRight, Loader, Cloud, Zap, Database, AlertCircle, Clock, } from "./ConstantinIcons.js"; import dataService from "../services/DataService.js"; import config from "../config.js"; import { tokenService } from "../services/auth/token-service.js"; import { UserSettingsService } from "../services/UserSettingsService.js"; import { ToastManager } from "./ToastManager.js"; /** * Типы из AppSettingsModal (COMP-008) - Source of Truth * @typedef {import('./AppSettingsModal.js').Project} Project * @typedef {import('./AppSettingsModal.js').SchedulePair} SchedulePair */ /** * @typedef {Object} LoaderConfig * @property {string[]} shortNames - short_name проектов Primavera для загрузки (KSG ID) * @property {string} authToken - Токен авторизации Primavera * @property {boolean} [autoSave=true] - Автоматически сохранять в файл после загрузки * @property {string} [filenamePattern]='{projectName}_{scheduleType}_{date}.dddb.zip' * @property {number} [parallelLoad=1] - Количество параллельных загрузок (1-3) * @property {boolean} [continueOnError=true] - Продолжать при ошибке одного проекта */ /** * @typedef {Object} LoadOperation * @property {string} operationId - UUID операции * @property {string} projectId - ID проекта DeepDive (из COMP-008) * @property {string} projectName - Название проекта * @property {string} pairId - ID пары (из COMP-008) * @property {'current'|'target'} scheduleType - Тип графика * @property {string} shortName - KSG ID = short_name Primavera * @property {'pending'|'loading'|'completed'|'error'|'cancelled'} status * @property {number} progress - Прогресс 0-100 * @property {string} [databaseId] - ID созданной БД (после завершения) * @property {number} [activityCount] - Количество загруженных работ * @property {string} [errorMessage] - Сообщение об ошибке * @property {AbortController} [abortController] - Для отмены операции * @property {number} [startTime] - Время начала операции * @property {number} [endTime] - Время завершения операции * @property {string} [currentPhase] - Текущая фаза ('api_call', 'transform', 'save', 'complete') */ /** * @typedef {Object} BatchLoadResult * @property {boolean} success - Успешна ли загрузка (все проекты) * @property {number} totalOperations - Всего операций (current + target per pair) * @property {number} successfulLoads - Успешно загружено * @property {number} failedLoads - Ошибок * @property {number} cancelledLoads - Отменено * @property {LoadOperation[]} operations - Детали по каждой операции * @property {string} [sessionId] - ID сессии в load_sessions * @property {number} totalDurationMs - Общая длительность */ /** * @typedef {Object} LoadingProgress * @property {number} overallPercent - Общий прогресс (0-100) * @property {LoadOperation[]} operations - Все операции со статусами * @property {Object} summary * @property {number} summary.total - Всего операций * @property {number} summary.completed - Завершено * @property {number} summary.loading - В процессе * @property {number} summary.pending - В очереди * @property {number} summary.failed - Ошибок * @property {string} [currentPhase] - Текущая фаза ('api_call', 'transform', 'save') */ /** * Сессия загрузки (load_sessions row) * @typedef {Object} LoadSession * @property {string} sessionId - UUID сессии * @property {string} startedAt - ISO 8601 * @property {string} [completedAt] - ISO 8601 * @property {number} totalOperations * @property {number} successfulOperations * @property {number} failedOperations * @property {number} cancelledOperations - Количество отмененных операций * @property {string} [configSnapshot] - JSON с конфигурацией */ /** * Генерация UUID v4 * @returns {string} UUID */ /** * Маппинг фаз загрузки DataService на UI-фазы шагов * @param {string} rawPhase * @returns {string} */ function mapProgressPhase(rawPhase) { if (!rawPhase) return "api_call"; if (rawPhase.startsWith("resolve_") || rawPhase.startsWith("fetch_")) return "api_call"; if (rawPhase.startsWith("import_") || rawPhase === "import_db") return "save"; if (rawPhase === "complete") return "complete"; return rawPhase; } 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); }); } /** * Форматирование времени (мс → MM:SS) * @param {number} ms - Время в миллисекундах * @returns {string} Отформатированное время */ function formatDuration(ms) { const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; } /** * Форматирование размера (байты → KB/MB) * @param {number} bytes - Размер в байтах * @returns {string} Отформатированный размер */ function formatSize(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } class PrimaveraDataLoader extends HTMLElement { constructor() { super(); this.attachShadow({ mode: "open" }); /** @type {Project[]} */ this.projects = []; /** @type {Set<string>} */ this.selectedProjectIds = new Set(); /** @type {Map<string, LoadOperation>} */ this.activeOperations = new Map(); /** @type {LoaderConfig} */ this.config = { shortNames: [], authToken: "", autoSave: false, filenamePattern: "{projectName}_{scheduleType}_{date}.dddb.zip", parallelLoad: 1, continueOnError: true, }; /** @type {boolean} */ this.isLoading = false; /** @type {boolean} */ this.isOpen = false; /** @type {'selection'|'loading'|'results'} */ this.currentScreen = "selection"; /** @type {BatchLoadResult|null} */ this.lastResult = null; /** @type {number} */ this.loadStartTime = 0; /** @type {HTMLElement|null} */ this.progressUpdateInterval = null; /** @type {boolean} */ this._pendingUIUpdate = false; // Bind methods this.open = this.open.bind(this); this.close = this.close.bind(this); this.load = this.load.bind(this); this.cancel = this.cancel.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); } static get observedAttributes() { return ["open", "auto-start", "compact"]; } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; switch (name) { case "open": this.isOpen = newValue !== null; this.updateVisibility(); break; case "auto-start": if (newValue !== null && this.isOpen) { this.load(); } break; case "compact": this.classList.toggle("compact", newValue !== null); break; } } connectedCallback() { console.log("[PrimaveraDataLoader] v2.3.0 initialized"); this.render(); this.setupEventListeners(); this.syncTheme(); if (this.isOpen) { this.updateVisibility(); } // Слушать изменения темы this.themeObserver = new MutationObserver(() => this.syncTheme()); this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"], }); // Загрузить проекты из localStorage this.loadProjects(); // Перезагрузить из UserSettingsService, когда он станет готов // (например, после корпоративной авторизации на старте приложения). this._userSettingsLoadedHandler = () => { if (!this.isOpen) { this.loadProjects(); } }; document.addEventListener( "user-settings-loaded", this._userSettingsLoadedHandler, ); } disconnectedCallback() { this.cleanupEventListeners(); if (this.themeObserver) { this.themeObserver.disconnect(); } if (this.progressUpdateInterval) { clearInterval(this.progressUpdateInterval); } if (this._userSettingsLoadedHandler) { document.removeEventListener( "user-settings-loaded", this._userSettingsLoadedHandler, ); } } /** * Синхронизировать тему с document.documentElement * @private */ syncTheme() { const theme = document.documentElement.getAttribute("data-theme"); if (theme) { this.setAttribute("data-theme", theme); } } /** * Ключ для хранения проектов в localStorage (legacy fallback) * @private */ get STORAGE_KEY() { return "deepdive_projects"; } /** * Ключ для хранения конфигурации загрузчика в localStorage (legacy fallback) * @private */ get CONFIG_STORAGE_KEY() { return "deepdive_loader_config"; } /** * Использовать ли 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( "[PrimaveraDataLoader] Failed to read projects from localStorage:", e, ); } return null; } /** * Прочитать конфигурацию загрузчика из актуального хранилища. * Auth token не сохраняется/восстанавливается отсюда (он хранится отдельно). * @private * @returns {Partial<LoaderConfig>|null} */ _loadConfigFromStorage() { if (this._useUserSettings()) { const cfg = UserSettingsService.getInstance().get( "loaderConfig", null, ); return cfg && typeof cfg === "object" ? cfg : null; } try { const stored = localStorage.getItem(this.CONFIG_STORAGE_KEY); if (stored) { return JSON.parse(stored); } } catch (e) { console.error( "[PrimaveraDataLoader] Failed to read loader config from localStorage:", e, ); } return null; } /** * Сохранить конфигурацию загрузчика в актуальное хранилище. * Auth token не сохраняется (он хранится отдельно). * @private * @returns {Promise<void>} */ async _saveConfigToStorage() { const configToSave = { autoSave: this.config.autoSave, continueOnError: this.config.continueOnError, parallelLoad: this.config.parallelLoad, filenamePattern: this.config.filenamePattern, }; if (this._useUserSettings()) { await UserSettingsService.getInstance().set( "loaderConfig", configToSave, ); return; } localStorage.setItem( this.CONFIG_STORAGE_KEY, JSON.stringify(configToSave), ); } /** * Асинхронно сохранить конфигурацию без ожидания результата. * @private */ _persistConfig() { this._saveConfigToStorage().catch((err) => { console.error("[PrimaveraDataLoader] Failed to persist loader config:", err); }); } /** * Загрузка проектов из актуального хранилища * @private */ loadProjects() { const stored = this._loadProjectsFromStorage(); this.projects = Array.isArray(stored) ? stored : []; if (this.projects.length > 0) { console.log( `[PrimaveraDataLoader] Loaded ${this.projects.length} projects`, ); } // По умолчанию выбираем все проекты this.projects.forEach((p) => this.selectedProjectIds.add(p.projectId)); // Загружаем сохранённую конфигурацию загрузчика const savedConfig = this._loadConfigFromStorage(); if (savedConfig) { if (typeof savedConfig.autoSave === "boolean") { this.config.autoSave = savedConfig.autoSave; } if (typeof savedConfig.continueOnError === "boolean") { this.config.continueOnError = savedConfig.continueOnError; } if ([1, 2, 3].includes(savedConfig.parallelLoad)) { this.config.parallelLoad = savedConfig.parallelLoad; } if (typeof savedConfig.filenamePattern === "string") { this.config.filenamePattern = savedConfig.filenamePattern; } } if (this.currentScreen === "selection") { this.renderProjectSelection(); } } /** * Получить auth token * @private * @returns {Promise<string>} */ async _getAuthToken() { const bearerToken = tokenService.valueStr(); if (bearerToken) { return bearerToken; } if (config.DEV_BEARER_TOKEN) { return config.DEV_BEARER_TOKEN; } if (this._useUserSettings()) { try { const token = await UserSettingsService.getInstance().getToken(); if (token) { console.log( "[PrimaveraDataLoader] Using auth token from UserSettingsService", ); return token; } } catch (e) { console.warn( "[PrimaveraDataLoader] Failed to get token from UserSettingsService:", e, ); } } const token = localStorage.getItem("primavera_auth_token") || ""; if (token) { console.log("[PrimaveraDataLoader] Using auth token from localStorage"); } return token; } // ==================== Public API ==================== /** * Получить слово "график" в правильном склонении * @private * @param {number} count * @returns {string} */ 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} графиков`; } /** * Открыть модальное окно загрузчика * @public */ open() { this.setAttribute("open", ""); this.currentScreen = "selection"; this.loadProjects(); this.renderProjectSelection(); this.dispatchEvent( new CustomEvent("modal-open", { bubbles: false, composed: true, }), ); } /** * Закрыть модальное окно * @public */ close() { if (this.isLoading) { this.cancel(); } this.removeAttribute("open"); this.dispatchEvent( new CustomEvent("modal-close", { bubbles: false, composed: true, }), ); } /** * Начать batch загрузку * @public * @param {string[]} [shortNames] - Список short_name для загрузки * @param {Partial<LoaderConfig>} [options] - Опции загрузки * @returns {Promise<BatchLoadResult>} */ async load(shortNames, options = {}) { if (this.isLoading) { console.warn("[PrimaveraDataLoader] Already loading"); return Promise.reject(new Error("Loading already in progress")); } // Объединяем конфиг const config = { ...this.config, ...options, authToken: options.authToken || (await this._getAuthToken()), }; if (!config.authToken) { this.showError( "Требуется авторизация", "Пожалуйста, настройте токен авторизации Primavera", ); return Promise.reject(new Error("No auth token")); } // Определяем список операций для загрузки const operations = this._buildOperations(shortNames); if (operations.length === 0) { this.showError( "Нет проектов для загрузки", "Выберите хотя бы один проект с заполненными парами графиков", ); return Promise.reject(new Error("No operations to load")); } // Инициализируем операции this.activeOperations.clear(); operations.forEach((op) => { this.activeOperations.set(op.operationId, op); }); this.isLoading = true; this.loadStartTime = Date.now(); this.currentScreen = "loading"; this.renderLoadingProgress(); // Диспатчим событие начала загрузки this.dispatchEvent( new CustomEvent("load-start", { detail: { config, totalOperations: operations.length }, bubbles: true, composed: true, }), ); try { // Запускаем загрузку с ограничением параллелизма await this._executeBatchLoad(config); // Автосохранение в файл если включено if (config.autoSave) { await this._autoSaveCompletedOperations(config); } // Формируем результат const result = await this._buildResult(); this.lastResult = result; this.dispatchEvent( new CustomEvent("load-complete", { detail: { result }, bubbles: true, composed: true, }), ); // Показываем уведомление о результате const toastMsg = `Загрузка завершена: ${result.successfulLoads} из ${result.totalOperations} графиков`; if (result.failedLoads > 0) { ToastManager.getInstance().warning( `${toastMsg} (${result.failedLoads} ошибок)`, 5000, ); } else { ToastManager.getInstance().success(toastMsg, 4000); } // Переходим на экран результатов this.currentScreen = "results"; await this.renderResults(); return result; } catch (error) { console.error("[PrimaveraDataLoader] Batch load failed:", error); ToastManager.getInstance().error( `Ошибка загрузки: ${error.message}`, 5000, ); this.dispatchEvent( new CustomEvent("load-error", { detail: { error }, bubbles: true, composed: true, }), ); throw error; } finally { this.isLoading = false; } } /** * Отменить всю загрузку * @public * @returns {Promise<void>} */ async cancel() { if (!this.isLoading) return; console.log("[PrimaveraDataLoader] Cancelling load..."); // Отменяем все активные операции this.activeOperations.forEach((op) => { if (op.status === "loading" && op.abortController) { op.abortController.abort(); } if (op.status === "pending" || op.status === "loading") { op.status = "cancelled"; } }); this.isLoading = false; const completedOps = [...this.activeOperations.values()].filter( (op) => op.status === "completed", ); const cancelledOps = [...this.activeOperations.values()].filter( (op) => op.status === "cancelled", ); this.dispatchEvent( new CustomEvent("load-cancel", { detail: { completedProjects: completedOps.map((op) => op.shortName), cancelledProjects: cancelledOps.map((op) => op.shortName), }, bubbles: true, composed: true, }), ); ToastManager.getInstance().warning( `Загрузка отменена. Завершено: ${completedOps.length}, отменено: ${cancelledOps.length}`, 4000, ); // Переходим на экран результатов this.currentScreen = "results"; await this.renderResults(); } /** * Отменить загрузку конкретного проекта * @public * @param {string} shortName - short_name проекта */ cancelProject(shortName) { const op = [...this.activeOperations.values()].find( (op) => op.shortName === shortName && op.status === "loading", ); if (op && op.abortController) { op.abortController.abort(); op.status = "cancelled"; } } /** * Получить databaseIds для проекта * @public * @param {string} projectId - ID проекта DeepDive * @returns {{current?: string, target?: string}} */ getProjectDatabases(projectId) { const result = { current: undefined, target: undefined }; this.activeOperations.forEach((op) => { if (op.projectId === projectId && op.databaseId) { if (op.scheduleType === "current") { result.current = op.databaseId; } else { result.target = op.databaseId; } } }); return result; } /** * Экспортировать БД проекта в файл * @public * @param {string} shortName - short_name проекта * @param {string} [fileName] - Имя файла */ async exportDatabase(shortName, fileName) { const op = [...this.activeOperations.values()].find( (op) => op.shortName === shortName && op.databaseId, ); if (!op) { throw new Error(`No database found for ${shortName}`); } const defaultName = fileName || `DeepDive_${shortName}_${new Date().toLocaleDateString("ru-RU")}.dddb.zip`; const toastId = ToastManager.getInstance().info( "Сжатие базы данных: 0%", 0, ); let lastUpdate = 0; try { await dataService.exportToFile( op.databaseId, defaultName, (percent) => { const now = performance.now(); if (now - lastUpdate < 80) return; lastUpdate = now; ToastManager.getInstance().updateMessage( toastId, `Сжатие базы данных: ${percent}%`, ); }, ); ToastManager.getInstance().updateMessage( toastId, `${shortName} сохранён (.dddb)`, ); setTimeout(() => ToastManager.getInstance().close(toastId), 4000); } catch (error) { ToastManager.getInstance().close(toastId); throw error; } } /** * Экспортировать БД пары графиков (current + target) в файл * @public * @param {string} pairId - ID пары * @param {string} currentName - short_name текущего графика * @param {string} targetName - short_name целевого графика * @param {string} projectName - Название проекта */ async exportPairDatabase(pairId, currentName, targetName, projectName) { const pairOps = [...this.activeOperations.values()].filter( (op) => op.pairId === pairId && op.databaseId, ); if (pairOps.length === 0) { throw new Error(`No database found for pair ${pairId}`); } const databaseId = pairOps[0].databaseId; const sanitize = (str) => str .replace(/[/\\?%*:|"<>]/g, "_") .replace(/_+/g, "_") .replace(/^_+|_+$/g, ""); const safeProjectName = sanitize(projectName); const date = new Date().toLocaleDateString("ru-RU"); const fileName = `DeepDive_${safeProjectName}_${date}.dddb.zip`; const toastId = ToastManager.getInstance().info( "Сжатие базы данных: 0%", 0, ); let lastUpdate = 0; try { await dataService.exportToFile(databaseId, fileName, (percent) => { const now = performance.now(); if (now - lastUpdate < 80) return; lastUpdate = now; ToastManager.getInstance().updateMessage( toastId, `Сжатие базы данных: ${percent}%`, ); }); ToastManager.getInstance().updateMessage( toastId, `Пара ${currentName} → ${targetName} сохранена (.dddb)`, ); setTimeout(() => ToastManager.getInstance().close(toastId), 4000); } catch (error) { ToastManager.getInstance().close(toastId); throw error; } } /** * Выгрузить БД проекта из памяти * @public * @param {string} shortName - short_name проекта * @returns {Promise<void>} */ async resetProject(shortName) { const op = [...this.activeOperations.values()].find( (op) => op.shortName === shortName && op.databaseId, ); if (!op) { console.warn( `[PrimaveraDataLoader] No database found for ${shortName} to reset`, ); return; } console.log(`[PrimaveraDataLoader] Resetting project ${shortName}...`); try { await dataService.closeDatabase(op.databaseId); this._updateKsgStatus(op, false); this.activeOperations.delete(op.operationId); const history = this._getLoadHistoryInternal(); const updatedHistory = history.filter((h) => h.shortName !== shortName); await this._saveHistoryToStorage(updatedHistory); ToastManager.getInstance().info( `Проект ${shortName} выгружен из памяти`, 3000, ); console.log( `[PrimaveraDataLoader] Project ${shortName} reset successfully`, ); } catch (error) { ToastManager.getInstance().error( `Ошибка выгрузки ${shortName}: ${error.message}`, 5000, ); console.error( `[PrimaveraDataLoader] Failed to reset project ${shortName}:`, error, ); throw error; } } /** * Получить историю загрузок из load_sessions * @public * @returns {LoadSession[]} */ getLoadHistory() { return this._getLoadHistoryInternal(); } /** * Прочитать историю загрузок из актуального хранилища. * @private * @returns {LoadSession[]} */ _getLoadHistoryInternal() { if (this._useUserSettings()) { const history = UserSettingsService.getInstance().get( "loadHistory", null, ); return Array.isArray(history) ? history : []; } try { const stored = localStorage.getItem("deepdive_load_history"); if (stored) { const parsed = JSON.parse(stored); return Array.isArray(parsed) ? parsed : []; } } catch (error) { console.error("[PrimaveraDataLoader] Failed to load history:", error); } return []; } /** * Сохранить историю загрузок в актуальное хранилище. * @private * @param {LoadSession[]} history * @returns {Promise<void>} */ async _saveHistoryToStorage(history) { if (this._useUserSettings()) { await UserSettingsService.getInstance().set("loadHistory", history); return; } localStorage.setItem("deepdive_load_history", JSON.stringify(history)); } /** * Сохранить сессию загрузки в историю * @private * @param {BatchLoadResult} result * @returns {Promise<string|null>} */ async _saveLoadSession(result) { try { const history = this._getLoadHistoryInternal(); /** @type {LoadSession} */ const session = { sessionId: generateUUID(), startedAt: new Date(this.loadStartTime).toISOString(), completedAt: new Date().toISOString(), totalOperations: result.totalOperations, successfulOperations: result.successfulLoads, failedOperations: result.failedLoads, cancelledOperations: result.cancelledLoads, configSnapshot: JSON.stringify(this.config), }; history.unshift(session); // Храним только последние 50 сессий if (history.length > 50) { history.length = 50; } await this._saveHistoryToStorage(history); return session.sessionId; } catch (error) { console.error("[PrimaveraDataLoader] Failed to save session:", error); return null; } } // ==================== Private Methods ==================== /** * Построить список операций для загрузки * @private * @param {string[]} [shortNamesFilter] - Фильтр по short_name * @returns {LoadOperation[]} */ _buildOperations(shortNamesFilter) { const operations = []; this.projects.forEach((project) => { if (!this.selectedProjectIds.has(project.projectId)) return; project.schedulePairs.forEach((pair) => { // Current schedule if ( pair.currentScheduleId && (!shortNamesFilter || shortNamesFilter.includes(pair.currentScheduleId)) ) { operations.push({ operationId: generateUUID(), projectId: project.projectId, projectName: project.name, pairId: pair.pairId, scheduleType: "current", shortName: pair.currentScheduleId, status: "pending", progress: 0, }); } // Target schedule if ( pair.targetScheduleId && (!shortNamesFilter || shortNamesFilter.includes(pair.targetScheduleId)) ) { operations.push({ operationId: generateUUID(), projectId: project.projectId, projectName: project.name, pairId: pair.pairId, scheduleType: "target", shortName: pair.targetScheduleId, status: "pending", progress: 0, }); } }); }); return operations; } /** * Выполнить batch загрузку с ограничением параллелизма * Пары графиков (current + target) загружаются вместе в одну БД * @private * @param {LoaderConfig} config */ async _executeBatchLoad(config) { const operations = [...this.activeOperations.values()]; // Группируем операции по парам const pairsMap = new Map(); const singleOps = []; operations.forEach((op) => { if (!pairsMap.has(op.pairId)) { pairsMap.set(op.pairId, {}); } const pair = pairsMap.get(op.pairId); pair[op.scheduleType] = op; }); // Формируем очередь загрузки: сначала пары, потом одиночные const pairQueue = []; for (const [pairId, pairOps] of pairsMap) { if (pairOps.current && pairOps.target) { pairQueue.push({ type: "pair", current: pairOps.current, target: pairOps.target, }); } else if (pairOps.current) { singleOps.push(pairOps.current); } else if (pairOps.target) { singleOps.push(pairOps.target); } } const parallelCount = Math.min(config.parallelLoad, 3); const queue = [ ...pairQueue, ...singleOps.map((op) => ({ type: "single", op })), ]; const running = new Set(); return new Promise((resolve, reject) => { const processNext = async () => { if (!this.isLoading) { resolve(); return; } if (queue.length === 0 && running.size === 0) { resolve(); return; } while (running.size < parallelCount && queue.length > 0) { const item = queue.shift(); if (!item) continue; let promise; if (item.type === "pair") { promise = this._loadPairOperation( item.current, item.target, config, ); } else { promise = this._loadSingleOperation(item.op, config); } promise .then(() => running.delete(promise)) .then(processNext) .catch((error) => { running.delete(promise); if (!config.continueOnError) { reject(error); return; } processNext(); }); running.add(promise); } }; processNext(); }); } /** * Обновить прогресс операции и диспатчить событие * @private * @param {LoadOperation} operation * @param {number} progress - Прогресс 0-100 * @param {string} [phase] - Текущая фаза ('api_call', 'transform', 'save') */ _updateOperationProgress(operation, progress, phase) { operation.progress = Math.min(100, Math.max(0, progress)); if (phase) { operation.currentPhase = phase; } this.dispatchEvent( new CustomEvent("project-load-progress", { detail: { shortName: operation.shortName, projectName: operation.projectName, scheduleType: operation.scheduleType, percent: operation.progress, phase: operation.currentPhase, }, bubbles: true, composed: true, }), ); this._dispatchLoadProgress(); this.updateLoadingUI(); } /** * Диспатчить событие общего прогресса загрузки * @private */ _dispatchLoadProgress() { const progress = this._getProgress(); this.dispatchEvent( new CustomEvent("load-progress", { detail: { progress, overallPercent: progress.overallPercent, summary: progress.summary, }, bubbles: true, composed: true, }), ); } /** * Загрузить пару графиков (current + target) в одну БД * @private * @param {LoadOperation} currentOp - Операция для текущего графика * @param {LoadOperation} targetOp - Операция для целевого графика * @param {LoaderConfig} options */ async _loadPairOperation(currentOp, targetOp, options) { console.log( `[PrimaveraDataLoader] Loading pair: ${currentOp.shortName} (current) + ${targetOp.shortName} (target)...`, ); currentOp.status = "loading"; targetOp.status = "loading"; currentOp.startTime = Date.now(); targetOp.startTime = Date.now(); currentOp.abortController = new AbortController(); targetOp.abortController = currentOp.abortController; [currentOp, targetOp].forEach((op) => { this.dispatchEvent( new CustomEvent("project-load-start", { detail: { shortName: op.shortName, projectName: op.projectName, scheduleType: op.scheduleType, }, bubbles: true, composed: true, }), ); }); this._updateOperationProgress(currentOp, 5, "api_call"); this._updateOperationProgress(targetOp, 5, "api_call"); try { const databaseId = await dataService.syncPairFromPrimavera( { currentShortName: currentOp.shortName, targetShortName: targetOp.shortName, }, options.authToken, (percent, phase) => { const isCurrent = phase.startsWith("current_") || phase === "resolve_current_ids" || phase === "fetch_current_data" || phase === "import_current_db"; const isTarget = phase.startsWith("target_") || phase === "resolve_target_ids" || phase === "fetch_target_data" || phase === "import_target_db"; const mappedPhase = mapProgressPhase( phase.replace(/^(current_|target_)/, ""), ); if (isCurrent) { this._updateOperationProgress(currentOp, percent, mappedPhase); } else if (isTarget) { this._updateOperationProgress(targetOp, percent, mappedPhase); } else if (phase === "complete") { this._updateOperationProgress(currentOp, 100, "complete"); this._updateOperationProgress(targetOp, 100, "complete"); } }, { databaseName: `${currentOp.projectName}-${currentOp.pairId}`, }, ); this.dispatchEvent( new CustomEvent("project-database-created", { detail: { shortName: `${currentOp.shortName}+${targetOp.shortName}`, databaseId, projectId: currentOp.projectId, pairId: currentOp.pairId, pairLoaded: true, }, bubbles: true, composed: true, }), ); currentOp.databaseId = databaseId; targetOp.databaseId = databaseId; const metadata = await dataService.getDatabaseMetadata(databaseId); const totalActivities = metadata.activityCount || 0; currentOp.activityCount = Math.floor(totalActivities / 2); targetOp.activityCount = Math.floor(totalActivities / 2); [currentOp, targetOp].forEach((op) => { op.status = "completed"; op.progress = 100; op.endTime = Date.now(); this._updateKsgStatus(op, true); this.dispatchEvent( new CustomEvent("project-load-complete", { detail: { shortName: op.shortName, databaseId, activityCount: op.activityCount, pairLoaded: true, }, bubbles: true, composed: true, }), ); }); } catch (error) { console.error(`[PrimaveraDataLoader] Failed to load pair:`, error); ToastManager.getInstance().error( `Ошибка загрузки пары ${currentOp.shortName} → ${targetOp.shortName}: ${error.message}`, 5000, ); [currentOp, targetOp].forEach((op) => { op.status = "error"; op.errorMessage = error.message; op.endTime = Date.now(); this.dispatchEvent( new CustomEvent("project-load-error", { detail: { shortName: op.shortName, error: error.message, }, bubbles: true, composed: true, }), ); }); this._dispatchLoadProgress(); if (!options.continueOnError) { throw error; } } } /** * Устаревший метод для загрузки одиночного графика * @deprecated Используйте _loadPairOperation для загрузки пар * @private * @param {LoadOperation} operation * @param {LoaderConfig} options */ async _loadSingleOperation(operation, options) { console.log( `[PrimaveraDataLoader] Loading single ${operation.shortName} (${operation.scheduleType})...`, ); operation.status = "loading"; operation.startTime = Date.now(); operation.abortController = new AbortController(); this.dispatchEvent( new CustomEvent("project-load-start", { detail: { shortName: operation.shortName, projectName: operation.projectName, scheduleType: operation.scheduleType, }, bubbles: true, composed: true, }), ); this._updateOperationProgress(operation, 5, "api_call"); try { const databaseId = await dataService.syncFromPrimavera( [operation.shortName], options.authToken, (percent, phase) => { this._updateOperationProgress( operation, percent, mapProgressPhase(phase), ); }, ); this.dispatchEvent( new CustomEvent("project-database-created", { detail: { shortName: operation.shortName, databaseId, projectId: operation.projectId, pairId: operation.pairId, }, bubbles: true, composed: true, }), ); operation.databaseId = databaseId; const metadata = await dataService.getDatabaseMetadata(databaseId); operation.activityCount = metadata.activityCount || 0; this._updateOperationProgress(operation, 100, "complete"); operation.status = "completed"; operation.progress = 100; operation.endTime = Date.now(); this._updateKsgStatus(operation, true); this._updateOperationProgress(operation, 100); this.dispatchEvent( new CustomEvent("project-load-complete", { detail: { shortName: operation.shortName, databaseId, activityCount: operation.activityCount, }, bubbles: true, composed: true, }), ); } catch (error) { console.error( `[PrimaveraDataLoader] Failed to load ${operation.shortName}:`, error, ); ToastManager.getInstance().error( `Ошибка загрузки ${operation.shortName}: ${error.message}`, 5000, ); operation.status = "error"; operation.errorMessage = error.message; operation.endTime = Date.now(); this.dispatchEvent( new CustomEvent("project-load-error", { detail: { shortName: operation.shortName, error: error.message, }, bubbles: true, composed: true, }), ); this._dispatchLoadProgress(); if (!options.continueOnError) { throw error; } } } /** * Обновить статус KSG в AppSettingsModal через событие * @private * @param {LoadOperation} operation * @param {boolean} loaded */ _updateKsgStatus(operation, loaded) { window.dispatchEvent( new CustomEvent("ksg-status-change", { detail: { projectId: operation.projectId, pairId: operation.pairId, type: operation.scheduleType, loaded, }, }), ); } /** * Сгенерировать имя файла * @private * @param {LoadOperation} operation * @param {string} pattern * @returns {string} */ _generateFilename(operation, pattern) { const date = new Date().toISOString().split("T")[0]; return pattern .replace( "{projectName}", operation.projectName.replace(/[^a-zA-Z0-9]/g, "_"), ) .replace("{scheduleType}", operation.scheduleType) .replace("{date}", date) .replace("{shortName}", operation.shortName); } /** * Построить результат batch загрузки * @private * @returns {BatchLoadResult} */ async _buildResult() { const operations = [...this.activeOperations.values()]; const completed = operations.filter((op) => op.status === "completed"); const failed = operations.filter((op) => op.status === "error"); const cancelled = operations.filter((op) => op.status === "cancelled"); /** @type {BatchLoadResult} */ const result = { success: failed.length === 0 && cancelled.length === 0, totalOperations: operations.length, successfulLoads: completed.length, failedLoads: failed.length, cancelledLoads: cancelled.length, operations, totalDurationMs: Date.now() - this.loadStartTime, }; const sessionId = await this._saveLoadSession(result); if (sessionId) { result.sessionId = sessionId; } return result; } /** * Автоматически сохранить успешно загруженные операции в файл * @private * @param {LoaderConfig} config */ async _autoSaveCompletedOperations(config) { const pairs = this._groupOperationsByPairs(); for (const [pairId, pair] of pairs) { const current = pair.current; const target = pair.target; const isPair = current && target; if (isPair) { if (current.status === "completed" && target.status === "completed") { try { await this.exportPairDatabase( pairId, current.shortName, target.shortName, current.projectName, ); } catch (error) { console.error( `[PrimaveraDataLoader] Auto-save failed for pair ${pairId}:`, error, ); ToastManager.getInstance().warning( `Автосохранение пары ${current.shortName} → ${target.shortName} не удалось`, 4000, ); } } } else { const op = current || target; if (op && op.status === "completed") { try { const fileName = this._generateFilename(op, config.filenamePattern); await this.exportDatabase(op.shortName, fileName); } catch (error) { console.error( `[PrimaveraDataLoader] Auto-save failed for ${op.shortName}:`, error, ); ToastManager.getInstance().warning( `Автосохранение ${op.shortName} не удалось`, 4000, ); } } } } } /** * Получить прогресс загрузки * @private * @returns {LoadingProgress} */ _getProgress() { const operations = [...this.activeOperations.values()]; const total = operations.length; if (total === 0) { return { overallPercent: 0, operations: [], summary: { total: 0, completed: 0, loading: 0, pending: 0, failed: 0 }, }; } const completed = operations.filter( (op) => op.status === "completed", ).length; const loading = operations.filter((op) => op.status === "loading").length; const pending = operations.filter((op) => op.status === "pending").length; const failed = operations.filter((op) => op.status === "error").length; const progressSum = operations.reduce( (sum, op) => sum + (op.progress || 0), 0, ); const overallPercent = Math.round(progressSum / total); return { overallPercent, operations, summary: { total, completed, loading, pending, failed }, }; } /** * Получить количество активных параллельных загрузок (пар/одиночек) * @private * @returns {number} */ _getActiveParallelCount() { const loadingOps = [...this.activeOperations.values()].filter( (op) => op.status === "loading", ); const groups = new Set(); for (const op of loadingOps) { groups.add(op.pairId || op.operationId); } return groups.size; } // ==================== UI Rendering ==================== render() { const theme = document.documentElement.getAttribute("data-theme") || "consta-light"; this.shadowRoot.innerHTML = ` <style> :host { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 1000; --target-color: #9A7EE3; } :host([open]) { display: block; } .modal-overlay { position: fixed; inset: 0; background: var(--modal-overlay-bg); z-index: 1000; display: flex; align-items: center; justify-content: center; padding: var(--space-sm); } .modal-container { position: relative; width: 100%; max-width: 720px; max-height: 90vh; background: var(--primary-bg); border-radius: var(--radius-m); box-shadow: var(--shadow-lg); display: flex; flex-direction: column; overflow: hidden; } .modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-xs) var(--space-sm); min-height: 24px; border-bottom: 1px solid var(--border-light); background: var(--primary-bg); } .modal-title { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); color: var(--text-primary); text-transform: uppercase; letter-spacing: 0.05em; margin: 0; } .modal-close-btn { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; cursor: pointer; color: var(--text-secondary); border-radius: var(--radius-xs); transition: all var(--transition-fast); padding: 0; } .modal-close-btn:hover { background: var(--surface-ghost); color: var(--text-primary); } .modal-close-btn svg { width: 16px; height: 16px; } .modal-body { flex: 1; overflow-y: auto; padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-sm); } .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(--radius-xs); } .modal-footer { display: flex; justify-content: flex-end; gap: var(--space-xs); padding: var(--space-xs) var(--space-sm); border-top: 1px solid var(--border-light); background: var(--primary-bg); } .btn { height: 24px; padding: var(--space-2xs) var(--space-xs); font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); text-transform: uppercase; letter-spacing: 0.02em; border-radius: var(--radius-s); cursor: pointer; transition: all var(--transition-fast); display: inline-flex; align-items: center; gap: var(--space-2xs); border: none; } .btn svg { width: 14px; height: 14px; } .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); } .btn-danger { background: var(--error-color); color: var(--color-control-typo-primary, #fff); } .btn:disabled { opacity: 0.5; cursor: not-allowed; } /* Project Selection Screen */ .projects-section { flex: 1; display: flex; flex-direction: column; min-height: 0; } .section-header { display: flex; align-items: center; justify-content: flex-start; margin-bottom: var(--space-xs); } .section-header--collapsible { cursor: pointer; padding: var(--space-2xs) var(--space-xs); margin: calc(-1 * var(--space-2xs)) calc(-1 * var(--space-xs)); border-radius: var(--radius-s); transition: background var(--transition-fast); display: flex; align-items: center; gap: var(--space-xs); justify-content: flex-start; } .section-header--collapsible:hover { background: var(--surface-bg); } .section-title { font-size: var(--font-size-xs); font-weight: var(--font-weight-bold); text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary); margin: 0; } .section-expand-icon { display: flex; align-items: center; justify-content: center; color: var(--text-secondary); transition: transform var(--transition-fast); width: 16px; height: 16px; } .section-expand-icon svg { width: 100%; height: 100%; } .settings-section:not(.collapsed) .section-expand-icon { transform: rotate(90deg); } .projects-list { display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; min-height: 0; overflow-y: auto; padding-right: var(--space-2xs); } .projects-list::-webkit-scrollbar { width: 4px; } .projects-list::-webkit-scrollbar-track { background: transparent; } .projects-list::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: var(--radius-xs); } .project-card { background: transparent; border: 1px solid var(--border-light); border-radius: var(--radius-s); overflow: hidden; transition: border-color var(--transition-fast); cursor: pointer; } .project-card.selected { border-color: var(--accent-color); background: transparent; } .project-card.disabled { opacity: 0.5; cursor: not-allowed; } .project-header { display: flex; align-items: center; gap: var(--space-xs); padding: var(--space-xs) var(--space-sm); } .project-toggle-wrapper { display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .project-toggle { position: relative; width: 28px; height: 16px; cursor: pointer; flex-shrink: 0; } .project-toggle input { opacity: 0; width: 0; height: 0; position: absolute; } .project-toggle__slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--border-color); border-radius: 8px; transition: background var(--transition-fast); } .project-toggle__slider::after { content: ''; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; background: var(--color-control-typo-primary, #fff); border-radius: 50%; transition: transform var(--transition-fast); box-shadow: 0 1px 2px rgba(0,0,0,0.2); } .project-toggle input:checked + .project-toggle__slider { background: var(--accent-color); } .project-toggle input:checked + .project-toggle__slider::after { transform: translateX(12px); } .project-toggle input:disabled + .project-toggle__slider { opacity: 0.4; cursor: not-allowed; } .section-header__toggle { display: flex; align-items: center; gap: var(--space-sm); } .project-info { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; } .project-name { font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); color: var(--text-primary); line-height: 1.4; } .project-meta { font-size: var(--font-size-xs); color: var(--text-secondary); } .project-ksg-statuses { display: flex; flex-direction: column; gap: var(--space-2xs); 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(--radius-s); 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__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__dot--empty { background: var(--text-ghost); } .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); } .empty-projects { padding: var(--space-xl); text-align: center; color: var(--text-secondary); font-size: var(--font-size-sm); } .settings-section { flex-shrink: 0; } .settings-grid { display: flex; flex-direction: column; gap: var(--space-sm); padding-top: var(--space-sm); } .setting-row { display: flex; align-items: center; padding: var(--space-2xs) 0; } .setting-row--compact { gap: var(--space-xs); flex-wrap: wrap; } .setting-name { font-size: var(--font-size-sm); color: var(--text-primary); font-weight: var(--font-weight-medium); } .setting-unit { font-size: var(--font-size-sm); color: var(--text-secondary); } .setting-switch { display: flex; align-items: center; gap: var(--space-xs); cursor: pointer; width: 100%; } .setting-switch input { opacity: 0; width: 0; height: 0; position: absolute; } .setting-switch__slider { position: relative; width: 28px; height: 16px; background: var(--border-color); border-radius: 8px; transition: background var(--transition-fast); flex-shrink: 0; } .setting-switch__slider::after { content: ''; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; background: var(--color-control-typo-primary, #fff); border-radius: 50%; transition: transform var(--transition-fast); box-shadow: 0 1px 2px rgba(0,0,0,0.2); } .setting-switch input:checked + .setting-switch__slider { background: var(--accent-color); } .setting-switch input:checked + .setting-switch__slider::after { transform: translateX(12px); } .setting-switch__label { font-size: var(--font-size-sm); color: var(--text-primary); } .setting-segmented { display: flex; border: 1px solid var(--border-color); border-radius: var(--radius-xs); overflow: hidden; flex-shrink: 0; } .setting-segment { padding: 4px 12px; font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); color: var(--text-secondary); background: var(--primary-bg); border: none; border-right: 1px solid var(--border-color); cursor: pointer; transition: all var(--transition-fast); min-width: 32px; } .setting-segment:last-child { border-right: none; } .setting-segment:hover { background: var(--surface-bg); color: var(--text-primary); } .setting-segment.active { background: var(--accent-color); color: var(--color-control-typo-primary, #fff); } /* Loading Screen */ .overall-progress { margin-bottom: var(--space-md); padding: var(--space-sm); background: var(--surface-bg); border: 1px solid var(--border-light); border-radius: var(--radius-s); min-height: 82px; display: flex; flex-direction: column; justify-content: center; box-sizing: border-box; } .progress-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-xs); } .progress-title { font-size: var(--font-size-xs); font-weight: var(--font-weight-bold); text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary); } .progress-stats { font-size: var(--font-size-xs); color: var(--text-secondary); font-family: var(--font-mono); } .progress-bar { height: 6px; background: var(--border-color); border-radius: var(--radius-xs); overflow: hidden; margin-bottom: var(--space-xs); } .progress-fill { height: 100%; background: var(--accent-color); border-radius: var(--radius-xs); transition: width 0.3s ease; } .progress-time { display: flex; gap: var(--space-md); font-size: var(--font-size-xs); color: var(--text-secondary); } .progress-time-item { display: inline-flex; align-items: center; gap: var(--space-2xs); } .progress-time-item svg { width: 12px; height: 12px; } .operations-list { display: flex; flex-direction: column; gap: var(--space-sm); max-height: 420px; overflow-y: auto; padding-right: var(--space-2xs); } .operations-list::-webkit-scrollbar { width: 4px; } .operations-list::-webkit-scrollbar-track { background: transparent; } .operations-list::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: var(--radius-xs); } .operation-card { background: var(--surface-bg); border: 1px solid var(--border-light); border-radius: var(--radius-s); transition: border-color var(--transition-fast); overflow: hidden; flex-shrink: 0; } .operation-card:hover { border-color: var(--accent-color); } .operation-card.success { border-color: var(--success-color); } .operation-card.error { border-color: var(--error-color); } .operation-header { display: flex; align-items: center; gap: var(--space-xs); padding: var(--space-xs) var(--space-sm); border-bottom: 1px solid var(--border-light); } .operation-info { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; } .operation-name { font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); color: var(--text-primary); line-height: 1.4; } .operation-meta { font-size: var(--font-size-xs); color: var(--text-secondary); } .operation-status-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; flex-shrink: 0; } .operation-status-icon svg { width: 100%; height: 100%; } .operation-status-icon.loading svg { animation: spin 1s linear infinite; } .operation-status-icon.pending { color: var(--text-secondary); } .operation-status-icon.loading { color: var(--accent-color); } .operation-status-icon.completed { color: var(--success-color); } .operation-status-icon.error { color: var(--error-color); } .operation-status-icon.cancelled { color: var(--text-secondary); } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* Stream-specific styles */ .stream-list { display: flex; flex-direction: column; gap: var(--space-xs); padding: var(--space-xs) var(--space-sm) var(--space-sm) var(--space-sm); } .stream-card { display: flex; flex-direction: column; gap: var(--space-2xs); padding: var(--space-xs); background: var(--primary-bg); border-radius: var(--radius-s); border: 1px solid var(--border-light); flex-shrink: 0; } .stream-card.error { border-color: var(--error-color); background: var(--error-bg); } .stream-header { display: flex; align-items: center; gap: var(--space-xs); } .stream-type-badge { font-size: var(--font-size-2xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.05em; padding: 1px 4px; border-radius: var(--radius-xs); background: var(--accent-bg); color: var(--accent-color); flex-shrink: 0; } .stream-type-badge.target { background: rgba(154, 126, 227, 0.15); color: var(--target-color); } .stream-name { font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); color: var(--text-primary); flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .stream-progress-text { font-size: var(--font-size-xs); font-family: var(--font-mono); color: var(--text-secondary); min-width: 36px; text-align: right; } .stream-steps { display: flex; align-items: center; gap: var(--space-2xs); flex-wrap: wrap; } .stream-step { display: flex; align-items: center; gap: var(--space-3xs); font-size: var(--font-size-2xs); color: var(--text-tertiary); transition: color var(--transition-fast); } .stream-step::after { content: ''; width: 10px; height: 1px; background: var(--border-color); } .stream-step:last-child::after { display: none; } .stream-step--active { color: var(--accent-color); font-weight: var(--font-weight-medium); } .stream-step--completed { color: var(--success-color); } .stream-step--error { color: var(--error-color); font-weight: var(--font-weight-medium); } .stream-step--pending { color: var(--text-tertiary); } .stream-step svg { width: 10px; height: 10px; } .stream-progress-bar { height: 3px; background: var(--border-color); border-radius: var(--radius-xs); overflow: hidden; } .stream-progress-fill { height: 100%; border-radius: var(--radius-xs); transition: width 0.3s ease; background: var(--accent-color); } .stream-progress-fill.completed { background: var(--success-color); } .stream-progress-fill.error { background: var(--error-color); } .stream-progress-fill.pending { background: var(--text-tertiary); } .stream-error { display: flex; align-items: flex-start; gap: var(--space-2xs); padding: var(--space-xs); background: rgba(235, 87, 87, 0.08); border-radius: var(--radius-xs); font-size: var(--font-size-xs); color: var(--error-color); margin-top: var(--space-2xs); } .stream-error svg { width: 14px; height: 14px; flex-shrink: 0; margin-top: 1px; } /* Single operation stream card */ .operation-card > .stream-card { border: none; background: transparent; padding: var(--space-xs) var(--space-sm) var(--space-sm) var(--space-sm); } /* Results Screen */ .results-summary { display: flex; flex-direction: column; gap: var(--space-sm); margin-bottom: var(--space-md); padding: var(--space-sm); background: var(--surface-bg); border: 1px solid var(--border-light); border-radius: var(--radius-s); align-items: flex-start; min-height: 82px; box-sizing: border-box; } .summary-badges { display: flex; gap: var(--space-sm); flex-wrap: wrap; align-items: center; } .summary-item { display: inline-flex; align-items: center; gap: var(--space-2xs); padding: var(--space-2xs) var(--space-xs); font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); background: var(--primary-bg); border: 1px solid var(--border-light); border-radius: var(--radius-s); } .summary-item.success { color: var(--success-color); border-color: var(--success-color); background: var(--success-bg); } .summary-item.error { color: var(--error-color); border-color: var(--error-color); background: var(--error-bg); } .summary-item.warning { color: var(--warning-color); border-color: var(--warning-color); background: var(--warning-bg); } .summary-item svg { width: 14px; height: 14px; flex-shrink: 0; } .summary-duration { font-size: var(--font-size-xs); color: var(--text-secondary); } .operation-status-badge { font-size: var(--font-size-2xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.02em; padding: 2px 6px; border-radius: var(--radius-xs); flex-shrink: 0; } .operation-status-badge.success { background: var(--success-bg); color: var(--success-color); } .operation-status-badge.error { background: var(--error-bg); color: var(--error-color); } .operation-status-badge.warning { background: var(--warning-bg); color: var(--warning-color); } .results-list { display: flex; flex-direction: column; gap: var(--space-sm); max-height: 420px; overflow-y: auto; padding-right: var(--space-2xs); } .results-list::-webkit-scrollbar { width: 4px; } .results-list::-webkit-scrollbar-track { background: transparent; } .results-list::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: var(--radius-xs); } .result-card { background: var(--surface-bg); border: 1px solid var(--border-light); border-radius: var(--radius-s); transition: border-color var(--transition-fast); overflow: hidden; flex-shrink: 0; } .result-card.success { border-color: var(--success-color); } .result-card.error { border-color: var(--error-color); } .result-card:hover { border-color: var(--accent-color); } .result-header { display: flex; align-items: center; gap: var(--space-xs); padding: var(--space-xs) var(--space-sm); border-bottom: 1px solid var(--border-light); } .result-info { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; } .result-name { font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); color: var(--text-primary); line-height: 1.4; } .result-meta { font-size: var(--font-size-xs); color: var(--text-secondary); } .result-status { font-size: var(--font-size-2xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.02em; padding: 2px 6px; border-radius: var(--radius-xs); flex-shrink: 0; } .result-status.success { background: var(--success-bg); color: var(--success-color); } .result-status.error { background: var(--error-bg); color: var(--error-color); } .result-streams { display: flex; flex-direction: column; gap: var(--space-xs); padding: var(--space-xs) var(--space-sm) var(--space-sm) var(--space-sm); } .result-stream { display: flex; align-items: center; gap: var(--space-xs); padding: var(--space-xs); background: var(--primary-bg); border-radius: var(--radius-xs); } .result-stream.error { background: var(--error-bg); } .result-stream-group { display: flex; flex-direction: column; gap: 2px; } .result-stream-badge { font-size: var(--font-size-2xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.05em; padding: 1px 4px; border-radius: var(--radius-xs); background: var(--accent-bg); color: var(--accent-color); flex-shrink: 0; } .result-stream-badge.target { background: rgba(154, 126, 227, 0.15); color: var(--target-color); } .result-stream-name { font-size: var(--font-size-xs); color: var(--text-primary); flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .result-stream-count { font-size: var(--font-size-xs); color: var(--text-secondary); font-family: var(--font-mono); display: inline-flex; align-items: center; gap: 4px; } .result-stream-error { display: flex; align-items: center; gap: var(--space-2xs); font-size: var(--font-size-xs); color: var(--error-color); } .result-stream-error svg { width: 14px; height: 14px; flex-shrink: 0; } .result-actions { display: flex; gap: var(--space-xs); padding: 0 var(--space-sm) var(--space-sm) var(--space-sm); } .result-actions .btn { height: 20px; padding: var(--space-3xs) var(--space-2xs); font-size: var(--font-size-2xs); } .export-action { display: inline-flex; align-items: center; gap: var(--space-xs); } .save-spinner { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; animation: spin 1s linear infinite; color: var(--accent-color); } .save-spinner svg { width: 100%; height: 100%; } .status-icon { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; flex-shrink: 0; } .status-icon svg { width: 100%; height: 100%; } .status-icon.loading svg { animation: spin 1s linear infinite; } .status-icon.completed { color: var(--success-color); } .status-icon.error { color: var(--error-color); } .status-icon.warning { color: var(--warning-color); } .status-icon.cancelled { color: var(--text-secondary); } .status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; } .status-dot--pending { background: var(--text-secondary); } .status-dot--cancelled { background: var(--text-secondary); } </style> <div class="modal-overlay" id="modal-overlay"> <div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="modal-title"> <div class="modal-header"> <h2 class="modal-title" id="modal-title">Загрузка данных из Primavera P6</h2> <button class="modal-close-btn" id="modal-close-btn" title="Закрыть"> ${X} </button> </div> <div class="modal-body" id="modal-body"> <!-- Content will be rendered here --> </div> <div class="modal-footer" id="modal-footer"> <!-- Footer buttons will be rendered here --> </div> </div> </div> `; } /** * Обновить видимость модалки * @private */ updateVisibility() { if (this.isOpen) { this.style.display = "flex"; document.body.style.overflow = "hidden"; } else { this.style.display = "none"; document.body.style.overflow = ""; } } /** * Отрисовать экран выбора проектов * @private */ renderProjectSelection() { const body = this.shadowRoot.getElementById("modal-body"); const footer = this.shadowRoot.getElementById("modal-footer"); if (!body || !footer) return; let projectsHtml = ""; const enabledProjects = this.projects.filter((p) => { const validPairs = p.schedulePairs.filter((pair) => pair.isValid).length; return validPairs > 0; }); const allEnabledSelected = enabledProjects.length > 0 && enabledProjects.every((p) => this.selectedProjectIds.has(p.projectId)); if (this.projects.length === 0) { projectsHtml = ` <div class="empty-projects"> Нет настроенных проектов. Сначала настройте проекты в настройках. </div> `; } else { projectsHtml = this.projects .map((project) => { const isSelected = this.selectedProjectIds.has(project.projectId); const validPairs = project.schedulePairs.filter( (p) => p.isValid, ).length; const isDisabled = validPairs === 0; const ksgStatusesHtml = project.schedulePairs .filter((p) => p.isValid) .map((pair) => { const currentStatus = pair.currentKsgLoaded ? "success" : pair.currentScheduleId ? "empty" : "warning"; const targetStatus = pair.targetKsgLoaded ? "success" : pair.targetScheduleId ? "empty" : "warning"; return ` <div class="ksg-pair-status"> <div class="ksg-pair-status__item"> <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 || "не указан"} </span> </div> <div class="ksg-pair-status__item"> <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 || "не указан"} </span> </div> </div> `; }) .join(""); return ` <div class="project-card ${isSelected ? "selected" : ""} ${isDisabled ? "disabled" : ""}" data-project-id="${project.projectId}"> <div class="project-header"> <div class="project-toggle-wrapper"> <label class="project-toggle" title="${isDisabled ? "Нет валидных пар графиков" : isSelected ? "Отключить проект" : "Включить проект"}"> <input type="checkbox" data-project-id="${project.projectId}" ${isSelected ? "checked" : ""} ${isDisabled ? "disabled" : ""}> <span class="project-toggle__slider"></span> </label> </div> <div class="project-info"> <div class="project-name" title="${this.escapeHtml(project.name)}">${this.escapeHtml(project.name)}</div> <div class="project-meta">${this.getScheduleWord(validPairs)}</div> </div> ${ksgStatusesHtml ? `<div class="project-ksg-statuses">${ksgStatusesHtml}</div>` : ""} </div> </div> `; }) .join(""); } body.innerHTML = ` <div class="projects-section"> <div class="section-header"> <div class="section-header__toggle"> <label class="project-toggle" title="${allEnabledSelected ? "Отключить все проекты" : "Включить все проекты"}"> <input type="checkbox" id="master-toggle" ${allEnabledSelected ? "checked" : ""} ${enabledProjects.length === 0 ? "disabled" : ""}> <span class="project-toggle__slider"></span> </label> <h3 class="section-title">Проекты для загрузки</h3> </div> </div> <div class="projects-list"> ${projectsHtml} </div> </div> <div class="settings-section collapsed"> <div class="section-header section-header--collapsible" data-action="toggle-settings"> <span class="section-expand-icon">${ChevronRight}</span> <h3 class="section-title">Настройки загрузки</h3> </div> <div class="settings-grid" style="display: none;"> <div class="setting-row"> <label class="setting-switch"> <input type="checkbox" id="auto-save-toggle" ${this.config.autoSave ? "checked" : ""}> <span class="setting-switch__slider"></span> <span class="setting-switch__label">Автосохранение в файл</span> </label> </div> <div class="setting-row"> <label class="setting-switch"> <input type="checkbox" id="continue-on-error-toggle" ${this.config.continueOnError ? "checked" : ""}> <span class="setting-switch__slider"></span> <span class="setting-switch__label">Продолжать при ошибке</span> </label> </div> <div class="setting-row setting-row--compact"> <span class="setting-name">Одновременно загружать проектов</span> <div class="setting-segmented"> <button class="setting-segment${this.config.parallelLoad === 1 ? " active" : ""}" data-value="1">1</button> <button class="setting-segment${this.config.parallelLoad === 2 ? " active" : ""}" data-value="2">2</button> <button class="setting-segment${this.config.parallelLoad === 3 ? " active" : ""}" data-value="3">3</button> </div> </div> </div> </div> `; footer.innerHTML = ` <button class="btn btn-secondary" id="cancel-btn">Отмена</button> <button class="btn btn-primary" id="start-load-btn" ${this.projects.length === 0 ? "disabled" : ""}> Начать загрузку </button> `; this._attachSelectionListeners(); } /** * Отрисовать экран прогресса загрузки * @private */ renderLoadingProgress() { const body = this.shadowRoot.getElementById("modal-body"); const footer = this.shadowRoot.getElementById("modal-footer"); if (!body || !footer) return; const progress = this._getProgress(); const elapsed = Date.now() - this.loadStartTime; body.innerHTML = ` <div class="overall-progress"> <div class="progress-header"> <span class="progress-title">Общий прогресс</span> <span class="progress-stats">${progress.summary.completed}/${progress.summary.total} операций</span> </div> <div class="progress-bar"> <div class="progress-fill" style="width: ${progress.overallPercent}%"></div> </div> <div class="progress-time"> <span class="progress-time-item">${Clock} ${formatDuration(elapsed)}</span> ${this._getActiveParallelCount() > 0 ? `<span class="progress-time-item">${Loader} ${this._getActiveParallelCount()} параллельно</span>` : ""} </div> </div> <div class="operations-list" id="operations-list"> <!-- Operations will be rendered here --> </div> `; footer.innerHTML = ` <button class="btn btn-danger" id="cancel-load-btn">Отменить загрузку</button> `; this.renderOperationsList(); this._attachLoadingListeners(); } /** * Сгруппировать операции по парам (current + target) * @private * @returns {Map<string, {current?: LoadOperation, target?: LoadOperation}>} */ _groupOperationsByPairs() { const pairs = new Map(); this.activeOperations.forEach((op) => { if (!pairs.has(op.pairId)) { pairs.set(op.pairId, {}); } const pair = pairs.get(op.pairId); pair[op.scheduleType] = op; }); return pairs; } /** * Получить CSS-класс состояния шага на основе фазы операции * @private * @param {LoadOperation} op * @param {string} stepPhase - 'api_call' | 'transform' | 'save' * @returns {string} */ _getStepState(op, stepPhase) { const phases = ["api_call", "transform", "save", "complete"]; const currentIdx = phases.indexOf(op.currentPhase || "api_call"); const stepIdx = phases.indexOf(stepPhase); if (op.status === "completed") return "completed"; if (op.status === "error") { if (stepIdx < currentIdx) return "completed"; if (stepIdx === currentIdx) return "error"; return "pending"; } if (op.status === "cancelled") { if (stepIdx < currentIdx) return "completed"; return "pending"; } if (stepIdx < currentIdx) return "completed"; if (stepIdx === currentIdx) return "active"; return "pending"; } /** * Отрисовать шаг потока * @private * @param {LoadOperation} op * @param {string} stepPhase * @param {string} label * @returns {string} */ _renderStreamStep(op, stepPhase, label) { const state = this._getStepState(op, stepPhase); const icon = state === "completed" ? Check : state === "error" ? AlertCircle : state === "active" ? Loader : '<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:currentColor;"></span>'; return `<span class="stream-step stream-step--${state}">${icon} ${label}</span>`; } /** * Отрисовать список операций (сгруппированных по парам) * @private */ renderOperationsList() { const container = this.shadowRoot.getElementById("operations-list"); if (!container) return; const pairs = this._groupOperationsByPairs(); const loadingIconSvg = `<span class="status-icon loading">${Loader}</span>`; const statusIcons = { pending: '<span class="status-dot status-dot--pending"></span>', loading: loadingIconSvg, completed: `<span class="status-icon completed">${Check}</span>`, error: `<span class="status-icon error">${XCircle}</span>`, cancelled: `<span class="status-icon cancelled">${XCircle}</span>`, }; const statusText = { pending: "В очереди", loading: "Загрузка...", completed: "Завершено", error: "Ошибка", cancelled: "Отменено", }; const isResults = this.currentScreen === "results"; container.innerHTML = [...pairs.entries()] .map(([pairId, pair]) => { const current = pair.current; const target = pair.target; const isPair = current && target; if (isPair) { const pairStatus = this._getPairStatus(current, target); const pairDuration = this._getPairDuration(current, target); const cardClass = isResults ? pairStatus === "completed" ? "success" : pairStatus === "error" ? "error" : "" : ""; const headerBadge = isResults ? `<span class="operation-status-badge ${pairStatus === "completed" ? "success" : pairStatus === "error" ? "error" : "warning"}">${statusText[pairStatus]}</span>` : ""; return ` <div class="operation-card ${cardClass}"> <div class="operation-header"> <span class="operation-status-icon ${pairStatus}">${statusIcons[pairStatus]}</span> <div class="operation-info"> <div class="operation-name">${this.escapeHtml(current.projectName)} — Пара графиков</div> <div class="operation-meta">${statusText[pairStatus]}${pairDuration ? ` • ${pairDuration}` : ""}</div> </div> ${headerBadge} </div> <div class="stream-list"> <div class="stream-card ${current.status === "error" ? "error" : ""}"> <div class="stream-header"> <span class="stream-type-badge">Текущий</span> <span class="stream-name" title="${current.shortName}">${current.shortName}</span> <span class="stream-progress-text">${isResults ? (current.status === "completed" ? `${(current.activityCount || 0).toLocaleString()} работ` : current.status === "error" ? "Ошибка" : "") : `${current.progress}%`}</span> </div> <div class="stream-steps"> ${this._renderStreamStep(current, "api_call", "Загрузка")} ${this._renderStreamStep(current, "transform", "Обработка")} ${this._renderStreamStep(current, "save", "Сохранение")} </div> <div class="stream-progress-bar"> <div class="stream-progress-fill ${current.status === "completed" ? "completed" : current.status === "error" ? "error" : current.status === "loading" ? "" : "pending"}" style="width: ${current.progress}%"></div> </div> ${ current.errorMessage ? ` <div class="stream-error"> ${AlertCircle} <span>${this.escapeHtml(current.errorMessage)}</span> </div> ` : "" } </div> <div class="stream-card ${target.status === "error" ? "error" : ""}"> <div class="stream-header"> <span class="stream-type-badge target">Целевой</span> <span class="stream-name" title="${target.shortName}">${target.shortName}</span> <span class="stream-progress-text">${isResults ? (target.status === "completed" ? `${(target.activityCount || 0).toLocaleString()} работ` : target.status === "error" ? "Ошибка" : "") : `${target.progress}%`}</span> </div> <div class="stream-steps"> ${this._renderStreamStep(target, "api_call", "Загрузка")} ${this._renderStreamStep(target, "transform", "Обработка")} ${this._renderStreamStep(target, "save", "Сохранение")} </div> <div class="stream-progress-bar"> <div class="stream-progress-fill ${target.status === "completed" ? "completed" : target.status === "error" ? "error" : target.status === "loading" ? "" : "pending"}" style="width: ${target.progress}%"></div> </div> ${ target.errorMessage ? ` <div class="stream-error"> ${AlertCircle} <span>${this.escapeHtml(target.errorMessage)}</span> </div> ` : "" } </div> </div> </div> `; } else { const op = current || target; const duration = op.endTime && op.startTime ? formatDuration(op.endTime - op.startTime) : op.startTime ? formatDuration(Date.now() - op.startTime) : ""; const singleCardClass = isResults ? op.status === "completed" ? "success" : op.status === "error" ? "error" : "" : ""; const singleBadge = isResults ? `<span class="operation-status-badge ${op.status === "completed" ? "success" : op.status === "error" ? "error" : "warning"}">${statusText[op.status]}</span>` : ""; return ` <div class="operation-card ${singleCardClass}"> <div class="operation-header"> <span class="operation-status-icon ${op.status}">${statusIcons[op.status]}</span> <div class="operation-info"> <div class="operation-name">${this.escapeHtml(op.projectName)} — ${op.shortName}</div> <div class="operation-meta">${statusText[op.status]}${duration ? ` • ${duration}` : ""}</div> </div> ${singleBadge} </div> <div class="stream-card ${op.status === "error" ? "error" : ""}"> <div class="stream-header"> <span class="stream-type-badge ${op.scheduleType === "target" ? "target" : ""}">${op.scheduleType === "current" ? "Текущий" : "Целевой"}</span> <span class="stream-name" title="${op.shortName}">${op.shortName}</span> <span class="stream-progress-text">${isResults ? (op.status === "completed" ? `${(op.activityCount || 0).toLocaleString()} работ` : op.status === "error" ? "Ошибка" : "") : `${op.progress}%`}</span> </div> <div class="stream-steps"> ${this._renderStreamStep(op, "api_call", "Загрузка")} ${this._renderStreamStep(op, "transform", "Обработка")} ${this._renderStreamStep(op, "save", "Сохранение")} </div> <div class="stream-progress-bar"> <div class="stream-progress-fill ${op.status === "completed" ? "completed" : op.status === "error" ? "error" : op.status === "loading" ? "" : "pending"}" style="width: ${op.progress}%"></div> </div> ${ op.errorMessage ? ` <div class="stream-error"> ${AlertCircle} <span>${this.escapeHtml(op.errorMessage)}</span> </div> ` : "" } </div> </div> `; } }) .join(""); } /** * Получить общий статус пары * @private * @param {LoadOperation} current * @param {LoadOperation} target * @returns {string} */ _getPairStatus(current, target) { if (current.status === "error" || target.status === "error") return "error"; if (current.status === "cancelled" || target.status === "cancelled") return "cancelled"; if (current.status === "loading" || target.status === "loading") return "loading"; if (current.status === "completed" && target.status === "completed") return "completed"; return "pending"; } /** * Получить длительность пары * @private * @param {LoadOperation} current * @param {LoadOperation} target * @returns {string} */ _getPairDuration(current, target) { const start = Math.min( current.startTime || Date.now(), target.startTime || Date.now(), ); const end = Math.max( current.endTime || Date.now(), target.endTime || Date.now(), ); if (!start) return ""; return formatDuration(end - start); } /** * Обновить UI загрузки * @private */ updateLoadingUI() { if (this.currentScreen !== "loading") return; if (this._pendingUIUpdate) return; this._pendingUIUpdate = true; requestAnimationFrame(() => { this._pendingUIUpdate = false; if (this.currentScreen !== "loading") return; const progress = this._getProgress(); const elapsed = Date.now() - this.loadStartTime; const progressFill = this.shadowRoot.querySelector(".progress-fill"); const progressStats = this.shadowRoot.querySelector(".progress-stats"); const progressTime = this.shadowRoot.querySelector(".progress-time"); if (progressFill) progressFill.style.width = `${progress.overallPercent}%`; if (progressStats) progressStats.textContent = `${progress.summary.completed}/${progress.summary.total} операций`; if (progressTime) { const activeParallel = this._getActiveParallelCount(); progressTime.innerHTML = ` <span class="progress-time-item">${Clock} ${formatDuration(elapsed)}</span> ${activeParallel > 0 ? `<span class="progress-time-item">${Loader} ${activeParallel} параллельно</span>` : ""} `; } this.renderOperationsList(); }); } /** * Отрисовать экран результатов (сгруппированных по парам) * @private */ async renderResults() { const body = this.shadowRoot.getElementById("modal-body"); const footer = this.shadowRoot.getElementById("modal-footer"); if (!body || !footer) return; const result = await this._buildResult(); body.innerHTML = ` <div class="results-summary"> <div class="summary-badges"> <span class="summary-item success"> <span class="status-icon completed">${Check}</span> ${result.successfulLoads} успешно </span> ${ result.failedLoads > 0 ? ` <span class="summary-item error"> <span class="status-icon error">${XCircle}</span> ${result.failedLoads} ошибок </span> ` : "" } ${ result.cancelledLoads > 0 ? ` <span class="summary-item warning"> <span class="status-icon warning">${XCircle}</span> ${result.cancelledLoads} отменено </span> ` : "" } </div> <div class="summary-duration">Выполнено на ${formatDuration(result.totalDurationMs)}</div> </div> <div class="operations-list" id="operations-list"> <!-- Operations will be rendered here --> </div> `; footer.innerHTML = ` <button class="btn btn-secondary" id="close-results-btn">Закрыть</button> <button class="btn btn-primary" id="new-load-btn">Новая загрузка</button> `; this.renderOperationsList(); // Добавляем кнопки экспорта для успешных операций const pairs = this._groupOperationsByPairs(); const cards = this.shadowRoot.querySelectorAll(".operation-card"); let cardIndex = 0; for (const [pairId, pair] of pairs) { const card = cards[cardIndex++]; if (!card) continue; const current = pair.current; const target = pair.target; const isPair = current && target; if (isPair) { const pairSuccess = current.status === "completed" && target.status === "completed"; if (pairSuccess) { const actions = document.createElement("div"); actions.className = "result-actions"; actions.innerHTML = ` <div class="export-action"> <button class="btn btn-secondary export-pair-btn" data-pair-id="${current.pairId}" data-current-name="${current.shortName}" data-target-name="${target.shortName}" data-project-name="${this.escapeHtml(current.projectName)}"> ${Save} Сохранить </button> </div> `; card.appendChild(actions); } } else { const op = current || target; if (op.status === "completed") { const actions = document.createElement("div"); actions.className = "result-actions"; actions.innerHTML = ` <div class="export-action"> <button class="btn btn-secondary export-btn" data-short-name="${op.shortName}"> ${Save} Сохранить </button> </div> `; card.appendChild(actions); } } } this._attachResultsListeners(); } /** * Обновить состояние общего переключателя * @private */ _updateMasterToggleState() { const masterToggle = this.shadowRoot.getElementById("master-toggle"); if (!masterToggle) return; const enabledProjects = this.projects.filter((p) => { const validPairs = p.schedulePairs.filter((pair) => pair.isValid).length; return validPairs > 0; }); const allEnabledSelected = enabledProjects.length > 0 && enabledProjects.every((p) => this.selectedProjectIds.has(p.projectId)); masterToggle.checked = allEnabledSelected; } /** * Подключить обработчики для экрана выбора * @private */ _attachSelectionListeners() { const settingsHeader = this.shadowRoot.querySelector( '[data-action="toggle-settings"]', ); if (settingsHeader) { settingsHeader.addEventListener("click", () => { const settingsSection = this.shadowRoot.querySelector(".settings-section"); const settingsGrid = this.shadowRoot.querySelector(".settings-grid"); if (settingsSection && settingsGrid) { settingsSection.classList.toggle("collapsed"); settingsGrid.style.display = settingsSection.classList.contains( "collapsed", ) ? "none" : "block"; } }); } this.shadowRoot .querySelectorAll('input[type="checkbox"][data-project-id]') .forEach((toggle) => { toggle.addEventListener("change", (e) => { const projectId = e.target.dataset.projectId; const card = this.shadowRoot.querySelector( `.project-card[data-project-id="${projectId}"]`, ); if (e.target.checked) { this.selectedProjectIds.add(projectId); if (card) card.classList.add("selected"); } else { this.selectedProjectIds.delete(projectId); if (card) card.classList.remove("selected"); } this._updateMasterToggleState(); }); }); this.shadowRoot.querySelectorAll(".project-card").forEach((card) => { card.addEventListener("click", (e) => { if (e.target.tagName === "INPUT" || e.target.closest(".project-toggle")) return; const projectId = card.dataset.projectId; const toggle = card.querySelector( 'input[type="checkbox"][data-project-id]', ); if (toggle && !toggle.disabled) { toggle.checked = !toggle.checked; if (toggle.checked) { this.selectedProjectIds.add(projectId); card.classList.add("selected"); } else { this.selectedProjectIds.delete(projectId); card.classList.remove("selected"); } this._updateMasterToggleState(); } }); }); const masterToggle = this.shadowRoot.getElementById("master-toggle"); if (masterToggle) { masterToggle.addEventListener("change", (e) => { const selectAll = e.target.checked; this.projects.forEach((project) => { const validPairs = project.schedulePairs.filter( (p) => p.isValid, ).length; if (validPairs === 0) return; const projectId = project.projectId; const card = this.shadowRoot.querySelector( `.project-card[data-project-id="${projectId}"]`, ); const toggle = this.shadowRoot.querySelector( `input[type="checkbox"][data-project-id="${projectId}"]`, ); if (selectAll) { this.selectedProjectIds.add(projectId); if (card) card.classList.add("selected"); if (toggle) toggle.checked = true; } else { this.selectedProjectIds.delete(projectId); if (card) card.classList.remove("selected"); if (toggle) toggle.checked = false; } }); }); } const autoSaveToggle = this.shadowRoot.getElementById("auto-save-toggle"); if (autoSaveToggle) { autoSaveToggle.addEventListener("change", (e) => { this.config.autoSave = e.target.checked; this._persistConfig(); }); } const parallelSegments = this.shadowRoot.querySelectorAll(".setting-segment"); parallelSegments.forEach((segment) => { segment.addEventListener("click", (e) => { const value = parseInt(e.currentTarget.dataset.value); this.config.parallelLoad = value; parallelSegments.forEach((s) => s.classList.remove("active")); e.currentTarget.classList.add("active"); this._persistConfig(); }); }); const continueOnErrorToggle = this.shadowRoot.getElementById( "continue-on-error-toggle", ); if (continueOnErrorToggle) { continueOnErrorToggle.addEventListener("change", (e) => { this.config.continueOnError = e.target.checked; this._persistConfig(); }); } const cancelBtn = this.shadowRoot.getElementById("cancel-btn"); if (cancelBtn) { cancelBtn.addEventListener("click", () => this.close()); } const startBtn = this.shadowRoot.getElementById("start-load-btn"); if (startBtn) { startBtn.addEventListener("click", () => { if (this.selectedProjectIds.size === 0) { this.showError( "Нет выбранных проектов", "Выберите хотя бы один проект для загрузки", ); return; } this.load(); }); } } /** * Подключить обработчики для экрана загрузки * @private */ _attachLoadingListeners() { const cancelBtn = this.shadowRoot.getElementById("cancel-load-btn"); if (cancelBtn) { cancelBtn.addEventListener("click", () => this.cancel()); } if (this.progressUpdateInterval) { clearInterval(this.progressUpdateInterval); } this.progressUpdateInterval = setInterval(() => { if (this.isLoading) { this.updateLoadingUI(); } else { clearInterval(this.progressUpdateInterval); } }, 500); } /** * Подключить обработчики для экрана результатов * @private */ _attachResultsListeners() { const closeBtn = this.shadowRoot.getElementById("close-results-btn"); if (closeBtn) { closeBtn.addEventListener("click", () => this.close()); } const newLoadBtn = this.shadowRoot.getElementById("new-load-btn"); if (newLoadBtn) { newLoadBtn.addEventListener("click", () => { this.currentScreen = "selection"; this.renderProjectSelection(); }); } this.shadowRoot.querySelectorAll(".export-btn").forEach((btn) => { btn.addEventListener("click", async (e) => { const target = e.currentTarget; const shortName = target.dataset.shortName; const actionWrap = target.closest(".export-action"); target.disabled = true; const spinner = document.createElement("span"); spinner.className = "save-spinner"; spinner.innerHTML = Loader; if (actionWrap) actionWrap.appendChild(spinner); try { await this.exportDatabase(shortName); } catch (err) { console.error("Export failed:", err); } finally { target.disabled = false; spinner.remove(); } }); }); this.shadowRoot.querySelectorAll(".export-pair-btn").forEach((btn) => { btn.addEventListener("click", async (e) => { const target = e.currentTarget; const pairId = target.dataset.pairId; const currentName = target.dataset.currentName; const targetName = target.dataset.targetName; const projectName = target.dataset.projectName; const actionWrap = target.closest(".export-action"); target.disabled = true; const spinner = document.createElement("span"); spinner.className = "save-spinner"; spinner.innerHTML = Loader; if (actionWrap) actionWrap.appendChild(spinner); try { await this.exportPairDatabase( pairId, currentName, targetName, projectName, ); } catch (err) { console.error("Pair export failed:", err); } finally { target.disabled = false; spinner.remove(); } }); }); } /** * Показать ошибку * @private * @param {string} title * @param {string} message */ showError(title, message) { alert(`${title}\n\n${message}`); } /** * Экранирование HTML * @private * @param {string} str * @returns {string} */ escapeHtml(str) { if (!str) return ""; const div = document.createElement("div"); div.textContent = str; return div.innerHTML; } /** * Настроить слушатели событий * @private */ setupEventListeners() { const overlay = this.shadowRoot.getElementById("modal-overlay"); if (overlay) { overlay.addEventListener("click", (e) => { if (e.target === e.currentTarget) this.close(); }); } const closeBtn = this.shadowRoot.getElementById("modal-close-btn"); if (closeBtn) { closeBtn.addEventListener("click", () => this.close()); } document.addEventListener("keydown", this.handleKeyDown); } /** * Очистить слушатели событий * @private */ cleanupEventListeners() { document.removeEventListener("keydown", this.handleKeyDown); } /** * Обработчик нажатия клавиш * @private * @param {KeyboardEvent} e */ handleKeyDown(e) { if (!this.isOpen) return; if (e.key === "Escape") { this.close(); } } } // Регистрируем компонент customElements.define("primavera-data-loader", PrimaveraDataLoader); export { PrimaveraDataLoader }; export default PrimaveraDataLoader;