$(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 = '' +
'' +
'' +
'';
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 =
'' +
'' + 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('' + received.data.message + '
');
} else {
$('.alerts').append('' + received.data.message + '
');
}
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);
};
}
});