$(document).ready(function () { function initQueuedUploads() { const uploadForms = new WeakSet(); const state = { modal: null, modalElement: null, queue: [], running: new Set(), completed: 0, failed: 0, totalBytes: 0, lastRedirectUrl: '', isCancelled: false, activeForm: null, }; const maxParallelUploads = 2; function formatBytes(bytes) { if (!bytes) { return '0 МБ'; } const units = ['Б', 'КБ', 'МБ', 'ГБ']; let value = bytes; let unitIndex = 0; while (value >= 1024 && unitIndex < units.length - 1) { value /= 1024; unitIndex += 1; } return value.toFixed(unitIndex === 0 ? 0 : 1) + ' ' + units[unitIndex]; } function ensureModal() { if (state.modalElement) { return state.modalElement; } const wrapper = document.createElement('div'); wrapper.innerHTML = '' + ''; state.modalElement = wrapper.firstElementChild; document.body.appendChild(state.modalElement); state.modal = bootstrap.Modal.getOrCreateInstance(state.modalElement); state.modalElement.querySelector('[data-upload-cancel]').addEventListener('click', cancelUploads); state.modalElement.querySelector('[data-upload-retry]').addEventListener('click', retryFailedUploads); state.modalElement.querySelector('[data-upload-refresh]').addEventListener('click', refreshAfterUpload); state.modalElement.querySelector('[data-upload-close]').addEventListener('click', function () { state.modal.hide(); }); return state.modalElement; } function getElements() { const modal = ensureModal(); return { status: modal.querySelector('[data-upload-status]'), bytes: modal.querySelector('[data-upload-bytes]'), progress: modal.querySelector('[data-upload-total-progress]'), counts: modal.querySelector('[data-upload-counts]'), list: modal.querySelector('[data-upload-list]'), cancel: modal.querySelector('[data-upload-cancel]'), retry: modal.querySelector('[data-upload-retry]'), refresh: modal.querySelector('[data-upload-refresh]'), close: modal.querySelector('[data-upload-close]'), }; } function getSelectedFiles(form) { return Array.from(form.querySelectorAll('input[type="file"]')) .flatMap(function (input) { return Array.from(input.files || []).map(function (file) { return { input: input, file: file }; }); }); } function buildQueue(form) { const selectedFiles = getSelectedFiles(form); return selectedFiles.map(function (entry, index) { const row = document.createElement('div'); row.className = 'list-group-item upload-queue-item'; row.innerHTML = '' + '
' + '
' + '
' + '
' + '
' + '
Ожидает
' + '
0%
' + '
' + '
' + '
' + '
'; row.querySelector('.upload-queue-name').textContent = entry.file.name; row.querySelector('.upload-queue-size').textContent = formatBytes(entry.file.size); return { id: index, form: form, input: entry.input, file: entry.file, row: row, loaded: 0, status: 'pending', xhr: null, error: '', }; }); } function updateItem(item, status, loaded, error) { item.status = status; item.loaded = typeof loaded === 'number' ? loaded : item.loaded; item.error = error || ''; const percent = item.file.size > 0 ? Math.round((item.loaded / item.file.size) * 100) : 0; const statusElement = item.row.querySelector('[data-upload-item-status]'); const percentElement = item.row.querySelector('[data-upload-item-percent]'); const progressElement = item.row.querySelector('[data-upload-item-progress]'); item.row.classList.toggle('upload-queue-item-failed', status === 'failed'); item.row.classList.toggle('upload-queue-item-success', status === 'done'); progressElement.classList.toggle('bg-danger', status === 'failed'); progressElement.classList.toggle('bg-success', status === 'done'); const statusLabels = { pending: 'Ожидает', uploading: 'Загружается', done: 'Загружено', failed: item.error || 'Ошибка загрузки', cancelled: 'Отменено', }; statusElement.textContent = statusLabels[status] || status; percentElement.textContent = Math.min(percent, 100) + '%'; progressElement.style.width = Math.min(percent, 100) + '%'; } function updateSummary() { const elements = getElements(); const uploadedBytes = state.queue.reduce(function (sum, item) { if (item.status === 'done') { return sum + item.file.size; } if (item.status === 'uploading') { return sum + item.loaded; } return sum; }, 0); const doneCount = state.queue.filter(function (item) { return item.status === 'done'; }).length; const failedCount = state.queue.filter(function (item) { return item.status === 'failed'; }).length; const pendingCount = state.queue.filter(function (item) { return item.status === 'pending'; }).length; const percent = state.totalBytes > 0 ? Math.round((uploadedBytes / state.totalBytes) * 100) : 0; elements.progress.style.width = Math.min(percent, 100) + '%'; elements.progress.textContent = Math.min(percent, 100) + '%'; elements.bytes.textContent = formatBytes(uploadedBytes) + ' / ' + formatBytes(state.totalBytes); elements.counts.textContent = doneCount + ' из ' + state.queue.length + ' файлов'; if (state.isCancelled) { elements.status.textContent = 'Загрузка отменена'; } else if (failedCount > 0 && state.running.size === 0 && pendingCount === 0) { elements.status.textContent = 'Загружено с ошибками: ' + doneCount + ' из ' + state.queue.length; } else if (doneCount === state.queue.length && state.queue.length > 0) { elements.status.textContent = 'Все файлы загружены'; } else { elements.status.textContent = 'Загрузка: ' + doneCount + ' из ' + state.queue.length; } elements.cancel.classList.toggle('d-none', state.running.size === 0 && pendingCount === 0); elements.retry.classList.toggle('d-none', failedCount === 0 || state.running.size > 0 || pendingCount > 0); elements.refresh.classList.toggle('d-none', doneCount === 0 || state.running.size > 0 || pendingCount > 0); elements.close.classList.toggle('d-none', state.running.size > 0 || pendingCount > 0); } function appendFormFields(formData, form, activeInput) { Array.from(form.elements).forEach(function (element) { if (!element.name || element.disabled || element === activeInput) { return; } if (element.type === 'file') { return; } if ((element.type === 'checkbox' || element.type === 'radio') && !element.checked) { return; } formData.append(element.name, element.value); }); } function extractError(xhr) { if (xhr.responseText) { try { const data = JSON.parse(xhr.responseText); if (data.message) { return data.message; } if (data.errors) { return Object.values(data.errors).flat().join(' '); } } catch (error) { // Response is not JSON. Fall through to generic status handling. } const htmlError = extractHtmlError(xhr.responseText); if (htmlError) { return htmlError; } } if (xhr.status === 413) { return 'Файл слишком большой'; } if (xhr.status === 419) { return 'Сессия истекла, обновите страницу'; } if (xhr.status >= 500) { return 'Ошибка сервера'; } return xhr.status ? 'Ошибка HTTP ' + xhr.status : 'Ошибка сети'; } function extractHtmlError(responseText) { if (!responseText || !responseText.includes('alert-danger')) { return ''; } try { const documentFragment = new DOMParser().parseFromString(responseText, 'text/html'); const alert = documentFragment.querySelector('.main-alert.alert-danger, .alert-danger[role="alert"]'); return alert ? alert.textContent.trim().replace(/\s+/g, ' ') : ''; } catch (error) { return ''; } } function hasHtmlUploadError(xhr) { const contentType = xhr.getResponseHeader('Content-Type') || ''; return contentType.includes('text/html') && Boolean(extractHtmlError(xhr.responseText)); } function uploadItem(item) { const formData = new FormData(); const xhr = new XMLHttpRequest(); const method = (item.form.getAttribute('method') || 'POST').toUpperCase(); const action = item.form.getAttribute('action') || window.location.href; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; appendFormFields(formData, item.form, item.input); formData.append(item.input.name, item.file, item.file.name); item.xhr = xhr; state.running.add(item); updateItem(item, 'uploading', 0); updateSummary(); xhr.open(method, action, true); xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); xhr.setRequestHeader('Accept', 'application/json, text/html;q=0.9, */*;q=0.8'); if (csrfToken) { xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken); } xhr.upload.onprogress = function (event) { if (!event.lengthComputable) { return; } updateItem(item, 'uploading', event.loaded); updateSummary(); }; xhr.onload = function () { state.running.delete(item); item.xhr = null; if (xhr.status >= 200 && xhr.status < 300 && !hasHtmlUploadError(xhr)) { state.lastRedirectUrl = xhr.responseURL || state.lastRedirectUrl; state.completed += 1; updateItem(item, 'done', item.file.size); } else { state.failed += 1; updateItem(item, 'failed', item.loaded, extractError(xhr)); } updateSummary(); runNext(); }; xhr.onerror = function () { state.running.delete(item); item.xhr = null; state.failed += 1; updateItem(item, 'failed', item.loaded, 'Ошибка сети'); updateSummary(); runNext(); }; xhr.onabort = function () { state.running.delete(item); item.xhr = null; updateItem(item, 'cancelled', item.loaded); updateSummary(); }; xhr.send(formData); } function runNext() { if (state.isCancelled) { return; } while (state.running.size < maxParallelUploads) { const nextItem = state.queue.find(function (item) { return item.status === 'pending'; }); if (!nextItem) { break; } uploadItem(nextItem); } const hasPending = state.queue.some(function (item) { return item.status === 'pending'; }); if (!hasPending && state.running.size === 0 && state.failed === 0 && state.completed > 0) { setTimeout(refreshAfterUpload, 700); } } function startUpload(form) { const hasActiveUpload = state.running.size > 0 || state.queue.some(function (item) { return item.status === 'pending' || item.status === 'uploading'; }); if (hasActiveUpload) { customAlert('Дождитесь завершения текущей загрузки файлов.'); return; } const elements = getElements(); state.queue = buildQueue(form); state.running = new Set(); state.completed = 0; state.failed = 0; state.totalBytes = state.queue.reduce(function (sum, item) { return sum + item.file.size; }, 0); state.lastRedirectUrl = ''; state.isCancelled = false; state.activeForm = form; elements.list.innerHTML = ''; state.queue.forEach(function (item) { elements.list.appendChild(item.row); updateItem(item, 'pending', 0); }); state.modal.show(); updateSummary(); runNext(); } function cancelUploads() { state.isCancelled = true; state.queue.forEach(function (item) { if (item.status === 'pending') { updateItem(item, 'cancelled', 0); } }); state.running.forEach(function (item) { if (item.xhr) { item.xhr.abort(); } }); updateSummary(); } function retryFailedUploads() { state.queue.forEach(function (item) { if (item.status === 'failed') { updateItem(item, 'pending', 0); } }); state.failed = 0; state.isCancelled = false; updateSummary(); runNext(); } function refreshAfterUpload() { const redirectUrl = state.lastRedirectUrl; if (redirectUrl && redirectUrl.startsWith(window.location.origin)) { window.location.href = redirectUrl; return; } window.location.reload(); } document.addEventListener('submit', function (event) { const form = event.target; if (!(form instanceof HTMLFormElement) || uploadForms.has(form) || form.matches('[data-chat-form]') || form.dataset.uploadNative === '1' || !form.matches('form[enctype="multipart/form-data"]') || getSelectedFiles(form).length === 0) { return; } event.preventDefault(); event.stopPropagation(); uploadForms.add(form); startUpload(form); setTimeout(function () { uploadForms.delete(form); }, 0); }, true); document.addEventListener('change', function (event) { const input = event.target; if (!(input instanceof HTMLInputElement) || input.type !== 'file' || !input.getAttribute('onchange')?.includes('submit') || !input.form || input.form.matches('[data-chat-form]') || input.form.dataset.uploadNative === '1' || !input.form.matches('form[enctype="multipart/form-data"]') || getSelectedFiles(input.form).length === 0) { return; } event.preventDefault(); event.stopImmediatePropagation(); startUpload(input.form); }, true); } function initMobileDatePicker() { const mobileQuery = window.matchMedia('(max-width: 767.98px)'); const coarsePointer = window.matchMedia('(pointer: coarse)'); if (!mobileQuery.matches || !coarsePointer.matches) { return; } const dateInputs = Array.from(document.querySelectorAll('input[type="date"]')).filter(function (input) { return !input.disabled && !input.dataset.mobileDateEnhanced; }); if (!dateInputs.length) { return; } const monthFormatter = new Intl.DateTimeFormat('ru-RU', { month: 'long', year: 'numeric', }); const displayFormatter = new Intl.DateTimeFormat('ru-RU'); const weekdayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; const overlay = document.createElement('div'); overlay.className = 'mobile-date-picker'; overlay.hidden = true; overlay.innerHTML = '' + ''; document.body.appendChild(overlay); const titleEl = overlay.querySelector('.mobile-date-picker__title'); const monthLabelEl = overlay.querySelector('.mobile-date-picker__month-label'); const weekdaysEl = overlay.querySelector('.mobile-date-picker__weekdays'); const gridEl = overlay.querySelector('.mobile-date-picker__grid'); const closeBtn = overlay.querySelector('.mobile-date-picker__close'); const todayBtn = overlay.querySelector('.mobile-date-picker__today'); const clearBtn = overlay.querySelector('.mobile-date-picker__clear'); const applyBtn = overlay.querySelector('.mobile-date-picker__apply'); const prevBtn = overlay.querySelector('.mobile-date-picker__nav--prev'); const nextBtn = overlay.querySelector('.mobile-date-picker__nav--next'); weekdaysEl.innerHTML = weekdayLabels.map(function (day) { return '
' + day + '
'; }).join(''); function parseIsoDate(value) { if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { return null; } const parts = value.split('-').map(Number); const date = new Date(parts[0], parts[1] - 1, parts[2]); if (Number.isNaN(date.getTime())) { return null; } return date; } function formatIsoDate(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return year + '-' + month + '-' + day; } function formatDisplayDate(value) { const date = parseIsoDate(value); return date ? displayFormatter.format(date) : ''; } function normalizeMonthLabel(date) { const label = monthFormatter.format(date); return label.charAt(0).toUpperCase() + label.slice(1); } function startOfMonth(date) { return new Date(date.getFullYear(), date.getMonth(), 1); } function isSameDate(a, b) { return a && b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } const state = { activeInput: null, hiddenInput: null, displayInput: null, selectedDate: null, draftDate: null, viewDate: startOfMonth(new Date()), }; function syncDisplay(hiddenInput, displayInput) { displayInput.value = formatDisplayDate(hiddenInput.value); displayInput.classList.toggle('mobile-date-input--empty', !hiddenInput.value); } function closePicker() { overlay.hidden = true; document.body.classList.remove('mobile-date-picker-open'); state.activeInput = null; state.hiddenInput = null; state.displayInput = null; state.selectedDate = null; state.draftDate = null; } function renderCalendar() { const today = new Date(); const monthStart = startOfMonth(state.viewDate); const gridStart = new Date(monthStart); const firstWeekday = (monthStart.getDay() + 6) % 7; gridStart.setDate(monthStart.getDate() - firstWeekday); monthLabelEl.textContent = normalizeMonthLabel(monthStart); gridEl.innerHTML = ''; for (let index = 0; index < 42; index += 1) { const day = new Date(gridStart); day.setDate(gridStart.getDate() + index); const button = document.createElement('button'); button.type = 'button'; button.className = 'mobile-date-picker__day'; button.textContent = String(day.getDate()); button.dataset.value = formatIsoDate(day); if (day.getMonth() !== monthStart.getMonth()) { button.classList.add('is-outside'); } if (isSameDate(day, today)) { button.classList.add('is-today'); } if (state.draftDate && isSameDate(day, state.draftDate)) { button.classList.add('is-selected'); } button.addEventListener('click', function () { state.draftDate = parseIsoDate(button.dataset.value); renderCalendar(); }); gridEl.appendChild(button); } } function openPicker(hiddenInput, displayInput) { state.activeInput = hiddenInput; state.hiddenInput = hiddenInput; state.displayInput = displayInput; state.selectedDate = parseIsoDate(hiddenInput.value); state.draftDate = state.selectedDate; state.viewDate = startOfMonth(state.selectedDate || new Date()); titleEl.textContent = hiddenInput.dataset.mobileDateTitle || 'Выбор даты'; overlay.hidden = false; document.body.classList.add('mobile-date-picker-open'); renderCalendar(); } closeBtn.addEventListener('click', closePicker); overlay.addEventListener('click', function (event) { if (event.target === overlay) { closePicker(); } }); prevBtn.addEventListener('click', function () { state.viewDate = new Date(state.viewDate.getFullYear(), state.viewDate.getMonth() - 1, 1); renderCalendar(); }); nextBtn.addEventListener('click', function () { state.viewDate = new Date(state.viewDate.getFullYear(), state.viewDate.getMonth() + 1, 1); renderCalendar(); }); todayBtn.addEventListener('click', function () { state.draftDate = startOfMonth(new Date()); state.draftDate = new Date(state.draftDate.getFullYear(), state.draftDate.getMonth(), new Date().getDate()); state.viewDate = startOfMonth(state.draftDate); renderCalendar(); }); clearBtn.addEventListener('click', function () { if (!state.hiddenInput || !state.displayInput) { return; } state.hiddenInput.value = ''; state.hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); state.hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); syncDisplay(state.hiddenInput, state.displayInput); closePicker(); }); applyBtn.addEventListener('click', function () { if (!state.hiddenInput || !state.displayInput || !state.draftDate) { closePicker(); return; } state.hiddenInput.value = formatIsoDate(state.draftDate); state.hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); state.hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); syncDisplay(state.hiddenInput, state.displayInput); closePicker(); }); document.addEventListener('keydown', function (event) { if (event.key === 'Escape' && !overlay.hidden) { closePicker(); } }); dateInputs.forEach(function (input) { input.dataset.mobileDateEnhanced = '1'; input.dataset.mobileDateTitle = input.closest('.row')?.querySelector('label[for="' + input.id + '"]')?.textContent.trim() || 'Выбор даты'; const displayInput = input.cloneNode(false); displayInput.type = 'text'; displayInput.removeAttribute('name'); displayInput.value = ''; displayInput.readOnly = true; displayInput.dataset.mobileDateDisplay = '1'; displayInput.classList.add('mobile-date-input'); displayInput.placeholder = 'Выберите дату'; input.type = 'hidden'; input.dataset.mobileDateHidden = '1'; input.parentNode.insertBefore(displayInput, input.nextSibling); syncDisplay(input, displayInput); displayInput.addEventListener('click', function () { openPicker(input, displayInput); }); }); } function cleanupStaleModalState() { if (!document.querySelector('.modal.show')) { document.querySelectorAll('.modal-backdrop').forEach(function (backdrop) { backdrop.remove(); }); document.body.classList.remove('modal-open'); document.body.style.removeProperty('padding-right'); document.body.style.removeProperty('overflow'); } } function getNotificationBadge() { return document.getElementById('notification-badge'); } function updateNotificationBadge(count) { const badge = getNotificationBadge(); if (!badge) { return; } const safeCount = Math.max(0, parseInt(count || 0, 10)); badge.dataset.count = String(safeCount); badge.classList.toggle('d-none', safeCount === 0); } function incrementNotificationBadge() { const badge = getNotificationBadge(); if (!badge) { return; } const current = parseInt(badge.dataset.count || '0', 10); updateNotificationBadge(current + 1); } function markNotificationRead(notificationId) { if (!notificationId || !window.notificationsReadUrlTemplate) { return; } fetch(window.notificationsReadUrlTemplate.replace('__id__', String(notificationId)), { method: 'POST', headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), 'Accept': 'application/json', }, keepalive: true, }) .then(function (response) { if (!response.ok) { return null; } return response.json(); }) .then(function (data) { if (data && typeof data.unread !== 'undefined') { updateNotificationBadge(data.unread); } }) .catch(function () { // noop }); } function appendWsPopup(notification) { const container = document.getElementById('ws-notification-container'); if (!container || !notification) { return; } const popup = document.createElement('div'); popup.className = 'ws-notification-popup'; popup.dataset.id = String(notification.id || ''); const safeTitle = notification.title || 'Уведомление'; const bodyHtml = notification.message_html || notification.message || ''; popup.innerHTML = '
' + '' + safeTitle + '' + '' + '
' + '
' + bodyHtml + '
'; const closeBtn = popup.querySelector('.ws-notification-close'); if (closeBtn) { closeBtn.addEventListener('click', function (event) { event.stopPropagation(); markNotificationRead(notification.id); popup.remove(); }); } container.appendChild(popup); } cleanupStaleModalState(); initQueuedUploads(); initMobileDatePicker(); window.addEventListener('pageshow', cleanupStaleModalState); if ($('.main-alert').length) { setTimeout(function () { $('.main-alert').fadeTo(2000, 500).slideUp(500, function () { $('.main-alert').slideUp(500); }); }, 3000); } const user = localStorage.getItem('user'); if (user > 0) { const socket = new WebSocket(localStorage.getItem('socketAddress')); socket.onopen = function () { console.log('[WS] Connected. Listen messages for user ' + user); }; socket.onmessage = function (event) { const received = JSON.parse(event.data); if (parseInt(received.data.user_id, 10) !== parseInt(user, 10)) { return; } const action = received.data.action; if (action === 'persistent_notification') { incrementNotificationBadge(); appendWsPopup(received.data.notification); return; } if (action !== 'message') { return; } if (received.data.payload.download) { document.location.href = '/storage/export/' + received.data.payload.download; } if (received.data.payload.link) { document.location.href = received.data.payload.link; setTimeout(function () { document.location.reload(); }, 2000); } setTimeout(function () { if (received.data.payload.error) { $('.alerts').append(''); } else { $('.alerts').append(''); } setTimeout(function () { $('.main-alert2').fadeTo(2000, 500).slideUp(500, function () { $('.main-alert2').slideUp(500); }); }, 3000); }, 1000); }; socket.onclose = function (event) { if (event.wasClean) { console.log('[WS] Closed clear, code=' + event.code + ' reason=' + event.reason); } else { console.log('[WS] Connection lost', event); } }; socket.onerror = function (error) { console.log('[error]', error); }; } });