custom.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. $(document).ready(function () {
  2. function initMobileDatePicker() {
  3. const mobileQuery = window.matchMedia('(max-width: 767.98px)');
  4. const coarsePointer = window.matchMedia('(pointer: coarse)');
  5. if (!mobileQuery.matches || !coarsePointer.matches) {
  6. return;
  7. }
  8. const dateInputs = Array.from(document.querySelectorAll('input[type="date"]')).filter(function (input) {
  9. return !input.disabled && !input.dataset.mobileDateEnhanced;
  10. });
  11. if (!dateInputs.length) {
  12. return;
  13. }
  14. const monthFormatter = new Intl.DateTimeFormat('ru-RU', {
  15. month: 'long',
  16. year: 'numeric',
  17. });
  18. const displayFormatter = new Intl.DateTimeFormat('ru-RU');
  19. const weekdayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
  20. const overlay = document.createElement('div');
  21. overlay.className = 'mobile-date-picker';
  22. overlay.hidden = true;
  23. overlay.innerHTML = '' +
  24. '<div class="mobile-date-picker__dialog" role="dialog" aria-modal="true" aria-label="Выбор даты">' +
  25. '<div class="mobile-date-picker__header">' +
  26. '<button type="button" class="btn btn-link mobile-date-picker__close" aria-label="Закрыть"><i class="bi bi-x-lg"></i></button>' +
  27. '<div class="mobile-date-picker__title"></div>' +
  28. '<button type="button" class="btn btn-link mobile-date-picker__today">Сегодня</button>' +
  29. '</div>' +
  30. '<div class="mobile-date-picker__month-nav">' +
  31. '<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>' +
  32. '<div class="mobile-date-picker__month-label"></div>' +
  33. '<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>' +
  34. '</div>' +
  35. '<div class="mobile-date-picker__weekdays"></div>' +
  36. '<div class="mobile-date-picker__grid"></div>' +
  37. '<div class="mobile-date-picker__footer">' +
  38. '<button type="button" class="btn btn-outline-secondary mobile-date-picker__clear">Очистить</button>' +
  39. '<button type="button" class="btn btn-primary mobile-date-picker__apply">Готово</button>' +
  40. '</div>' +
  41. '</div>';
  42. document.body.appendChild(overlay);
  43. const titleEl = overlay.querySelector('.mobile-date-picker__title');
  44. const monthLabelEl = overlay.querySelector('.mobile-date-picker__month-label');
  45. const weekdaysEl = overlay.querySelector('.mobile-date-picker__weekdays');
  46. const gridEl = overlay.querySelector('.mobile-date-picker__grid');
  47. const closeBtn = overlay.querySelector('.mobile-date-picker__close');
  48. const todayBtn = overlay.querySelector('.mobile-date-picker__today');
  49. const clearBtn = overlay.querySelector('.mobile-date-picker__clear');
  50. const applyBtn = overlay.querySelector('.mobile-date-picker__apply');
  51. const prevBtn = overlay.querySelector('.mobile-date-picker__nav--prev');
  52. const nextBtn = overlay.querySelector('.mobile-date-picker__nav--next');
  53. weekdaysEl.innerHTML = weekdayLabels.map(function (day) {
  54. return '<div class="mobile-date-picker__weekday">' + day + '</div>';
  55. }).join('');
  56. function parseIsoDate(value) {
  57. if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
  58. return null;
  59. }
  60. const parts = value.split('-').map(Number);
  61. const date = new Date(parts[0], parts[1] - 1, parts[2]);
  62. if (Number.isNaN(date.getTime())) {
  63. return null;
  64. }
  65. return date;
  66. }
  67. function formatIsoDate(date) {
  68. const year = date.getFullYear();
  69. const month = String(date.getMonth() + 1).padStart(2, '0');
  70. const day = String(date.getDate()).padStart(2, '0');
  71. return year + '-' + month + '-' + day;
  72. }
  73. function formatDisplayDate(value) {
  74. const date = parseIsoDate(value);
  75. return date ? displayFormatter.format(date) : '';
  76. }
  77. function normalizeMonthLabel(date) {
  78. const label = monthFormatter.format(date);
  79. return label.charAt(0).toUpperCase() + label.slice(1);
  80. }
  81. function startOfMonth(date) {
  82. return new Date(date.getFullYear(), date.getMonth(), 1);
  83. }
  84. function isSameDate(a, b) {
  85. return a && b
  86. && a.getFullYear() === b.getFullYear()
  87. && a.getMonth() === b.getMonth()
  88. && a.getDate() === b.getDate();
  89. }
  90. const state = {
  91. activeInput: null,
  92. hiddenInput: null,
  93. displayInput: null,
  94. selectedDate: null,
  95. draftDate: null,
  96. viewDate: startOfMonth(new Date()),
  97. };
  98. function syncDisplay(hiddenInput, displayInput) {
  99. displayInput.value = formatDisplayDate(hiddenInput.value);
  100. displayInput.classList.toggle('mobile-date-input--empty', !hiddenInput.value);
  101. }
  102. function closePicker() {
  103. overlay.hidden = true;
  104. document.body.classList.remove('mobile-date-picker-open');
  105. state.activeInput = null;
  106. state.hiddenInput = null;
  107. state.displayInput = null;
  108. state.selectedDate = null;
  109. state.draftDate = null;
  110. }
  111. function renderCalendar() {
  112. const today = new Date();
  113. const monthStart = startOfMonth(state.viewDate);
  114. const gridStart = new Date(monthStart);
  115. const firstWeekday = (monthStart.getDay() + 6) % 7;
  116. gridStart.setDate(monthStart.getDate() - firstWeekday);
  117. monthLabelEl.textContent = normalizeMonthLabel(monthStart);
  118. gridEl.innerHTML = '';
  119. for (let index = 0; index < 42; index += 1) {
  120. const day = new Date(gridStart);
  121. day.setDate(gridStart.getDate() + index);
  122. const button = document.createElement('button');
  123. button.type = 'button';
  124. button.className = 'mobile-date-picker__day';
  125. button.textContent = String(day.getDate());
  126. button.dataset.value = formatIsoDate(day);
  127. if (day.getMonth() !== monthStart.getMonth()) {
  128. button.classList.add('is-outside');
  129. }
  130. if (isSameDate(day, today)) {
  131. button.classList.add('is-today');
  132. }
  133. if (state.draftDate && isSameDate(day, state.draftDate)) {
  134. button.classList.add('is-selected');
  135. }
  136. button.addEventListener('click', function () {
  137. state.draftDate = parseIsoDate(button.dataset.value);
  138. renderCalendar();
  139. });
  140. gridEl.appendChild(button);
  141. }
  142. }
  143. function openPicker(hiddenInput, displayInput) {
  144. state.activeInput = hiddenInput;
  145. state.hiddenInput = hiddenInput;
  146. state.displayInput = displayInput;
  147. state.selectedDate = parseIsoDate(hiddenInput.value);
  148. state.draftDate = state.selectedDate;
  149. state.viewDate = startOfMonth(state.selectedDate || new Date());
  150. titleEl.textContent = hiddenInput.dataset.mobileDateTitle || 'Выбор даты';
  151. overlay.hidden = false;
  152. document.body.classList.add('mobile-date-picker-open');
  153. renderCalendar();
  154. }
  155. closeBtn.addEventListener('click', closePicker);
  156. overlay.addEventListener('click', function (event) {
  157. if (event.target === overlay) {
  158. closePicker();
  159. }
  160. });
  161. prevBtn.addEventListener('click', function () {
  162. state.viewDate = new Date(state.viewDate.getFullYear(), state.viewDate.getMonth() - 1, 1);
  163. renderCalendar();
  164. });
  165. nextBtn.addEventListener('click', function () {
  166. state.viewDate = new Date(state.viewDate.getFullYear(), state.viewDate.getMonth() + 1, 1);
  167. renderCalendar();
  168. });
  169. todayBtn.addEventListener('click', function () {
  170. state.draftDate = startOfMonth(new Date());
  171. state.draftDate = new Date(state.draftDate.getFullYear(), state.draftDate.getMonth(), new Date().getDate());
  172. state.viewDate = startOfMonth(state.draftDate);
  173. renderCalendar();
  174. });
  175. clearBtn.addEventListener('click', function () {
  176. if (!state.hiddenInput || !state.displayInput) {
  177. return;
  178. }
  179. state.hiddenInput.value = '';
  180. state.hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
  181. state.hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
  182. syncDisplay(state.hiddenInput, state.displayInput);
  183. closePicker();
  184. });
  185. applyBtn.addEventListener('click', function () {
  186. if (!state.hiddenInput || !state.displayInput || !state.draftDate) {
  187. closePicker();
  188. return;
  189. }
  190. state.hiddenInput.value = formatIsoDate(state.draftDate);
  191. state.hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
  192. state.hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
  193. syncDisplay(state.hiddenInput, state.displayInput);
  194. closePicker();
  195. });
  196. document.addEventListener('keydown', function (event) {
  197. if (event.key === 'Escape' && !overlay.hidden) {
  198. closePicker();
  199. }
  200. });
  201. dateInputs.forEach(function (input) {
  202. input.dataset.mobileDateEnhanced = '1';
  203. input.dataset.mobileDateTitle = input.closest('.row')?.querySelector('label[for="' + input.id + '"]')?.textContent.trim() || 'Выбор даты';
  204. const displayInput = input.cloneNode(false);
  205. displayInput.type = 'text';
  206. displayInput.removeAttribute('name');
  207. displayInput.value = '';
  208. displayInput.readOnly = true;
  209. displayInput.dataset.mobileDateDisplay = '1';
  210. displayInput.classList.add('mobile-date-input');
  211. displayInput.placeholder = 'Выберите дату';
  212. input.type = 'hidden';
  213. input.dataset.mobileDateHidden = '1';
  214. input.parentNode.insertBefore(displayInput, input.nextSibling);
  215. syncDisplay(input, displayInput);
  216. displayInput.addEventListener('click', function () {
  217. openPicker(input, displayInput);
  218. });
  219. });
  220. }
  221. function cleanupStaleModalState() {
  222. if (!document.querySelector('.modal.show')) {
  223. document.querySelectorAll('.modal-backdrop').forEach(function (backdrop) {
  224. backdrop.remove();
  225. });
  226. document.body.classList.remove('modal-open');
  227. document.body.style.removeProperty('padding-right');
  228. document.body.style.removeProperty('overflow');
  229. }
  230. }
  231. function getNotificationBadge() {
  232. return document.getElementById('notification-badge');
  233. }
  234. function updateNotificationBadge(count) {
  235. const badge = getNotificationBadge();
  236. if (!badge) {
  237. return;
  238. }
  239. const safeCount = Math.max(0, parseInt(count || 0, 10));
  240. badge.dataset.count = String(safeCount);
  241. badge.classList.toggle('d-none', safeCount === 0);
  242. }
  243. function incrementNotificationBadge() {
  244. const badge = getNotificationBadge();
  245. if (!badge) {
  246. return;
  247. }
  248. const current = parseInt(badge.dataset.count || '0', 10);
  249. updateNotificationBadge(current + 1);
  250. }
  251. function markNotificationRead(notificationId) {
  252. if (!notificationId || !window.notificationsReadUrlTemplate) {
  253. return;
  254. }
  255. fetch(window.notificationsReadUrlTemplate.replace('__id__', String(notificationId)), {
  256. method: 'POST',
  257. headers: {
  258. 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
  259. 'Accept': 'application/json',
  260. },
  261. keepalive: true,
  262. })
  263. .then(function (response) {
  264. if (!response.ok) {
  265. return null;
  266. }
  267. return response.json();
  268. })
  269. .then(function (data) {
  270. if (data && typeof data.unread !== 'undefined') {
  271. updateNotificationBadge(data.unread);
  272. }
  273. })
  274. .catch(function () {
  275. // noop
  276. });
  277. }
  278. function appendWsPopup(notification) {
  279. const container = document.getElementById('ws-notification-container');
  280. if (!container || !notification) {
  281. return;
  282. }
  283. const popup = document.createElement('div');
  284. popup.className = 'ws-notification-popup';
  285. popup.dataset.id = String(notification.id || '');
  286. const safeTitle = notification.title || 'Уведомление';
  287. const bodyHtml = notification.message_html || notification.message || '';
  288. popup.innerHTML =
  289. '<div class="ws-notification-header">' +
  290. '<strong>' + safeTitle + '</strong>' +
  291. '<button type="button" class="btn-close btn-sm ws-notification-close" aria-label="Закрыть"></button>' +
  292. '</div>' +
  293. '<div class="ws-notification-body">' + bodyHtml + '</div>';
  294. const closeBtn = popup.querySelector('.ws-notification-close');
  295. if (closeBtn) {
  296. closeBtn.addEventListener('click', function (event) {
  297. event.stopPropagation();
  298. markNotificationRead(notification.id);
  299. popup.remove();
  300. });
  301. }
  302. container.appendChild(popup);
  303. }
  304. cleanupStaleModalState();
  305. initMobileDatePicker();
  306. window.addEventListener('pageshow', cleanupStaleModalState);
  307. if ($('.main-alert').length) {
  308. setTimeout(function () {
  309. $('.main-alert').fadeTo(2000, 500).slideUp(500, function () {
  310. $('.main-alert').slideUp(500);
  311. });
  312. }, 3000);
  313. }
  314. const user = localStorage.getItem('user');
  315. if (user > 0) {
  316. const socket = new WebSocket(localStorage.getItem('socketAddress'));
  317. socket.onopen = function () {
  318. console.log('[WS] Connected. Listen messages for user ' + user);
  319. };
  320. socket.onmessage = function (event) {
  321. const received = JSON.parse(event.data);
  322. if (parseInt(received.data.user_id, 10) !== parseInt(user, 10)) {
  323. return;
  324. }
  325. const action = received.data.action;
  326. if (action === 'persistent_notification') {
  327. incrementNotificationBadge();
  328. appendWsPopup(received.data.notification);
  329. return;
  330. }
  331. if (action !== 'message') {
  332. return;
  333. }
  334. if (received.data.payload.download) {
  335. document.location.href = '/storage/export/' + received.data.payload.download;
  336. }
  337. if (received.data.payload.link) {
  338. document.location.href = received.data.payload.link;
  339. setTimeout(function () {
  340. document.location.reload();
  341. }, 2000);
  342. }
  343. setTimeout(function () {
  344. if (received.data.payload.error) {
  345. $('.alerts').append('<div class="main-alert2 alert alert-danger" role="alert">' + received.data.message + '</div>');
  346. } else {
  347. $('.alerts').append('<div class="main-alert2 alert alert-success" role="alert">' + received.data.message + '</div>');
  348. }
  349. setTimeout(function () {
  350. $('.main-alert2').fadeTo(2000, 500).slideUp(500, function () {
  351. $('.main-alert2').slideUp(500);
  352. });
  353. }, 3000);
  354. }, 1000);
  355. };
  356. socket.onclose = function (event) {
  357. if (event.wasClean) {
  358. console.log('[WS] Closed clear, code=' + event.code + ' reason=' + event.reason);
  359. } else {
  360. console.log('[WS] Connection lost', event);
  361. }
  362. };
  363. socket.onerror = function (error) {
  364. console.log('[error]', error);
  365. };
  366. }
  367. });