/
otk11neter
/
TestMESSystem
Обзор
Документация
Войти
/
otk11neter
/
TestMESSystem
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
script.js
1 384 строки
63 KB
otk11neter
BatareonV2.0
30 янв 2026, 15:07
Верифицирован
30 янв 2026, 15:07
0194816
Код
Авторство
О чём код?
// Использование относительного пути для API (автоматически использует HTTPS если нужно) const API_URL = '/api'; const TOAST_DURATION = 3000; const SEARCH_DEBOUNCE_DELAY = 300; // === HELPER ФУНКЦИИ === const helpers = { // Создать стиль для карточки статистики createStatCardStyle: (bgColor, label, value, percent, unit = '') => ({ container: `flex: 1; min-width: 120px; background: linear-gradient(135deg, ${bgColor[0]}, ${bgColor[1]}); padding: 12px 16px; border-radius: 8px; text-align: center; border: 1px solid ${bgColor[2]};`, label: 'font-size: 11px; color: #ffffff; margin-bottom: 3px;', value: 'font-size: 24px; font-weight: 700; color: #ffffff; margin-bottom: 2px;', unit: 'font-size: 11px; color: #ffffff;' }), // Форматировать число с разделителем тысяч formatNumber: (num) => parseInt(num || 0).toLocaleString(), // Дебаунс функция debounce: (func, delay) => { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func(...args), delay); }; }, // Получить % значение getPercent: (value, total) => total > 0 ? ((value / total) * 100).toFixed(1) : 0 }; window.kanban = { tasksData: { production: [], todo: [], progress: [], done: [], archive: [] }, batchCache: {}, // Кэш для быстрого доступа по ID engineerTableVisible: false, masterTableVisible: false, searchTimeout: null, showArchive: false, pendingFocus: null, currentView: 'grid', // Только grid представление currentView: 'table', // Текущее представление: 'table' или 'kanban' // 🔔 Показать уведомление showNotification(message, type = 'success') { const toast = document.createElement('div'); toast.style.cssText = ` position: fixed; bottom: 20px; right: 20px; background: ${type === 'success' ? '#10b981' : '#ef4444'}; color: white; padding: 16px 24px; border-radius: 6px; font-weight: 500; z-index: 99999; animation: slideIn 0.3s ease; box-shadow: 0 4px 12px rgba(0,0,0,0.3); `; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => { toast.style.animation = 'slideOut 0.3s ease'; setTimeout(() => toast.remove(), 300); }, TOAST_DURATION); }, // ✅ Названия колонок getStatusLabels() { return { production: 'Производство', todo: 'Тест', progress: 'Отлеживание', done: 'OCV1', archive: 'Архив' }; }, // ✅ ДИАПАЗОНЫ со шагом 170 номеров getRangeLabels() { return [ '17000-17169.9', '17170-17339.9', '17340-17509.9', '17510-17679.9', '17680-17849.9', '17850-18019.9', '18020-18189.9', '18190-18359.9', '18360-18529.9', '18530-18699.9', '18700-18869.9', '18870-19039.9' ]; }, async init() { console.log('Kanban v5.0 - Alternative View + Table + Archive Analytics'); try { // Проверяем соединение с сервером const health = await fetch(`${API_URL}/health`); if (!health.ok) throw new Error('Сервер недоступен'); // Загружаем данные из SQLite await this.loadBatches(); this.renderAllTasks(); this.updateStats(); this.bindEvents(); } catch (error) { console.error('❌ Ошибка инициализации:', error); alert('⚠️ Не удалось подключиться к серверу. Убедитесь, что Flask запущен на http://localhost:5000'); } }, getDefaultMasterStations() { return { 'Участок смешивания АНОД': { input: 0, output: 0 }, 'Машина нанесения АНОД': { input: 0, output: 0 }, 'Участок смешивания КАТОД': { input: 0, output: 0 }, 'Участок смешивания КАТОД изолятор': { input: 0, output: 0 }, 'Машина нанесения КАТОД': { input: 0, output: 0 }, 'Каландрирование АНОД': { input: 0, output: 0 }, 'Каландрирование КАТОД': { input: 0, output: 0 }, 'Лазерная резка АНОД': { input: 0, output: 0 }, 'Лазерная резка КАТОД': { input: 0, output: 0 }, 'Zукладка': { input: 0, output: 0 }, 'Участок сборки': { input: 0, output: 0 }, 'Инъекция электролита': { input: 0, output: 0 }, 'Химическая формовка': { input: 0, output: 0 }, 'Дегазация': { input: 0, output: 0 }, 'Загибка кромок': { input: 0, output: 0 } }; }, async loadBatches() { try { const response = await fetch(`${API_URL}/batches`); const batches = await response.json(); // Организуем батчи по статусам this.tasksData = { production: [], todo: [], progress: [], done: [], archive: [] }; this.batchCache = {}; batches.forEach(batch => { // Если батч заархивирован, добавляем в архив if (batch.archived) { batch.status = 'archive'; } if (!this.tasksData[batch.status]) { this.tasksData[batch.status] = []; } // Инициализируем masterStations если его нет if (!batch.masterStations) { batch.masterStations = this.getDefaultMasterStations(); // Сохраняем в БД this.saveBatch(batch); } this.tasksData[batch.status].push(batch); this.batchCache[batch.id] = batch; }); } catch (error) { console.error('Ошибка загрузки батчей:', error); } }, getTotal(batch) { return batch.groups.reduce((sum, g) => sum + parseInt(g.count || 0), 0) + parseInt(batch.scrap || 0); }, bindEvents() { // Добавление новой партии через кнопку (нет больше переключения режимов) document.querySelectorAll('.add-btn').forEach(btn => { btn.addEventListener('click', () => this.addBatch(btn.dataset.status)); }); // Закрытие модального окна document.getElementById('modal-close')?.addEventListener('click', () => this.closeModal()); // Перехватываем Ctrl+И для добавления document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && (e.key === 'и' || e.key === 'b' || e.keyCode === 192)) { e.preventDefault(); this.quickCreateBatch(); } if (e.key === 'Escape') { document.getElementById('batchModal')?.classList.remove('active'); document.getElementById('analyticsModal')?.classList.remove('active'); } }); // Клики по партиям document.addEventListener('click', (e) => { const batch = e.target.closest('.batch'); if (!batch) return; if (e.target.closest('.batch-summary') && !e.target.closest('.delete-btn')) { const batchId = parseInt(batch.dataset.id); this.openModal(batchId); } if (e.target.closest('.delete-btn')) { const batchId = parseInt(batch.dataset.id); if (confirm('Удалить партию?')) { this.deleteBatch(batchId); } } }); document.getElementById('engineer-btn')?.addEventListener('click', () => this.toggleEngineerTable()); document.getElementById('master-btn')?.addEventListener('click', () => this.toggleMasterTable()); document.getElementById('btn-analytics')?.addEventListener('click', () => this.showAnalytics()); document.getElementById('btn-archive')?.addEventListener('click', () => this.toggleArchiveView()); document.getElementById('btn-clear')?.addEventListener('click', () => this.clearAll()); document.getElementById('analytics-close')?.addEventListener('click', () => { document.getElementById('analyticsModal').classList.remove('active'); }); }, quickCreateBatch() { const title = prompt('Введите название партии:\n(оставьте пусто для автоматического)'); if (title === null) return; // Отмена const finalTitle = title || `Партия #${Math.random().toString(36).substr(2, 9).toUpperCase()}`; this.addBatch('todo', finalTitle); }, bindDragAndDrop() { document.addEventListener('dragstart', (e) => { const batch = e.target.closest('.batch'); if (batch) { batch.classList.add('dragging'); e.dataTransfer.setData('text/plain', batch.dataset.id); } }); document.addEventListener('dragend', () => { document.querySelectorAll('.batch').forEach(b => b.classList.remove('dragging')); document.querySelectorAll('.column').forEach(c => c.classList.remove('drag-over')); }); document.querySelectorAll('.column').forEach(column => { column.addEventListener('dragover', (e) => { e.preventDefault(); column.classList.add('drag-over'); }); column.addEventListener('dragleave', () => { column.classList.remove('drag-over'); }); column.addEventListener('drop', (e) => this.handleDrop(e)); }); }, async handleDrop(e) { e.preventDefault(); const column = e.target.closest('.column'); if (!column) return; try { const batchId = parseInt(e.dataTransfer.getData('text/plain')); const newStatus = column.dataset.status; const batch = this.batchCache[batchId]; if (batch && batch.status !== newStatus) { await this.updateBatchStatus(batchId, newStatus); await this.loadBatches(); this.renderAllTasks(); } } catch(err) { console.error('Drag & Drop error:', err); } }, renderAllTasks() { const grid = document.getElementById('batchesGrid'); if (!grid) return; grid.innerHTML = ''; // Если показываем архив, только архивные карточки // Иначе все остальные let allBatches = []; if (this.showArchive) { allBatches = this.tasksData['archive'] || []; } else { ['production', 'todo', 'progress', 'done'].forEach(status => { allBatches.push(...(this.tasksData[status] || [])); }); } // Отрисовываем все карточки в сетке allBatches.forEach((batch) => { const card = this.createBatchElement(batch); grid.appendChild(card); }); this.updateStats(); }, createBatchElement(batch) { if (!batch.groups) batch.groups = Array(12).fill().map(() => ({count: 0})); if (!batch.created) batch.created = new Date().toISOString(); // Количество годных ячеек — итог из таблицы инженера (группы), брак — из поля scrap const groupsTotal = (batch.groups || []).reduce((sum, g) => sum + parseInt(g.count || 0), 0); const goodCells = groupsTotal; const scrapTotal = parseInt(batch.scrap || 0); const labels = this.getStatusLabels(); const statusColors = { production: '#FF8C00', todo: '#00B050', progress: '#003d4c', done: '#00B050', archive: '#6B7280' }; const div = document.createElement('div'); div.className = 'batch'; div.draggable = true; div.dataset.id = batch.id; div.dataset.status = batch.status; div.title = `${batch.title} • ${new Date(batch.created).toLocaleDateString('ru-RU')}`; div.style.borderLeftColor = statusColors[batch.status] || '#999'; div.style.borderLeftWidth = '4px'; const isArchived = batch.status === 'archive'; div.innerHTML = ` <div class="batch-summary"> <div style="flex: 1; padding: 12px;"> <div class="batch-title">${batch.title}</div> <div style="display: flex; gap: 16px; margin-top: 12px; align-items: center;"> <div style="display: flex; flex-direction: column; align-items: center;"> <div style="font-size: 20px; font-weight: 700; color: #10b981;">${helpers.formatNumber(goodCells)}</div> <div style="font-size: 10px; color: #666; margin-top: 2px;">годных</div> </div> <div style="display: flex; flex-direction: column; align-items: center;"> <div style="font-size: 20px; font-weight: 700; color: #ef4444;">${helpers.formatNumber(scrapTotal)}</div> <div style="font-size: 10px; color: #666; margin-top: 2px;">брак</div> </div> </div> </div> <div style="display:flex; align-items:center; gap:8px;"> <button class="delete-btn" title="Удалить">×</button> </div> </div> `; return div; }, openModal(batchId) { const batch = this.batchCache[batchId]; if (!batch) return; const ranges = this.getRangeLabels(); const labels = this.getStatusLabels(); document.getElementById('modalTitle').textContent = batch.title; document.getElementById('modalSummary').textContent = `${labels[batch.status].toUpperCase()} • ${this.getTotal(batch)} шт.`; document.getElementById('batchModal').classList.add('active'); // Скрываем таблицы и убираем классы active с кнопок this.engineerTableVisible = false; this.masterTableVisible = false; document.getElementById('modalTable').style.display = 'none'; document.getElementById('masterTable').style.display = 'none'; document.getElementById('engineer-btn').classList.remove('active'); document.getElementById('master-btn').classList.remove('active'); // Обновляем обработчик для кнопки архивирования const archiveBtn = document.getElementById('modal-archive-btn'); if (archiveBtn) { archiveBtn.onclick = () => this.archiveBatch(batchId); } // Обновляем обработчик для кнопки импорта const importBtn = document.getElementById('import-btn'); const modalFileUpload = document.getElementById('modal-file-upload'); if (importBtn && modalFileUpload) { importBtn.onclick = () => modalFileUpload.click(); modalFileUpload.onchange = (e) => { const files = Array.from(e.target.files || []); if (!files.length) return; this.showImportDialog(files, batchId); e.target.value = ''; }; } this.renderModalTable(batch, ranges); this.renderMasterTable(batch); }, renderModalTable(batch, ranges) { const groupsTotal = batch.groups.reduce((sum, g) => sum + parseInt(g.count || 0), 0); const scrapTotal = parseInt(batch.scrap || 0); const grandTotal = groupsTotal + scrapTotal; let html = ` <thead> <tr> <th>Диапазон</th> <th>Количество</th> <th>Действия</th> </tr> </thead> <tbody> `; batch.groups.forEach((group, gIndex) => { const count = parseInt(group.count || 0); const rangeLabel = ranges[gIndex] || `Диапазон ${gIndex + 1}`; html += ` <tr> <td>${rangeLabel}</td> <td> <input type="number" min="0" value="${count}" class="table-input" data-batch-id="${batch.id}" data-group-index="${gIndex}" data-type="group" onchange="kanban.updateGroup(${batch.id}, ${gIndex}, this.value)" onkeydown="kanban.handleTableKeydown(event, ${batch.id}, ${gIndex}, 'group', ${batch.groups.length})" style="width: 80px; padding: 6px; border: 2px solid #4b5563; background: #111827; color: white; border-radius: 4px; transition: border-color 0.2s;"> </td> <td><button onclick="kanban.clearGroup(${batch.id}, ${gIndex})" style="padding: 4px 8px; background: #ef4444; color: white; border: none; border-radius: 4px; cursor: pointer;">✕</button></td> </tr> `; }); html += ` <tr style="background: #1e1e1e;"> <td><strong>БРАК</strong></td> <td> <input type="number" min="0" value="${scrapTotal}" class="table-input" data-batch-id="${batch.id}" data-type="scrap" onchange="kanban.updateScrap(${batch.id}, this.value)" onkeydown="kanban.handleTableKeydown(event, ${batch.id}, null, 'scrap', ${batch.groups.length})" style="width: 80px; padding: 6px; background: #ef4444; color: white; border: 2px solid #ef4444; border-radius: 4px; transition: border-color 0.2s;"> </td> <td></td> </tr> <tr class="group-total-final"> <td><strong>ИТОГО ХОРОШИХ:</strong></td><td><strong>${groupsTotal}</strong></td><td></td></tr> <tr class="group-total-final"> <td><strong>ВСЕГО:</strong></td><td><strong>${grandTotal}</strong></td><td></td></tr> </tbody> `; const tableElement = document.getElementById('modalTable'); tableElement.innerHTML = html; // Вешаем обработчик на inputs для навигации this.attachTableNavigation(batch.id, batch.groups.length); }, attachTableNavigation(batchId, groupCount) { const inputs = document.querySelectorAll(`input[data-batch-id="${batchId}"]`); inputs.forEach(input => { input.addEventListener('focus', () => { input.style.borderColor = '#00B050'; input.style.boxShadow = '0 0 0 3px rgba(0, 176, 80, 0.2)'; }); input.addEventListener('blur', () => { input.style.borderColor = '#4b5563'; input.style.boxShadow = 'none'; }); }); }, handleTableKeydown(event, batchId, groupIndex, type, totalGroups) { const inputs = Array.from(document.querySelectorAll(`input[data-batch-id="${batchId}"]`)); const currentIndex = inputs.findIndex(input => input === event.target); if (event.key === 'Tab') { event.preventDefault(); let nextIndex; if (event.shiftKey) { // Shift+Tab - движение вверх nextIndex = currentIndex - 1; if (nextIndex < 0) nextIndex = inputs.length - 1; } else { // Tab - движение вниз nextIndex = currentIndex + 1; if (nextIndex >= inputs.length) nextIndex = 0; } inputs[nextIndex].focus(); inputs[nextIndex].select(); } else if (event.key === 'Enter') { event.preventDefault(); // Enter - движение на следующее поле вниз const nextIndex = currentIndex + 1; if (nextIndex < inputs.length) { inputs[nextIndex].focus(); inputs[nextIndex].select(); } else { // Если это последнее поле, закрыть таблицу или остаться event.target.blur(); } } else if (event.key === 'ArrowDown' && !event.ctrlKey) { // Arrow Down - тоже движение вниз event.preventDefault(); const nextIndex = currentIndex + 1; if (nextIndex < inputs.length) { inputs[nextIndex].focus(); inputs[nextIndex].select(); } } else if (event.key === 'ArrowUp' && !event.ctrlKey) { // Arrow Up - движение вверх event.preventDefault(); const nextIndex = currentIndex - 1; if (nextIndex >= 0) { inputs[nextIndex].focus(); inputs[nextIndex].select(); } } }, renderMasterTable(batch) { console.log('renderMasterTable called with batch:', batch.id, 'masterStations:', batch.masterStations); // Инициализируем мастер данные если их нет if (!batch.masterStations) { console.log('Initializing masterStations for batch', batch.id); batch.masterStations = { 'Участок смешивания АНОД': { input: 0, output: 0 }, 'Машина нанесения АНОД': { input: 0, output: 0 }, 'Участок смешивания КАТОД': { input: 0, output: 0 }, 'Участок смешивания КАТОД изолятор': { input: 0, output: 0 }, 'Машина нанесения КАТОД': { input: 0, output: 0 }, 'Каландрирование АНОД': { input: 0, output: 0 }, 'Каландрирование КАТОД': { input: 0, output: 0 }, 'Лазерная резка АНОД': { input: 0, output: 0 }, 'Лазерная резка КАТОД': { input: 0, output: 0 }, 'Zукладка': { input: 0, output: 0 }, 'Участок сборки': { input: 0, output: 0 }, 'Инъекция электролита': { input: 0, output: 0 }, 'Химическая формовка': { input: 0, output: 0 }, 'Дегазация': { input: 0, output: 0 }, 'Загибка кромок': { input: 0, output: 0 } }; // Сохраняем инициализированные данные this.saveBatch(batch); } let html = ` <thead> <tr> <th style="text-align:center;">Производственный участок</th> <th style="text-align:center;">Выработка кг/м/шт</th> <th style="text-align:center;">Выработка в ячейках</th> </tr> </thead> <tbody> `; // Определяем порядок станций явно const stationOrder = [ 'Участок смешивания АНОД', 'Машина нанесения АНОД', 'Участок смешивания КАТОД', 'Участок смешивания КАТОД изолятор', 'Машина нанесения КАТОД', 'Каландрирование АНОД', 'Каландрирование КАТОД', 'Лазерная резка АНОД', 'Лазерная резка КАТОД', 'Zукладка', 'Участок сборки', 'Инъекция электролита', 'Химическая формовка', 'Дегазация', 'Загибка кромок' ]; const stationConfigs = { 'Участок смешивания АНОД': 'anode', 'Машина нанесения АНОД': 'anode', 'Участок смешивания КАТОД': 'cathode', 'Участок смешивания КАТОД изолятор': 'cathode', 'Машина нанесения КАТОД': 'cathode', 'Каландрирование АНОД': 'anode', 'Каландрирование КАТОД': 'cathode', 'Лазерная резка АНОД': 'anode_laser', 'Лазерная резка КАТОД': 'cathode_laser', 'Zукладка': 'anode', 'Участок сборки': 'anode', 'Инъекция электролита': 'cathode', 'Химическая формовка': 'cathode', 'Дегазация': 'anode', 'Загибка кромок': 'anode' }; let afterZukladka = false; let stationIndex = 0; console.log('stationOrder:', stationOrder); console.log('batch.masterStations before loop:', batch.masterStations); // Используем явно определённый порядок вместо Object.keys stationOrder.forEach((station) => { // Инициализируем данные станции если её нет if (!batch.masterStations[station]) { batch.masterStations[station] = { input: 0, output: 0 }; } const data = batch.masterStations[station]; console.log(`Processing station ${station}:`, data); const type = stationConfigs[station] || 'anode'; if (station === 'Zукладка') afterZukladka = true; html += ` <tr> <td style="text-align:center;">${station}</td> <td style="text-align:center;"> <input type="number" step="0.01" class="master-input" data-batch-id="${batch.id}" data-station="${station}" data-type="input" data-index="${stationIndex}" value="${data.input || 0}" onchange="kanban.updateMasterInput(${batch.id}, '${station}', this.value, '${type}')" onkeydown="kanban.handleMasterKeydown(event, ${batch.id})" onfocus="if(this.value=='0'){this.value='';}" onblur="if(this.value==''){this.value='0';}" style="width: 100%; padding: 6px; border: 2px solid #ddd; border-radius: 4px; font-size: 13px; text-align:center; transition: border-color 0.2s;"> </td> <td style="text-align:center;"> <input type="number" step="0.01" class="master-input" data-batch-id="${batch.id}" data-station="${station}" data-type="output" data-index="${stationIndex}" value="${afterZukladka ? (data.input || 0) : (data.output || 0)}" ${afterZukladka ? 'readonly' : ''} onchange="kanban.updateMasterOutput(${batch.id}, '${station}', this.value)" onkeydown="kanban.handleMasterKeydown(event, ${batch.id})" style="width: 100%; padding: 6px; border: 2px solid #ddd; border-radius: 4px; font-size: 13px; background: #f0f9ff; text-align:center; transition: border-color 0.2s;" ${afterZukladka ? 'style="background: #f0f0f0; border: 2px solid #ccc; cursor: not-allowed;"' : ''}> </td> </tr> `; stationIndex++; }); html += `</tbody>`; console.log('Generated HTML length:', html.length, 'stations count:', stationOrder.length); const masterTableElement = document.getElementById('masterTable'); if (masterTableElement) { masterTableElement.innerHTML = html; console.log('masterTable updated, innerHTML length:', masterTableElement.innerHTML.length); } else { console.error('masterTable element not found!'); } // Вешаем стили для фокуса на inputs таблицы мастера this.attachMasterTableNavigation(batch.id); }, attachMasterTableNavigation(batchId) { const inputs = document.querySelectorAll(`input.master-input[data-batch-id="${batchId}"]`); inputs.forEach(input => { input.addEventListener('focus', () => { input.style.borderColor = '#00B050'; input.style.boxShadow = '0 0 0 3px rgba(0, 176, 80, 0.2)'; }); input.addEventListener('blur', () => { input.style.borderColor = '#ddd'; input.style.boxShadow = 'none'; }); }); }, handleMasterKeydown(event, batchId) { // Ограничиваем навигацию только INPUT столбцом (второй столбец) const inputs = Array.from(document.querySelectorAll(`input.master-input[data-batch-id="${batchId}"][data-type="input"]`)); const currentIndex = inputs.findIndex(input => input === event.target); if (event.key === 'Tab') { event.preventDefault(); let nextIndex; if (event.shiftKey) { // Shift+Tab - движение вверх nextIndex = currentIndex - 1; if (nextIndex < 0) nextIndex = inputs.length - 1; } else { // Tab - движение вниз nextIndex = currentIndex + 1; if (nextIndex >= inputs.length) nextIndex = 0; } // Сохраняем станцию для восстановления фокуса после перерисовки const nextStation = inputs[nextIndex].getAttribute('data-station'); this.pendingFocus = { batchId, station: nextStation, type: 'input' }; inputs[nextIndex].focus(); inputs[nextIndex].select(); } else if (event.key === 'Enter') { event.preventDefault(); // Enter - движение на следующее поле вниз let nextIndex = currentIndex + 1; if (nextIndex < inputs.length) { // Сохраняем станцию для восстановления фокуса после перерисовки const nextStation = inputs[nextIndex].getAttribute('data-station'); this.pendingFocus = { batchId, station: nextStation, type: 'input' }; inputs[nextIndex].focus(); inputs[nextIndex].select(); } else { event.target.blur(); } } else if (event.key === 'ArrowDown' && !event.ctrlKey) { // Arrow Down - движение на 2 позиции вниз (на следующую станцию) event.preventDefault(); let nextIndex = currentIndex + 2; if (nextIndex < inputs.length) { // Сохраняем станцию для восстановления фокуса после перерисовки const nextStation = inputs[nextIndex].getAttribute('data-station'); this.pendingFocus = { batchId, station: nextStation, type: 'input' }; inputs[nextIndex].focus(); inputs[nextIndex].select(); } } else if (event.key === 'ArrowUp' && !event.ctrlKey) { // Arrow Up - движение на 2 позиции вверх (на предыдущую станцию) event.preventDefault(); let nextIndex = currentIndex - 2; if (nextIndex >= 0) { // Сохраняем станцию для восстановления фокуса после перерисовки const nextStation = inputs[nextIndex].getAttribute('data-station'); this.pendingFocus = { batchId, station: nextStation, type: 'input' }; inputs[nextIndex].focus(); inputs[nextIndex].select(); } } else if (event.key === 'ArrowRight') { // Arrow Right - движение на 1 позицию вправо (input → output) event.preventDefault(); let nextIndex = currentIndex + 1; if (nextIndex < inputs.length) { // Сохраняем станцию для восстановления фокуса после перерисовки const nextStation = inputs[nextIndex].getAttribute('data-station'); this.pendingFocus = { batchId, station: nextStation, type: 'input' }; inputs[nextIndex].focus(); inputs[nextIndex].select(); } } else if (event.key === 'ArrowLeft') { // Arrow Left - движение на 1 позицию влево (output → input) event.preventDefault(); let nextIndex = currentIndex - 1; if (nextIndex >= 0) { // Сохраняем станцию для восстановления фокуса после перерисовки const nextStation = inputs[nextIndex].getAttribute('data-station'); this.pendingFocus = { batchId, station: nextStation, type: 'input' }; inputs[nextIndex].focus(); inputs[nextIndex].select(); } } }, async updateGroup(batchId, groupIndex, value) { const batch = this.batchCache[batchId]; if (batch) { batch.groups[groupIndex].count = parseInt(value) || 0; await this.saveBatch(batch); } }, async clearGroup(batchId, groupIndex) { const batch = this.batchCache[batchId]; if (batch) { batch.groups[groupIndex].count = 0; await this.saveBatch(batch); this.openModal(batchId); } }, async updateScrap(batchId, value) { const batch = this.batchCache[batchId]; if (batch) { batch.scrap = parseInt(value) || 0; await this.saveBatch(batch); } }, closeModal() { document.getElementById('batchModal').classList.remove('active'); document.getElementById('engineer-btn').classList.remove('active'); document.getElementById('master-btn').classList.remove('active'); this.engineerTableVisible = false; this.masterTableVisible = false; }, async updateMasterInput(batchId, stationName, value, type) { const batch = this.batchCache[batchId]; if (batch && batch.masterStations) { const inputVal = parseFloat(value) || 0; batch.masterStations[stationName].input = inputVal; // Автоматический расчёт выработки в ячейках по формуле let output = 0; if (type === 'anode') { output = (inputVal * 2000) / 65 / 29; } else if (type === 'cathode') { output = (inputVal * 2000) / 65 / 28; } else if (type === 'anode_laser') { output = inputVal / 29; } else if (type === 'cathode_laser') { output = inputVal / 28; } batch.masterStations[stationName].output = parseFloat(output.toFixed(2)); await this.saveBatch(batch); // Сохраняем текущий фокус перед перерисовкой const pendingFocus = this.pendingFocus; this.renderMasterTable(batch); // Восстанавливаем фокус после перерисовки if (pendingFocus) { setTimeout(() => { const targetInput = document.querySelector( `input.master-input[data-batch-id="${pendingFocus.batchId}"][data-station="${pendingFocus.station}"][data-type="${pendingFocus.type}"]` ); if (targetInput) { targetInput.focus(); targetInput.select(); } this.pendingFocus = null; }, 0); } } }, async updateMasterOutput(batchId, stationName, value) { const batch = this.batchCache[batchId]; if (batch && batch.masterStations) { batch.masterStations[stationName].output = parseFloat(value) || 0; await this.saveBatch(batch); } }, async toggleMasterStation(batchId, stationName) { const batch = this.batchCache[batchId]; if (batch) { if (!batch.masterStations) { batch.masterStations = {}; } batch.masterStations[stationName] = batch.masterStations[stationName] === 1 ? 0 : 1; await this.saveBatch(batch); this.renderMasterTable(batch); } }, toggleEngineerTable() { const btn = document.getElementById('engineer-btn'); const masterBtn = document.getElementById('master-btn'); this.engineerTableVisible = !this.engineerTableVisible; this.masterTableVisible = false; masterBtn.classList.remove('active'); if (this.engineerTableVisible) { btn.classList.add('active'); document.getElementById('modalTable').style.display = 'table'; document.getElementById('masterTable').style.display = 'none'; } else { btn.classList.remove('active'); document.getElementById('modalTable').style.display = 'none'; } }, toggleMasterTable() { const btn = document.getElementById('master-btn'); const engineerBtn = document.getElementById('engineer-btn'); this.masterTableVisible = !this.masterTableVisible; this.engineerTableVisible = false; engineerBtn.classList.remove('active'); if (this.masterTableVisible) { btn.classList.add('active'); document.getElementById('masterTable').style.display = 'table'; document.getElementById('modalTable').style.display = 'none'; } else { btn.classList.remove('active'); document.getElementById('masterTable').style.display = 'none'; } }, async addBatch(status, customTitle = null) { const labels = this.getStatusLabels(); let title = customTitle; if (!title) { title = prompt('Название партии:', `${new Date().toLocaleDateString('ru-RU')}`); } if (!title || title.trim().length === 0) { if (!customTitle) { this.showNotification('Название партии не может быть пусто', 'error'); } return; } try { const response = await fetch(`${API_URL}/batches`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title.trim(), status, groups: Array(12).fill().map(() => ({count: 0})), scrap: 0 }) }); if (response.ok) { await this.loadBatches(); this.renderAllTasks(); this.showNotification(`✓ Партия "${title}" создана`); } else { throw new Error(`HTTP ${response.status}`); } } catch (error) { console.error('Ошибка создания партии:', error); this.showNotification('Ошибка создания партии', 'error'); } }, async deleteBatch(batchId) { try { const response = await fetch(`${API_URL}/batches/${batchId}`, { method: 'DELETE' }); if (response.ok) { await this.loadBatches(); this.renderAllTasks(); this.showNotification('✓ Партия удалена'); } else { throw new Error(`HTTP ${response.status}`); } } catch (error) { console.error('Ошибка удаления партии:', error); this.showNotification('Ошибка удаления партии', 'error'); } }, closeModal() { document.getElementById('batchModal')?.classList.remove('active'); }, async archiveBatch(batchId) { try { const batch = this.batchCache[batchId]; if (batch) { batch.archived = true; const response = await fetch(`${API_URL}/batches/${batchId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(batch) }); if (response.ok) { await this.loadBatches(); // Автоматически переключаемся на просмотр архива this.showArchive = true; const btn = document.getElementById('btn-archive'); if (btn) { btn.classList.add('active'); } this.renderAllTasks(); this.updateStats(); this.showNotification('✓ Партия отправлена в архив'); this.closeModal(); } else { const error = await response.text(); console.error('Ошибка от сервера:', error); throw new Error(`HTTP ${response.status}`); } } } catch (error) { console.error('Ошибка архивирования партии:', error); this.showNotification('Ошибка архивирования партии: ' + error.message, 'error'); } }, async unarchiveBatch(batchId) { try { const batch = this.batchCache[batchId]; if (batch) { batch.archived = false; batch.status = 'todo'; const response = await fetch(`${API_URL}/batches/${batchId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(batch) }); if (response.ok) { await this.loadBatches(); this.renderAllTasks(); this.showNotification('✓ Партия восстановлена из архива'); } else { throw new Error(`HTTP ${response.status}`); } } } catch (error) { console.error('Ошибка восстановления партии:', error); this.showNotification('Ошибка восстановления партии', 'error'); } }, toggleArchiveView() { this.showArchive = !this.showArchive; const btn = document.getElementById('btn-archive'); if (btn) { btn.classList.toggle('active', this.showArchive); } this.renderAllTasks(); this.updateStats(); }, async saveBatch(batch) { try { const response = await fetch(`${API_URL}/batches/${batch.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(batch) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); } catch (error) { console.error('Ошибка сохранения партии:', error); this.showNotification('Ошибка сохранения партии', 'error'); } }, async updateBatchStatus(batchId, newStatus) { const batch = this.batchCache[batchId]; if (batch) { batch.status = newStatus; await this.saveBatch(batch); } }, updateStats() { let totalGood = 0, totalAll = 0; const statuses = ['production', 'todo', 'progress', 'done']; statuses.forEach(status => { const el = document.getElementById(status + '-count'); const tasks = this.tasksData[status] || []; if (el) el.textContent = tasks.length; tasks.forEach(batch => { const good = batch.groups.reduce((sum, g) => sum + parseInt(g.count || 0), 0); const scrap = parseInt(batch.scrap || 0); totalGood += good; totalAll += good + scrap; }); }); const totalElement = document.getElementById('total-count'); if (totalElement) totalElement.textContent = totalAll; }, async handleFileUpload(file, batchId = null) { if (!file) return; const formData = new FormData(); formData.append('file', file); formData.append('title', file.name); if (batchId) formData.append('batch_id', batchId); try { const res = await fetch('/api/batches/import', { method: 'POST', body: formData }); const data = await res.json(); if (res.ok) { this.showNotification('Импорт выполнен'); await this.loadBatches(); this.renderAllTasks(); // Открыть модал для обновлённой партии если это был batch upload if (batchId) { this.openModal(parseInt(batchId)); } } else { this.showNotification('Ошибка импорта: ' + (data.error || JSON.stringify(data)), 'error'); } } catch (err) { console.error('Ошибка при загрузке файла:', err); this.showNotification('Ошибка при загрузке файла', 'error'); } }, showImportDialog(files, batchId) { // Проверяем тип файлов const fileArray = Array.isArray(files) ? files : [files]; const hasIFC = fileArray.some(f => f.name.toLowerCase().endsWith('.ifc')); let dialogMessage = ''; if (hasIFC) { dialogMessage = 'Обнаружены IFC файлы. Данные будут извлечены со столбца 21.\n\n' + 'Для Excel файлов укажите диапазон данных:\n' + '• A:A - весь столбец A\n' + '• A1:A100 - строки 1-100 столбца A\n' + '• (оставьте пусто для использования всех данных)'; } else { dialogMessage = 'Укажите диапазон данных в Excel:\n\n' + 'Примеры:\n' + '• A:A - весь столбец A\n' + '• A1:A100 - строки 1-100 столбца A\n' + '• B:C - столбцы B и C\n' + '• A1:F50 - диапазон A1:F50\n\n' + '(оставьте пусто для использования всех данных)'; } const prompt = window.prompt(dialogMessage, 'A:A'); if (prompt === null) return; // Отмена // files может быть массивом File this.handleFileUpload(files, batchId, prompt); }, async handleFileUpload(files, batchId = null, range = null) { if (!files) return; // files может быть единичным File или массивом const fileArray = Array.isArray(files) ? files : [files]; const formData = new FormData(); for (const f of fileArray) { formData.append('file', f); } formData.append('title', fileArray[0]?.name || 'import'); if (batchId) formData.append('batch_id', batchId); if (range) formData.append('range', range); try { const res = await fetch('/api/batches/import', { method: 'POST', body: formData }); const data = await res.json(); if (res.ok) { this.showNotification('Импорт выполнен'); await this.loadBatches(); this.renderAllTasks(); if (batchId) this.openModal(parseInt(batchId)); } else { this.showNotification('Ошибка импорта: ' + (data.error || JSON.stringify(data)), 'error'); } } catch (err) { console.error('Ошибка при загрузке файла:', err); this.showNotification('Ошибка при загрузке файла', 'error'); } }, exportBatch(batchId) { const batch = this.batchCache[batchId]; if (!batch) { this.showNotification('Партия не найдена', 'error'); return; } // Подготавливаем объект для экспорта const exportData = { id: batch.id, title: batch.title, status: batch.status, created: batch.created, updated: batch.updated, groups: batch.groups, scrap: batch.scrap, masterStations: batch.masterStations || {} }; // Конвертируем в JSON и скачиваем как файл const dataStr = JSON.stringify(exportData, null, 2); const blob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${batch.title}_${batch.id}.json`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); this.showNotification(`✓ Партия "${batch.title}" экспортирована`); }, showAnalytics() { const analytics = this.calculateAnalytics(); this.currentAnalytics = analytics; this.renderAnalyticsTab('engineer'); document.getElementById('analyticsModal').classList.add('active'); document.getElementById('analytics-tab-engineer').onclick = () => this.renderAnalyticsTab('engineer'); document.getElementById('analytics-tab-master').onclick = () => this.renderAnalyticsTab('master'); }, renderAnalyticsTab(tab) { const analytics = this.currentAnalytics; const tabEngineer = document.getElementById('analytics-tab-engineer'); const tabMaster = document.getElementById('analytics-tab-master'); if (tab === 'engineer') { tabEngineer.style.background = '#003d4c'; tabEngineer.style.border = '2px solid #003d4c'; tabMaster.style.background = '#374151'; tabMaster.style.border = '2px solid transparent'; document.getElementById('analyticsModalBody').innerHTML = this.renderEngineerAnalytics(analytics); } else { tabMaster.style.background = '#003d4c'; tabMaster.style.border = '2px solid #003d4c'; tabEngineer.style.background = '#374151'; tabEngineer.style.border = '2px solid transparent'; document.getElementById('analyticsModalBody').innerHTML = this.renderMasterAnalytics(analytics); } }, calculateAnalytics() { let totalGood = 0, totalScrap = 0, totalItems = 0, totalBatches = 0; const byGroup = Array(12).fill(0); const ranges = this.getRangeLabels(); // Для расчета брака по операциям const stationScrap = {}; const stationOutput = {}; // Включаем ВСЕ статусы, включая архив в аналитику const allStatuses = ['production', 'todo', 'progress', 'done', 'archive']; allStatuses.forEach(status => { totalBatches += this.tasksData[status].length; this.tasksData[status].forEach(batch => { const good = batch.groups.reduce((sum, g) => sum + parseInt(g.count || 0), 0); const scrap = parseInt(batch.scrap || 0); totalGood += good; totalScrap += scrap; totalItems += good + scrap; batch.groups.forEach((g, i) => byGroup[i] += parseInt(g.count || 0)); // Аналитика по мастер-таблице - брак по операциям if (batch.masterStations) { const stations = batch.masterStations; const zagibka = stations['Загибка кромок']?.output || 0; Object.keys(stations).forEach(stationName => { if (!stationScrap[stationName]) { stationScrap[stationName] = 0; stationOutput[stationName] = 0; } const output = stations[stationName]?.output || 0; stationOutput[stationName] += output; // Брак - это разница между текущей операцией и Загибкой кромок (только для других операций) if (stationName !== 'Загибка кромок' && output > zagibka) { stationScrap[stationName] += (output - zagibka); } }); } }); }); return { totalBatches, totalGood, totalScrap, totalItems, scrapPercent: totalItems ? ((totalScrap/totalItems)*100).toFixed(1) : 0, goodPercent: totalItems ? ((totalGood/totalItems)*100).toFixed(1) : 0, byGroup, ranges, stationScrap, stationOutput }; }, renderEngineerAnalytics(analytics) { const totalItems = analytics.totalGood + analytics.totalScrap; return ` <div style="margin-bottom: 24px;"> <h3 style="margin-bottom: 12px; color: #ffffff; font-size: 16px;">📊 Инженер</h3> <div style="display: flex; gap: 12px; flex-wrap: wrap; justify-content: space-between; align-items: center;"> <div style="flex: 1; min-width: 120px; background: linear-gradient(135deg, #1e293b, #334155); padding: 12px 16px; border-radius: 8px; text-align: center; border: 1px solid #475569;"> <div style="font-size: 11px; color: #ffffff; margin-bottom: 3px;">Всего партий</div> <div style="font-size: 24px; font-weight: 700; color: #ffffff; margin-bottom: 2px;">${analytics.totalBatches}</div> <div style="font-size: 11px; color: #ffffff;">партий</div> </div> <div style="flex: 1; min-width: 120px; background: linear-gradient(135deg, #10b981, #059669); padding: 12px 16px; border-radius: 8px; text-align: center;"> <div style="font-size: 11px; color: #ffffff; margin-bottom: 3px;">Хороших ячеек</div> <div style="font-size: 24px; font-weight: 700; color: #ffffff; margin-bottom: 2px;">${analytics.totalGood.toLocaleString()}</div> <div style="font-size: 11px; color: #ffffff;">${analytics.goodPercent}%</div> </div> <div style="flex: 1; min-width: 120px; background: linear-gradient(135deg, #ef4444, #dc2626); padding: 12px 16px; border-radius: 8px; text-align: center;"> <div style="font-size: 11px; color: #ffffff; margin-bottom: 3px;">Брак</div> <div style="font-size: 24px; font-weight: 700; color: #ffffff; margin-bottom: 2px;">${analytics.totalScrap.toLocaleString()}</div> <div style="font-size: 11px; color: #ffffff;">${analytics.scrapPercent}%</div> </div> </div> </div> <div> <h3 style="margin-bottom: 12px; color: #ffffff; font-size: 16px;">📈 По диапазонам</h3> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px;"> ${analytics.byGroup.map((groupTotal, index) => { const percent = totalItems > 0 ? ((groupTotal / totalItems) * 100).toFixed(1) : 0; const rangeLabel = analytics.ranges[index]; const rangeNumber = index + 1; return ` <div style="background: #1f2937; border: 1px solid #374151; padding: 10px; text-align: center; border-radius: 6px;"> <div style="font-size: 14px; color: #ffffff; font-weight: 600; margin-bottom: 3px;">${rangeLabel}</div> <div style="font-size: 9px; color: #ffffff; margin-bottom: 4px;">Д.${rangeNumber}</div> <div style="font-size: 18px; font-weight: 700; color: #ffffff; margin-bottom: 4px;">${groupTotal.toLocaleString()}</div> <div style="font-size: 12px; color: #3b82f6; font-weight: 600; background: rgba(59, 130, 246, 0.1); padding: 2px 6px; border-radius: 4px; display: inline-block;">${percent}%</div> </div> `; }).join('')} </div> </div> `; }, renderMasterAnalytics(analytics) { const keys = Object.keys(analytics.stationScrap || {}); if (!keys.length) { return `<div style='color:#fff; padding:32px; text-align:center;'>Нет данных для отображения</div>`; } return ` <div> <h3 style="margin-bottom: 12px; color: #ffffff; font-size: 16px;">🔧 Брак по операциям</h3> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px;"> ${keys.map(stationName => { const scrap = analytics.stationScrap[stationName] || 0; const output = analytics.stationOutput[stationName] || 0; const scrapPercent = output > 0 ? ((scrap / output) * 100).toFixed(1) : 0; return ` <div style="background: #1f2937; border: 1px solid #374151; padding: 10px; border-radius: 6px;"> <div style="font-size: 13px; color: #ffffff; font-weight: 600; margin-bottom: 6px;">${stationName}</div> <div style="font-size: 10px; color: #ffffff; margin-bottom: 4px;">Выработка: ${output.toLocaleString()}</div> <div style="font-size: 18px; font-weight: 700; color: #ef4444; margin-bottom: 4px;">${scrap.toLocaleString()}</div> <div style="font-size: 11px; color: #ffffff; background: rgba(239, 68, 68, 0.1); padding: 3px 6px; border-radius: 4px; display: inline-block;">${scrapPercent}%</div> </div> `; }).join('')} </div> </div> `; }, async clearAll() { if (confirm('Очистить все данные? Это действие невозможно отменить!')) { try { const batches = Object.values(this.batchCache); for (const batch of batches) { await fetch(`${API_URL}/batches/${batch.id}`, { method: 'DELETE' }); } await this.loadBatches(); this.renderAllTasks(); this.showNotification('✓ Все данные удалены'); } catch (error) { console.error('Ошибка очистки:', error); this.showNotification('Ошибка при очистке данных', 'error'); } } } }; document.addEventListener('DOMContentLoaded', () => { kanban.init(); });