custom.js 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935
  1. $(document).ready(function () {
  2. function initQueuedUploads() {
  3. const uploadForms = new WeakSet();
  4. const state = {
  5. modal: null,
  6. modalElement: null,
  7. queue: [],
  8. running: new Set(),
  9. completed: 0,
  10. failed: 0,
  11. totalBytes: 0,
  12. lastRedirectUrl: '',
  13. isCancelled: false,
  14. activeForm: null,
  15. };
  16. const maxParallelUploads = 2;
  17. function formatBytes(bytes) {
  18. if (!bytes) {
  19. return '0 МБ';
  20. }
  21. const units = ['Б', 'КБ', 'МБ', 'ГБ'];
  22. let value = bytes;
  23. let unitIndex = 0;
  24. while (value >= 1024 && unitIndex < units.length - 1) {
  25. value /= 1024;
  26. unitIndex += 1;
  27. }
  28. return value.toFixed(unitIndex === 0 ? 0 : 1) + ' ' + units[unitIndex];
  29. }
  30. function ensureModal() {
  31. if (state.modalElement) {
  32. return state.modalElement;
  33. }
  34. const wrapper = document.createElement('div');
  35. wrapper.innerHTML = '' +
  36. '<div class="modal fade upload-queue-modal" id="uploadQueueModal" tabindex="-1" aria-labelledby="uploadQueueModalLabel" data-bs-backdrop="static" data-bs-keyboard="false" aria-hidden="true">' +
  37. '<div class="modal-dialog modal-dialog-scrollable modal-lg">' +
  38. '<div class="modal-content">' +
  39. '<div class="modal-header">' +
  40. '<h1 class="modal-title fs-5" id="uploadQueueModalLabel">Загрузка файлов</h1>' +
  41. '<button type="button" class="btn-close d-none" data-upload-close aria-label="Закрыть"></button>' +
  42. '</div>' +
  43. '<div class="modal-body">' +
  44. '<div class="upload-queue-summary">' +
  45. '<div class="d-flex justify-content-between align-items-center gap-3 mb-2">' +
  46. '<div class="fw-semibold" data-upload-status>Подготовка загрузки</div>' +
  47. '<div class="text-muted small text-nowrap" data-upload-bytes>0 МБ / 0 МБ</div>' +
  48. '</div>' +
  49. '<div class="progress upload-queue-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100">' +
  50. '<div class="progress-bar" data-upload-total-progress style="width: 0%">0%</div>' +
  51. '</div>' +
  52. '<div class="small text-muted mt-2" data-upload-counts>0 из 0 файлов</div>' +
  53. '</div>' +
  54. '<div class="upload-queue-list list-group mt-3" data-upload-list></div>' +
  55. '</div>' +
  56. '<div class="modal-footer">' +
  57. '<button type="button" class="btn btn-outline-secondary" data-upload-cancel>Отмена</button>' +
  58. '<button type="button" class="btn btn-outline-primary d-none" data-upload-retry>Повторить ошибки</button>' +
  59. '<button type="button" class="btn btn-primary d-none" data-upload-refresh>Обновить страницу</button>' +
  60. '</div>' +
  61. '</div>' +
  62. '</div>' +
  63. '</div>';
  64. state.modalElement = wrapper.firstElementChild;
  65. document.body.appendChild(state.modalElement);
  66. state.modal = bootstrap.Modal.getOrCreateInstance(state.modalElement);
  67. state.modalElement.querySelector('[data-upload-cancel]').addEventListener('click', cancelUploads);
  68. state.modalElement.querySelector('[data-upload-retry]').addEventListener('click', retryFailedUploads);
  69. state.modalElement.querySelector('[data-upload-refresh]').addEventListener('click', refreshAfterUpload);
  70. state.modalElement.querySelector('[data-upload-close]').addEventListener('click', function () {
  71. state.modal.hide();
  72. });
  73. return state.modalElement;
  74. }
  75. function getElements() {
  76. const modal = ensureModal();
  77. return {
  78. status: modal.querySelector('[data-upload-status]'),
  79. bytes: modal.querySelector('[data-upload-bytes]'),
  80. progress: modal.querySelector('[data-upload-total-progress]'),
  81. counts: modal.querySelector('[data-upload-counts]'),
  82. list: modal.querySelector('[data-upload-list]'),
  83. cancel: modal.querySelector('[data-upload-cancel]'),
  84. retry: modal.querySelector('[data-upload-retry]'),
  85. refresh: modal.querySelector('[data-upload-refresh]'),
  86. close: modal.querySelector('[data-upload-close]'),
  87. };
  88. }
  89. function getSelectedFiles(form) {
  90. return Array.from(form.querySelectorAll('input[type="file"]'))
  91. .flatMap(function (input) {
  92. return Array.from(input.files || []).map(function (file) {
  93. return { input: input, file: file };
  94. });
  95. });
  96. }
  97. function buildQueue(form) {
  98. const selectedFiles = getSelectedFiles(form);
  99. return selectedFiles.map(function (entry, index) {
  100. const row = document.createElement('div');
  101. row.className = 'list-group-item upload-queue-item';
  102. row.innerHTML = '' +
  103. '<div class="d-flex justify-content-between gap-3">' +
  104. '<div class="upload-queue-name text-truncate"></div>' +
  105. '<div class="upload-queue-size text-muted small text-nowrap"></div>' +
  106. '</div>' +
  107. '<div class="d-flex justify-content-between gap-3 mt-1">' +
  108. '<div class="small" data-upload-item-status>Ожидает</div>' +
  109. '<div class="small text-muted" data-upload-item-percent>0%</div>' +
  110. '</div>' +
  111. '<div class="progress upload-queue-item-progress mt-2" role="progressbar" aria-valuemin="0" aria-valuemax="100">' +
  112. '<div class="progress-bar" data-upload-item-progress style="width: 0%"></div>' +
  113. '</div>';
  114. row.querySelector('.upload-queue-name').textContent = entry.file.name;
  115. row.querySelector('.upload-queue-size').textContent = formatBytes(entry.file.size);
  116. return {
  117. id: index,
  118. form: form,
  119. input: entry.input,
  120. file: entry.file,
  121. row: row,
  122. loaded: 0,
  123. status: 'pending',
  124. xhr: null,
  125. error: '',
  126. };
  127. });
  128. }
  129. function updateItem(item, status, loaded, error) {
  130. item.status = status;
  131. item.loaded = typeof loaded === 'number' ? loaded : item.loaded;
  132. item.error = error || '';
  133. const percent = item.file.size > 0 ? Math.round((item.loaded / item.file.size) * 100) : 0;
  134. const statusElement = item.row.querySelector('[data-upload-item-status]');
  135. const percentElement = item.row.querySelector('[data-upload-item-percent]');
  136. const progressElement = item.row.querySelector('[data-upload-item-progress]');
  137. item.row.classList.toggle('upload-queue-item-failed', status === 'failed');
  138. item.row.classList.toggle('upload-queue-item-success', status === 'done');
  139. progressElement.classList.toggle('bg-danger', status === 'failed');
  140. progressElement.classList.toggle('bg-success', status === 'done');
  141. const statusLabels = {
  142. pending: 'Ожидает',
  143. uploading: 'Загружается',
  144. done: 'Загружено',
  145. failed: item.error || 'Ошибка загрузки',
  146. cancelled: 'Отменено',
  147. };
  148. statusElement.textContent = statusLabels[status] || status;
  149. percentElement.textContent = Math.min(percent, 100) + '%';
  150. progressElement.style.width = Math.min(percent, 100) + '%';
  151. }
  152. function updateSummary() {
  153. const elements = getElements();
  154. const uploadedBytes = state.queue.reduce(function (sum, item) {
  155. if (item.status === 'done') {
  156. return sum + item.file.size;
  157. }
  158. if (item.status === 'uploading') {
  159. return sum + item.loaded;
  160. }
  161. return sum;
  162. }, 0);
  163. const doneCount = state.queue.filter(function (item) { return item.status === 'done'; }).length;
  164. const failedCount = state.queue.filter(function (item) { return item.status === 'failed'; }).length;
  165. const pendingCount = state.queue.filter(function (item) { return item.status === 'pending'; }).length;
  166. const percent = state.totalBytes > 0 ? Math.round((uploadedBytes / state.totalBytes) * 100) : 0;
  167. elements.progress.style.width = Math.min(percent, 100) + '%';
  168. elements.progress.textContent = Math.min(percent, 100) + '%';
  169. elements.bytes.textContent = formatBytes(uploadedBytes) + ' / ' + formatBytes(state.totalBytes);
  170. elements.counts.textContent = doneCount + ' из ' + state.queue.length + ' файлов';
  171. if (state.isCancelled) {
  172. elements.status.textContent = 'Загрузка отменена';
  173. } else if (failedCount > 0 && state.running.size === 0 && pendingCount === 0) {
  174. elements.status.textContent = 'Загружено с ошибками: ' + doneCount + ' из ' + state.queue.length;
  175. } else if (doneCount === state.queue.length && state.queue.length > 0) {
  176. elements.status.textContent = 'Все файлы загружены';
  177. } else {
  178. elements.status.textContent = 'Загрузка: ' + doneCount + ' из ' + state.queue.length;
  179. }
  180. elements.cancel.classList.toggle('d-none', state.running.size === 0 && pendingCount === 0);
  181. elements.retry.classList.toggle('d-none', failedCount === 0 || state.running.size > 0 || pendingCount > 0);
  182. elements.refresh.classList.toggle('d-none', doneCount === 0 || state.running.size > 0 || pendingCount > 0);
  183. elements.close.classList.toggle('d-none', state.running.size > 0 || pendingCount > 0);
  184. }
  185. function appendFormFields(formData, form, activeInput) {
  186. Array.from(form.elements).forEach(function (element) {
  187. if (!element.name || element.disabled || element === activeInput) {
  188. return;
  189. }
  190. if (element.type === 'file') {
  191. return;
  192. }
  193. if ((element.type === 'checkbox' || element.type === 'radio') && !element.checked) {
  194. return;
  195. }
  196. formData.append(element.name, element.value);
  197. });
  198. }
  199. function extractError(xhr) {
  200. if (xhr.responseText) {
  201. try {
  202. const data = JSON.parse(xhr.responseText);
  203. if (data.message) {
  204. return data.message;
  205. }
  206. if (data.errors) {
  207. return Object.values(data.errors).flat().join(' ');
  208. }
  209. } catch (error) {
  210. // Response is not JSON. Fall through to generic status handling.
  211. }
  212. const htmlError = extractHtmlError(xhr.responseText);
  213. if (htmlError) {
  214. return htmlError;
  215. }
  216. }
  217. if (xhr.status === 413) {
  218. return 'Файл слишком большой';
  219. }
  220. if (xhr.status === 419) {
  221. return 'Сессия истекла, обновите страницу';
  222. }
  223. if (xhr.status >= 500) {
  224. return 'Ошибка сервера';
  225. }
  226. return xhr.status ? 'Ошибка HTTP ' + xhr.status : 'Ошибка сети';
  227. }
  228. function extractHtmlError(responseText) {
  229. if (!responseText || !responseText.includes('alert-danger')) {
  230. return '';
  231. }
  232. try {
  233. const documentFragment = new DOMParser().parseFromString(responseText, 'text/html');
  234. const alert = documentFragment.querySelector('.main-alert.alert-danger, .alert-danger[role="alert"]');
  235. return alert ? alert.textContent.trim().replace(/\s+/g, ' ') : '';
  236. } catch (error) {
  237. return '';
  238. }
  239. }
  240. function hasHtmlUploadError(xhr) {
  241. const contentType = xhr.getResponseHeader('Content-Type') || '';
  242. return contentType.includes('text/html') && Boolean(extractHtmlError(xhr.responseText));
  243. }
  244. function uploadItem(item) {
  245. const formData = new FormData();
  246. const xhr = new XMLHttpRequest();
  247. const method = (item.form.getAttribute('method') || 'POST').toUpperCase();
  248. const action = item.form.getAttribute('action') || window.location.href;
  249. const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
  250. appendFormFields(formData, item.form, item.input);
  251. formData.append(item.input.name, item.file, item.file.name);
  252. item.xhr = xhr;
  253. state.running.add(item);
  254. updateItem(item, 'uploading', 0);
  255. updateSummary();
  256. xhr.open(method, action, true);
  257. xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
  258. xhr.setRequestHeader('Accept', 'application/json, text/html;q=0.9, */*;q=0.8');
  259. if (csrfToken) {
  260. xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken);
  261. }
  262. xhr.upload.onprogress = function (event) {
  263. if (!event.lengthComputable) {
  264. return;
  265. }
  266. updateItem(item, 'uploading', event.loaded);
  267. updateSummary();
  268. };
  269. xhr.onload = function () {
  270. state.running.delete(item);
  271. item.xhr = null;
  272. if (xhr.status >= 200 && xhr.status < 300 && !hasHtmlUploadError(xhr)) {
  273. state.lastRedirectUrl = xhr.responseURL || state.lastRedirectUrl;
  274. state.completed += 1;
  275. updateItem(item, 'done', item.file.size);
  276. } else {
  277. state.failed += 1;
  278. updateItem(item, 'failed', item.loaded, extractError(xhr));
  279. }
  280. updateSummary();
  281. runNext();
  282. };
  283. xhr.onerror = function () {
  284. state.running.delete(item);
  285. item.xhr = null;
  286. state.failed += 1;
  287. updateItem(item, 'failed', item.loaded, 'Ошибка сети');
  288. updateSummary();
  289. runNext();
  290. };
  291. xhr.onabort = function () {
  292. state.running.delete(item);
  293. item.xhr = null;
  294. updateItem(item, 'cancelled', item.loaded);
  295. updateSummary();
  296. };
  297. xhr.send(formData);
  298. }
  299. function runNext() {
  300. if (state.isCancelled) {
  301. return;
  302. }
  303. while (state.running.size < maxParallelUploads) {
  304. const nextItem = state.queue.find(function (item) {
  305. return item.status === 'pending';
  306. });
  307. if (!nextItem) {
  308. break;
  309. }
  310. uploadItem(nextItem);
  311. }
  312. const hasPending = state.queue.some(function (item) { return item.status === 'pending'; });
  313. if (!hasPending && state.running.size === 0 && state.failed === 0 && state.completed > 0) {
  314. setTimeout(refreshAfterUpload, 700);
  315. }
  316. }
  317. function startUpload(form) {
  318. const hasActiveUpload = state.running.size > 0 || state.queue.some(function (item) {
  319. return item.status === 'pending' || item.status === 'uploading';
  320. });
  321. if (hasActiveUpload) {
  322. customAlert('Дождитесь завершения текущей загрузки файлов.');
  323. return;
  324. }
  325. const elements = getElements();
  326. state.queue = buildQueue(form);
  327. state.running = new Set();
  328. state.completed = 0;
  329. state.failed = 0;
  330. state.totalBytes = state.queue.reduce(function (sum, item) {
  331. return sum + item.file.size;
  332. }, 0);
  333. state.lastRedirectUrl = '';
  334. state.isCancelled = false;
  335. state.activeForm = form;
  336. elements.list.innerHTML = '';
  337. state.queue.forEach(function (item) {
  338. elements.list.appendChild(item.row);
  339. updateItem(item, 'pending', 0);
  340. });
  341. state.modal.show();
  342. updateSummary();
  343. runNext();
  344. }
  345. function cancelUploads() {
  346. state.isCancelled = true;
  347. state.queue.forEach(function (item) {
  348. if (item.status === 'pending') {
  349. updateItem(item, 'cancelled', 0);
  350. }
  351. });
  352. state.running.forEach(function (item) {
  353. if (item.xhr) {
  354. item.xhr.abort();
  355. }
  356. });
  357. updateSummary();
  358. }
  359. function retryFailedUploads() {
  360. state.queue.forEach(function (item) {
  361. if (item.status === 'failed') {
  362. updateItem(item, 'pending', 0);
  363. }
  364. });
  365. state.failed = 0;
  366. state.isCancelled = false;
  367. updateSummary();
  368. runNext();
  369. }
  370. function refreshAfterUpload() {
  371. const redirectUrl = state.lastRedirectUrl;
  372. if (redirectUrl && redirectUrl.startsWith(window.location.origin)) {
  373. window.location.href = redirectUrl;
  374. return;
  375. }
  376. window.location.reload();
  377. }
  378. document.addEventListener('submit', function (event) {
  379. const form = event.target;
  380. if (!(form instanceof HTMLFormElement)
  381. || uploadForms.has(form)
  382. || form.matches('[data-chat-form]')
  383. || form.dataset.uploadNative === '1'
  384. || !form.matches('form[enctype="multipart/form-data"]')
  385. || getSelectedFiles(form).length === 0) {
  386. return;
  387. }
  388. event.preventDefault();
  389. event.stopPropagation();
  390. uploadForms.add(form);
  391. startUpload(form);
  392. setTimeout(function () {
  393. uploadForms.delete(form);
  394. }, 0);
  395. }, true);
  396. document.addEventListener('change', function (event) {
  397. const input = event.target;
  398. if (!(input instanceof HTMLInputElement)
  399. || input.type !== 'file'
  400. || !input.getAttribute('onchange')?.includes('submit')
  401. || !input.form
  402. || input.form.matches('[data-chat-form]')
  403. || input.form.dataset.uploadNative === '1'
  404. || !input.form.matches('form[enctype="multipart/form-data"]')
  405. || getSelectedFiles(input.form).length === 0) {
  406. return;
  407. }
  408. event.preventDefault();
  409. event.stopImmediatePropagation();
  410. startUpload(input.form);
  411. }, true);
  412. }
  413. function initMobileDatePicker() {
  414. const mobileQuery = window.matchMedia('(max-width: 767.98px)');
  415. const coarsePointer = window.matchMedia('(pointer: coarse)');
  416. if (!mobileQuery.matches || !coarsePointer.matches) {
  417. return;
  418. }
  419. const dateInputs = Array.from(document.querySelectorAll('input[type="date"]')).filter(function (input) {
  420. return !input.disabled && !input.dataset.mobileDateEnhanced;
  421. });
  422. if (!dateInputs.length) {
  423. return;
  424. }
  425. const monthFormatter = new Intl.DateTimeFormat('ru-RU', {
  426. month: 'long',
  427. year: 'numeric',
  428. });
  429. const displayFormatter = new Intl.DateTimeFormat('ru-RU');
  430. const weekdayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
  431. const overlay = document.createElement('div');
  432. overlay.className = 'mobile-date-picker';
  433. overlay.hidden = true;
  434. overlay.innerHTML = '' +
  435. '<div class="mobile-date-picker__dialog" role="dialog" aria-modal="true" aria-label="Выбор даты">' +
  436. '<div class="mobile-date-picker__header">' +
  437. '<button type="button" class="btn btn-link mobile-date-picker__close" aria-label="Закрыть"><i class="bi bi-x-lg"></i></button>' +
  438. '<div class="mobile-date-picker__title"></div>' +
  439. '<button type="button" class="btn btn-link mobile-date-picker__today">Сегодня</button>' +
  440. '</div>' +
  441. '<div class="mobile-date-picker__month-nav">' +
  442. '<button type="button" class="btn btn-link mobile-date-picker__nav mobile-date-picker__nav--prev" aria-label="Предыдущий месяц"><i class="bi bi-chevron-left"></i></button>' +
  443. '<div class="mobile-date-picker__month-label"></div>' +
  444. '<button type="button" class="btn btn-link mobile-date-picker__nav mobile-date-picker__nav--next" aria-label="Следующий месяц"><i class="bi bi-chevron-right"></i></button>' +
  445. '</div>' +
  446. '<div class="mobile-date-picker__weekdays"></div>' +
  447. '<div class="mobile-date-picker__grid"></div>' +
  448. '<div class="mobile-date-picker__footer">' +
  449. '<button type="button" class="btn btn-outline-secondary mobile-date-picker__clear">Очистить</button>' +
  450. '<button type="button" class="btn btn-primary mobile-date-picker__apply">Готово</button>' +
  451. '</div>' +
  452. '</div>';
  453. document.body.appendChild(overlay);
  454. const titleEl = overlay.querySelector('.mobile-date-picker__title');
  455. const monthLabelEl = overlay.querySelector('.mobile-date-picker__month-label');
  456. const weekdaysEl = overlay.querySelector('.mobile-date-picker__weekdays');
  457. const gridEl = overlay.querySelector('.mobile-date-picker__grid');
  458. const closeBtn = overlay.querySelector('.mobile-date-picker__close');
  459. const todayBtn = overlay.querySelector('.mobile-date-picker__today');
  460. const clearBtn = overlay.querySelector('.mobile-date-picker__clear');
  461. const applyBtn = overlay.querySelector('.mobile-date-picker__apply');
  462. const prevBtn = overlay.querySelector('.mobile-date-picker__nav--prev');
  463. const nextBtn = overlay.querySelector('.mobile-date-picker__nav--next');
  464. weekdaysEl.innerHTML = weekdayLabels.map(function (day) {
  465. return '<div class="mobile-date-picker__weekday">' + day + '</div>';
  466. }).join('');
  467. function parseIsoDate(value) {
  468. if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
  469. return null;
  470. }
  471. const parts = value.split('-').map(Number);
  472. const date = new Date(parts[0], parts[1] - 1, parts[2]);
  473. if (Number.isNaN(date.getTime())) {
  474. return null;
  475. }
  476. return date;
  477. }
  478. function formatIsoDate(date) {
  479. const year = date.getFullYear();
  480. const month = String(date.getMonth() + 1).padStart(2, '0');
  481. const day = String(date.getDate()).padStart(2, '0');
  482. return year + '-' + month + '-' + day;
  483. }
  484. function formatDisplayDate(value) {
  485. const date = parseIsoDate(value);
  486. return date ? displayFormatter.format(date) : '';
  487. }
  488. function normalizeMonthLabel(date) {
  489. const label = monthFormatter.format(date);
  490. return label.charAt(0).toUpperCase() + label.slice(1);
  491. }
  492. function startOfMonth(date) {
  493. return new Date(date.getFullYear(), date.getMonth(), 1);
  494. }
  495. function isSameDate(a, b) {
  496. return a && b
  497. && a.getFullYear() === b.getFullYear()
  498. && a.getMonth() === b.getMonth()
  499. && a.getDate() === b.getDate();
  500. }
  501. const state = {
  502. activeInput: null,
  503. hiddenInput: null,
  504. displayInput: null,
  505. selectedDate: null,
  506. draftDate: null,
  507. viewDate: startOfMonth(new Date()),
  508. };
  509. function syncDisplay(hiddenInput, displayInput) {
  510. displayInput.value = formatDisplayDate(hiddenInput.value);
  511. displayInput.classList.toggle('mobile-date-input--empty', !hiddenInput.value);
  512. }
  513. function closePicker() {
  514. overlay.hidden = true;
  515. document.body.classList.remove('mobile-date-picker-open');
  516. state.activeInput = null;
  517. state.hiddenInput = null;
  518. state.displayInput = null;
  519. state.selectedDate = null;
  520. state.draftDate = null;
  521. }
  522. function renderCalendar() {
  523. const today = new Date();
  524. const monthStart = startOfMonth(state.viewDate);
  525. const gridStart = new Date(monthStart);
  526. const firstWeekday = (monthStart.getDay() + 6) % 7;
  527. gridStart.setDate(monthStart.getDate() - firstWeekday);
  528. monthLabelEl.textContent = normalizeMonthLabel(monthStart);
  529. gridEl.innerHTML = '';
  530. for (let index = 0; index < 42; index += 1) {
  531. const day = new Date(gridStart);
  532. day.setDate(gridStart.getDate() + index);
  533. const button = document.createElement('button');
  534. button.type = 'button';
  535. button.className = 'mobile-date-picker__day';
  536. button.textContent = String(day.getDate());
  537. button.dataset.value = formatIsoDate(day);
  538. if (day.getMonth() !== monthStart.getMonth()) {
  539. button.classList.add('is-outside');
  540. }
  541. if (isSameDate(day, today)) {
  542. button.classList.add('is-today');
  543. }
  544. if (state.draftDate && isSameDate(day, state.draftDate)) {
  545. button.classList.add('is-selected');
  546. }
  547. button.addEventListener('click', function () {
  548. state.draftDate = parseIsoDate(button.dataset.value);
  549. renderCalendar();
  550. });
  551. gridEl.appendChild(button);
  552. }
  553. }
  554. function openPicker(hiddenInput, displayInput) {
  555. state.activeInput = hiddenInput;
  556. state.hiddenInput = hiddenInput;
  557. state.displayInput = displayInput;
  558. state.selectedDate = parseIsoDate(hiddenInput.value);
  559. state.draftDate = state.selectedDate;
  560. state.viewDate = startOfMonth(state.selectedDate || new Date());
  561. titleEl.textContent = hiddenInput.dataset.mobileDateTitle || 'Выбор даты';
  562. overlay.hidden = false;
  563. document.body.classList.add('mobile-date-picker-open');
  564. renderCalendar();
  565. }
  566. closeBtn.addEventListener('click', closePicker);
  567. overlay.addEventListener('click', function (event) {
  568. if (event.target === overlay) {
  569. closePicker();
  570. }
  571. });
  572. prevBtn.addEventListener('click', function () {
  573. state.viewDate = new Date(state.viewDate.getFullYear(), state.viewDate.getMonth() - 1, 1);
  574. renderCalendar();
  575. });
  576. nextBtn.addEventListener('click', function () {
  577. state.viewDate = new Date(state.viewDate.getFullYear(), state.viewDate.getMonth() + 1, 1);
  578. renderCalendar();
  579. });
  580. todayBtn.addEventListener('click', function () {
  581. state.draftDate = startOfMonth(new Date());
  582. state.draftDate = new Date(state.draftDate.getFullYear(), state.draftDate.getMonth(), new Date().getDate());
  583. state.viewDate = startOfMonth(state.draftDate);
  584. renderCalendar();
  585. });
  586. clearBtn.addEventListener('click', function () {
  587. if (!state.hiddenInput || !state.displayInput) {
  588. return;
  589. }
  590. state.hiddenInput.value = '';
  591. state.hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
  592. state.hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
  593. syncDisplay(state.hiddenInput, state.displayInput);
  594. closePicker();
  595. });
  596. applyBtn.addEventListener('click', function () {
  597. if (!state.hiddenInput || !state.displayInput || !state.draftDate) {
  598. closePicker();
  599. return;
  600. }
  601. state.hiddenInput.value = formatIsoDate(state.draftDate);
  602. state.hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
  603. state.hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
  604. syncDisplay(state.hiddenInput, state.displayInput);
  605. closePicker();
  606. });
  607. document.addEventListener('keydown', function (event) {
  608. if (event.key === 'Escape' && !overlay.hidden) {
  609. closePicker();
  610. }
  611. });
  612. dateInputs.forEach(function (input) {
  613. input.dataset.mobileDateEnhanced = '1';
  614. input.dataset.mobileDateTitle = input.closest('.row')?.querySelector('label[for="' + input.id + '"]')?.textContent.trim() || 'Выбор даты';
  615. const displayInput = input.cloneNode(false);
  616. displayInput.type = 'text';
  617. displayInput.removeAttribute('name');
  618. displayInput.value = '';
  619. displayInput.readOnly = true;
  620. displayInput.dataset.mobileDateDisplay = '1';
  621. displayInput.classList.add('mobile-date-input');
  622. displayInput.placeholder = 'Выберите дату';
  623. input.type = 'hidden';
  624. input.dataset.mobileDateHidden = '1';
  625. input.parentNode.insertBefore(displayInput, input.nextSibling);
  626. syncDisplay(input, displayInput);
  627. displayInput.addEventListener('click', function () {
  628. openPicker(input, displayInput);
  629. });
  630. });
  631. }
  632. function cleanupStaleModalState() {
  633. if (!document.querySelector('.modal.show')) {
  634. document.querySelectorAll('.modal-backdrop').forEach(function (backdrop) {
  635. backdrop.remove();
  636. });
  637. document.body.classList.remove('modal-open');
  638. document.body.style.removeProperty('padding-right');
  639. document.body.style.removeProperty('overflow');
  640. }
  641. }
  642. function getNotificationBadge() {
  643. return document.getElementById('notification-badge');
  644. }
  645. function updateNotificationBadge(count) {
  646. const badge = getNotificationBadge();
  647. if (!badge) {
  648. return;
  649. }
  650. const safeCount = Math.max(0, parseInt(count || 0, 10));
  651. badge.dataset.count = String(safeCount);
  652. badge.classList.toggle('d-none', safeCount === 0);
  653. }
  654. function incrementNotificationBadge() {
  655. const badge = getNotificationBadge();
  656. if (!badge) {
  657. return;
  658. }
  659. const current = parseInt(badge.dataset.count || '0', 10);
  660. updateNotificationBadge(current + 1);
  661. }
  662. function markNotificationRead(notificationId) {
  663. if (!notificationId || !window.notificationsReadUrlTemplate) {
  664. return;
  665. }
  666. fetch(window.notificationsReadUrlTemplate.replace('__id__', String(notificationId)), {
  667. method: 'POST',
  668. headers: {
  669. 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
  670. 'Accept': 'application/json',
  671. },
  672. keepalive: true,
  673. })
  674. .then(function (response) {
  675. if (!response.ok) {
  676. return null;
  677. }
  678. return response.json();
  679. })
  680. .then(function (data) {
  681. if (data && typeof data.unread !== 'undefined') {
  682. updateNotificationBadge(data.unread);
  683. }
  684. })
  685. .catch(function () {
  686. // noop
  687. });
  688. }
  689. function appendWsPopup(notification) {
  690. const container = document.getElementById('ws-notification-container');
  691. if (!container || !notification) {
  692. return;
  693. }
  694. const popup = document.createElement('div');
  695. popup.className = 'ws-notification-popup';
  696. popup.dataset.id = String(notification.id || '');
  697. const safeTitle = notification.title || 'Уведомление';
  698. const bodyHtml = notification.message_html || notification.message || '';
  699. popup.innerHTML =
  700. '<div class="ws-notification-header">' +
  701. '<strong>' + safeTitle + '</strong>' +
  702. '<button type="button" class="btn-close btn-sm ws-notification-close" aria-label="Закрыть"></button>' +
  703. '</div>' +
  704. '<div class="ws-notification-body">' + bodyHtml + '</div>';
  705. const closeBtn = popup.querySelector('.ws-notification-close');
  706. if (closeBtn) {
  707. closeBtn.addEventListener('click', function (event) {
  708. event.stopPropagation();
  709. markNotificationRead(notification.id);
  710. popup.remove();
  711. });
  712. }
  713. container.appendChild(popup);
  714. }
  715. cleanupStaleModalState();
  716. initQueuedUploads();
  717. initMobileDatePicker();
  718. window.addEventListener('pageshow', cleanupStaleModalState);
  719. if ($('.main-alert').length) {
  720. setTimeout(function () {
  721. $('.main-alert').fadeTo(2000, 500).slideUp(500, function () {
  722. $('.main-alert').slideUp(500);
  723. });
  724. }, 3000);
  725. }
  726. const user = localStorage.getItem('user');
  727. if (user > 0) {
  728. const socket = new WebSocket(localStorage.getItem('socketAddress'));
  729. socket.onopen = function () {
  730. console.log('[WS] Connected. Listen messages for user ' + user);
  731. };
  732. socket.onmessage = function (event) {
  733. const received = JSON.parse(event.data);
  734. if (parseInt(received.data.user_id, 10) !== parseInt(user, 10)) {
  735. return;
  736. }
  737. const action = received.data.action;
  738. if (action === 'persistent_notification') {
  739. incrementNotificationBadge();
  740. appendWsPopup(received.data.notification);
  741. return;
  742. }
  743. if (action !== 'message') {
  744. return;
  745. }
  746. if (received.data.payload.download) {
  747. document.location.href = '/storage/export/' + received.data.payload.download;
  748. }
  749. if (received.data.payload.link) {
  750. document.location.href = received.data.payload.link;
  751. setTimeout(function () {
  752. document.location.reload();
  753. }, 2000);
  754. }
  755. setTimeout(function () {
  756. if (received.data.payload.error) {
  757. $('.alerts').append('<div class="main-alert2 alert alert-danger" role="alert">' + received.data.message + '</div>');
  758. } else {
  759. $('.alerts').append('<div class="main-alert2 alert alert-success" role="alert">' + received.data.message + '</div>');
  760. }
  761. setTimeout(function () {
  762. $('.main-alert2').fadeTo(2000, 500).slideUp(500, function () {
  763. $('.main-alert2').slideUp(500);
  764. });
  765. }, 3000);
  766. }, 1000);
  767. };
  768. socket.onclose = function (event) {
  769. if (event.wasClean) {
  770. console.log('[WS] Closed clear, code=' + event.code + ' reason=' + event.reason);
  771. } else {
  772. console.log('[WS] Connection lost', event);
  773. }
  774. };
  775. socket.onerror = function (error) {
  776. console.log('[error]', error);
  777. };
  778. }
  779. });