chat.blade.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. @php
  2. /** @var \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Collection $messages */
  3. $messages = $messages ?? collect();
  4. $users = $users ?? collect();
  5. $responsibleUserIds = array_map('intval', $responsibleUserIds ?? []);
  6. $contextKey = $contextKey ?? 'chat';
  7. $title = $title ?? 'Чат';
  8. $submitLabel = $submitLabel ?? 'Отправить';
  9. $canSendNotifications = hasRole('admin,manager');
  10. $notificationValue = old('notification_type', \App\Models\ChatMessage::NOTIFICATION_NONE);
  11. $selectedTargetUserIds = collect(old('target_user_ids', []))
  12. ->map(static fn ($id) => (int) $id)
  13. ->filter()
  14. ->unique()
  15. ->values()
  16. ->all();
  17. @endphp
  18. @once
  19. <style>
  20. .chat-block {
  21. --chat-height: 420px;
  22. }
  23. .chat-card {
  24. border: 1px solid var(--bs-border-color);
  25. border-radius: 0.75rem;
  26. background: var(--bs-light-bg-subtle, #f8f9fa);
  27. }
  28. .chat-messages-wrap {
  29. position: relative;
  30. padding: 0.75rem;
  31. }
  32. .chat-messages {
  33. max-height: var(--chat-height);
  34. overflow-y: auto;
  35. padding-right: 0.35rem;
  36. scroll-behavior: smooth;
  37. }
  38. .chat-message {
  39. border: 1px solid rgba(0, 0, 0, 0.08);
  40. border-radius: 0.75rem;
  41. background: #fff;
  42. padding: 0.75rem 0.875rem;
  43. margin-bottom: 0.625rem;
  44. box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
  45. }
  46. .chat-message:last-child {
  47. margin-bottom: 0;
  48. }
  49. .chat-message-header {
  50. display: flex;
  51. justify-content: space-between;
  52. gap: 0.75rem;
  53. flex-wrap: wrap;
  54. font-size: 0.875rem;
  55. }
  56. .chat-message-text {
  57. margin-top: 0.5rem;
  58. white-space: pre-wrap;
  59. line-height: 1.35;
  60. }
  61. .chat-message-files {
  62. margin-top: 0.625rem;
  63. display: flex;
  64. flex-wrap: wrap;
  65. gap: 0.5rem;
  66. }
  67. .chat-message-files img {
  68. width: 84px;
  69. height: 84px;
  70. object-fit: cover;
  71. }
  72. .chat-scroll-bottom {
  73. position: absolute;
  74. right: 1rem;
  75. bottom: 1rem;
  76. z-index: 2;
  77. border-radius: 999px;
  78. box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.18);
  79. }
  80. .chat-recipient-list {
  81. max-height: 320px;
  82. overflow-y: auto;
  83. border: 1px solid var(--bs-border-color);
  84. border-radius: 0.5rem;
  85. padding: 0.5rem 0.75rem;
  86. }
  87. .chat-recipient-item[hidden] {
  88. display: none !important;
  89. }
  90. .chat-recipient-summary {
  91. min-height: 1.25rem;
  92. }
  93. </style>
  94. @endonce
  95. <div class="chat-block mt-3" data-chat-block data-context-key="{{ $contextKey }}">
  96. <hr>
  97. <h5>{{ $title }}</h5>
  98. <div class="chat-card">
  99. <div class="chat-messages-wrap">
  100. <div class="chat-messages" data-chat-messages>
  101. @forelse($messages as $message)
  102. <div class="chat-message">
  103. <div class="chat-message-header">
  104. <div>
  105. <strong>{{ $message->user?->name ?? 'Пользователь' }}</strong>
  106. @if($message->notification_type === \App\Models\ChatMessage::NOTIFICATION_USER && $message->targetUser)
  107. <span class="text-muted">для {{ $message->targetUser->name }}</span>
  108. @elseif($message->notification_type === \App\Models\ChatMessage::NOTIFICATION_RESPONSIBLES)
  109. <span class="badge text-bg-light border">Уведомления: админы/менеджер/бригадир</span>
  110. @elseif($message->notification_type === \App\Models\ChatMessage::NOTIFICATION_ALL)
  111. <span class="badge text-bg-light border">Уведомления: выбранные получатели</span>
  112. @endif
  113. </div>
  114. <small class="text-muted">{{ $message->created_at?->format('d.m.Y H:i') }}</small>
  115. </div>
  116. @if(!empty($message->message))
  117. <div class="chat-message-text">{{ $message->message }}</div>
  118. @endif
  119. @if($message->files->isNotEmpty())
  120. <div class="chat-message-files">
  121. @foreach($message->files as $file)
  122. @if(\Illuminate\Support\Str::startsWith((string) $file->mime_type, 'image/'))
  123. <a href="{{ $file->link }}" target="_blank" data-toggle="lightbox" data-gallery="chat-{{ $contextKey }}" data-size="fullscreen">
  124. <img src="{{ $file->link }}" alt="{{ $file->original_name }}" class="img-thumbnail">
  125. </a>
  126. @else
  127. <a href="{{ $file->link }}" target="_blank" class="btn btn-sm btn-outline-secondary">
  128. <i class="bi bi-paperclip"></i> {{ $file->original_name }}
  129. </a>
  130. @endif
  131. @endforeach
  132. </div>
  133. @endif
  134. </div>
  135. @empty
  136. <div class="text-muted px-1">Сообщений пока нет.</div>
  137. @endforelse
  138. </div>
  139. <button type="button" class="btn btn-primary btn-sm chat-scroll-bottom d-none" data-chat-scroll-bottom>
  140. <i class="bi bi-arrow-down"></i>
  141. </button>
  142. </div>
  143. </div>
  144. <form action="{{ $action }}" method="post" enctype="multipart/form-data" class="mt-3" data-chat-form>
  145. @csrf
  146. <div class="row g-2 align-items-start">
  147. <div class="col-12 col-lg-5">
  148. <label class="form-label" for="chat-message-{{ $contextKey }}">Сообщение</label>
  149. <textarea
  150. class="form-control"
  151. id="chat-message-{{ $contextKey }}"
  152. name="message"
  153. rows="3"
  154. placeholder="Введите сообщение"
  155. >{{ old('message') }}</textarea>
  156. </div>
  157. <div class="col-12 col-md-4 col-lg-3">
  158. <label class="form-label" for="chat-notification-{{ $contextKey }}">Уведомление</label>
  159. <select
  160. class="form-select chat-notification-type"
  161. id="chat-notification-{{ $contextKey }}"
  162. name="notification_type"
  163. data-chat-context-key="{{ $contextKey }}"
  164. @disabled(!$canSendNotifications)
  165. >
  166. <option value="{{ \App\Models\ChatMessage::NOTIFICATION_NONE }}" @selected($notificationValue === \App\Models\ChatMessage::NOTIFICATION_NONE)>Нет</option>
  167. @if($canSendNotifications)
  168. <option value="{{ \App\Models\ChatMessage::NOTIFICATION_RESPONSIBLES }}" @selected($notificationValue === \App\Models\ChatMessage::NOTIFICATION_RESPONSIBLES)>Админы, менеджер, бригадир</option>
  169. <option value="{{ \App\Models\ChatMessage::NOTIFICATION_ALL }}" @selected($notificationValue === \App\Models\ChatMessage::NOTIFICATION_ALL)>Все</option>
  170. @endif
  171. </select>
  172. @if(!$canSendNotifications)
  173. <input type="hidden" name="notification_type" value="{{ \App\Models\ChatMessage::NOTIFICATION_NONE }}">
  174. @endif
  175. </div>
  176. <div class="col-12 col-md-4 col-lg-2">
  177. <label class="form-label" for="chat-attachments-{{ $contextKey }}">Файлы</label>
  178. <input class="form-control" id="chat-attachments-{{ $contextKey }}" type="file" name="attachments[]" multiple>
  179. </div>
  180. <div class="col-12">
  181. <div class="row g-2 align-items-center {{ $canSendNotifications && $notificationValue !== \App\Models\ChatMessage::NOTIFICATION_NONE ? '' : 'd-none' }}"
  182. data-chat-recipient-picker-wrap>
  183. <div class="col-12 col-md-auto">
  184. <button
  185. type="button"
  186. class="btn btn-outline-secondary btn-sm"
  187. data-chat-open-recipient-modal
  188. data-bs-toggle="modal"
  189. data-bs-target="#chatRecipientsModal-{{ $contextKey }}"
  190. >
  191. Выбрать получателей
  192. </button>
  193. </div>
  194. <div class="col-12 col-md">
  195. <div class="small text-muted chat-recipient-summary" data-chat-recipient-summary>
  196. Получатели не выбраны
  197. </div>
  198. </div>
  199. </div>
  200. <div data-chat-hidden-targets>
  201. @foreach($selectedTargetUserIds as $selectedTargetUserId)
  202. <input type="hidden" name="target_user_ids[]" value="{{ $selectedTargetUserId }}">
  203. @endforeach
  204. </div>
  205. </div>
  206. <div class="col-12 text-end">
  207. <button class="btn btn-primary btn-sm" type="submit">{{ $submitLabel }}</button>
  208. </div>
  209. </div>
  210. </form>
  211. @if($canSendNotifications)
  212. <div class="modal fade" id="chatRecipientsModal-{{ $contextKey }}" tabindex="-1" aria-labelledby="chatRecipientsModalLabel-{{ $contextKey }}" aria-hidden="true">
  213. <div class="modal-dialog modal-dialog-scrollable">
  214. <div class="modal-content">
  215. <div class="modal-header">
  216. <h1 class="modal-title fs-5" id="chatRecipientsModalLabel-{{ $contextKey }}">Получатели уведомления</h1>
  217. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
  218. </div>
  219. <div class="modal-body" data-chat-recipient-modal-body>
  220. <div class="d-flex justify-content-between align-items-center gap-2 mb-2 flex-wrap">
  221. <div class="small text-muted" data-chat-recipient-hint></div>
  222. <div class="d-flex gap-2">
  223. <button type="button" class="btn btn-sm btn-outline-primary" data-chat-check-visible>Выбрать видимых</button>
  224. <button type="button" class="btn btn-sm btn-outline-secondary" data-chat-uncheck-visible>Снять видимые</button>
  225. </div>
  226. </div>
  227. <div class="chat-recipient-list">
  228. @foreach($users as $userId => $userName)
  229. <label class="form-check mb-2 chat-recipient-item"
  230. data-chat-recipient-item
  231. data-user-id="{{ $userId }}"
  232. data-user-name="{{ $userName }}"
  233. data-chat-responsible="{{ in_array((int) $userId, $responsibleUserIds, true) ? '1' : '0' }}">
  234. <input
  235. class="form-check-input"
  236. type="checkbox"
  237. value="{{ $userId }}"
  238. data-chat-recipient-checkbox
  239. @checked(in_array((int) $userId, $selectedTargetUserIds, true))
  240. >
  241. <span class="form-check-label">{{ $userName }}</span>
  242. </label>
  243. @endforeach
  244. </div>
  245. </div>
  246. <div class="modal-footer">
  247. <button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Закрыть</button>
  248. <button type="button" class="btn btn-primary btn-sm" data-chat-apply-recipients data-bs-dismiss="modal">Применить</button>
  249. </div>
  250. </div>
  251. </div>
  252. </div>
  253. @endif
  254. </div>
  255. @once
  256. @push('scripts')
  257. <script type="module">
  258. function initChatBlock(block) {
  259. const messages = block.querySelector('[data-chat-messages]');
  260. const scrollButton = block.querySelector('[data-chat-scroll-bottom]');
  261. const form = block.querySelector('[data-chat-form]');
  262. const notificationType = block.querySelector('.chat-notification-type');
  263. const recipientPickerWrap = block.querySelector('[data-chat-recipient-picker-wrap]');
  264. const hiddenTargets = block.querySelector('[data-chat-hidden-targets]');
  265. const summary = block.querySelector('[data-chat-recipient-summary]');
  266. const modal = block.querySelector('.modal');
  267. const scrollToBottom = (force = false) => {
  268. if (!messages) {
  269. return;
  270. }
  271. const isNearBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 48;
  272. if (force || isNearBottom) {
  273. messages.scrollTop = messages.scrollHeight;
  274. }
  275. };
  276. const syncScrollButton = () => {
  277. if (!messages || !scrollButton) {
  278. return;
  279. }
  280. const shouldShow = messages.scrollHeight - messages.scrollTop - messages.clientHeight > 80;
  281. scrollButton.classList.toggle('d-none', !shouldShow);
  282. };
  283. const getSelectedIds = () => Array.from(hiddenTargets.querySelectorAll('input[name="target_user_ids[]"]'))
  284. .map((input) => Number(input.value))
  285. .filter((value) => value > 0);
  286. const setSelectedIds = (ids) => {
  287. hiddenTargets.innerHTML = '';
  288. ids.forEach((id) => {
  289. const input = document.createElement('input');
  290. input.type = 'hidden';
  291. input.name = 'target_user_ids[]';
  292. input.value = String(id);
  293. hiddenTargets.appendChild(input);
  294. });
  295. };
  296. const visibleRecipientItems = () => Array.from(block.querySelectorAll('[data-chat-recipient-item]'))
  297. .filter((item) => !item.hidden);
  298. const syncRecipientSummary = () => {
  299. if (!summary || !notificationType) {
  300. return;
  301. }
  302. if (notificationType.value === 'none') {
  303. summary.textContent = 'Уведомления выключены';
  304. return;
  305. }
  306. const selectedIds = getSelectedIds();
  307. if (!selectedIds.length) {
  308. summary.textContent = 'Получатели не выбраны';
  309. return;
  310. }
  311. const names = selectedIds
  312. .map((id) => block.querySelector('[data-chat-recipient-item][data-user-id="' + id + '"]'))
  313. .filter(Boolean)
  314. .map((item) => item.dataset.userName);
  315. summary.textContent = 'Выбрано: ' + names.join(', ');
  316. };
  317. const applyRecipientFilter = (preserveSelection = true) => {
  318. if (!notificationType || !modal) {
  319. return;
  320. }
  321. const isResponsibles = notificationType.value === 'responsibles';
  322. const isAll = notificationType.value === 'all';
  323. const recipientItems = Array.from(block.querySelectorAll('[data-chat-recipient-item]'));
  324. const selectedIds = new Set(getSelectedIds());
  325. const visibleIds = [];
  326. recipientItems.forEach((item) => {
  327. const isResponsible = item.dataset.chatResponsible === '1';
  328. const visible = isAll || (isResponsibles && isResponsible);
  329. const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
  330. item.hidden = !visible;
  331. checkbox.disabled = !visible;
  332. if (visible) {
  333. visibleIds.push(Number(item.dataset.userId));
  334. } else {
  335. checkbox.checked = false;
  336. }
  337. });
  338. if (!preserveSelection) {
  339. recipientItems.forEach((item) => {
  340. const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
  341. if (!checkbox.disabled) {
  342. checkbox.checked = true;
  343. }
  344. });
  345. } else {
  346. recipientItems.forEach((item) => {
  347. const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
  348. checkbox.checked = !checkbox.disabled && selectedIds.has(Number(item.dataset.userId));
  349. });
  350. const hasVisibleSelected = recipientItems.some((item) => {
  351. const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
  352. return !checkbox.disabled && checkbox.checked;
  353. });
  354. if (!hasVisibleSelected && visibleIds.length) {
  355. recipientItems.forEach((item) => {
  356. const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
  357. if (!checkbox.disabled) {
  358. checkbox.checked = true;
  359. }
  360. });
  361. }
  362. }
  363. const hint = block.querySelector('[data-chat-recipient-hint]');
  364. if (hint) {
  365. hint.textContent = isResponsibles
  366. ? 'Доступны только админы, менеджер и бригадир текущей сущности.'
  367. : 'Доступны все пользователи.';
  368. }
  369. };
  370. const commitRecipientSelection = () => {
  371. if (!notificationType || notificationType.value === 'none') {
  372. setSelectedIds([]);
  373. syncRecipientSummary();
  374. return;
  375. }
  376. const ids = visibleRecipientItems()
  377. .map((item) => item.querySelector('[data-chat-recipient-checkbox]'))
  378. .filter((checkbox) => checkbox && checkbox.checked)
  379. .map((checkbox) => Number(checkbox.value))
  380. .filter((value) => value > 0);
  381. setSelectedIds(ids);
  382. syncRecipientSummary();
  383. };
  384. if (messages) {
  385. requestAnimationFrame(() => {
  386. scrollToBottom(true);
  387. syncScrollButton();
  388. });
  389. setTimeout(() => {
  390. scrollToBottom(true);
  391. syncScrollButton();
  392. }, 150);
  393. messages.addEventListener('scroll', syncScrollButton);
  394. }
  395. if (scrollButton) {
  396. scrollButton.addEventListener('click', () => scrollToBottom(true));
  397. }
  398. if (notificationType) {
  399. notificationType.addEventListener('change', (event) => {
  400. const enabled = event.target.value !== 'none';
  401. if (recipientPickerWrap) {
  402. recipientPickerWrap.classList.toggle('d-none', !enabled);
  403. }
  404. if (!enabled) {
  405. setSelectedIds([]);
  406. syncRecipientSummary();
  407. return;
  408. }
  409. applyRecipientFilter(false);
  410. commitRecipientSelection();
  411. if (modal) {
  412. bootstrap.Modal.getOrCreateInstance(modal).show();
  413. }
  414. });
  415. }
  416. block.querySelector('[data-chat-open-recipient-modal]')?.addEventListener('click', () => {
  417. applyRecipientFilter(true);
  418. });
  419. block.querySelector('[data-chat-check-visible]')?.addEventListener('click', () => {
  420. visibleRecipientItems().forEach((item) => {
  421. const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
  422. if (checkbox) {
  423. checkbox.checked = true;
  424. }
  425. });
  426. });
  427. block.querySelector('[data-chat-uncheck-visible]')?.addEventListener('click', () => {
  428. visibleRecipientItems().forEach((item) => {
  429. const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
  430. if (checkbox) {
  431. checkbox.checked = false;
  432. }
  433. });
  434. });
  435. block.querySelector('[data-chat-apply-recipients]')?.addEventListener('click', commitRecipientSelection);
  436. modal?.addEventListener('hidden.bs.modal', () => {
  437. if (notificationType && notificationType.value !== 'none') {
  438. commitRecipientSelection();
  439. }
  440. });
  441. form?.addEventListener('submit', () => {
  442. if (notificationType && notificationType.value !== 'none') {
  443. commitRecipientSelection();
  444. }
  445. });
  446. applyRecipientFilter(true);
  447. if (notificationType && notificationType.value !== 'none') {
  448. commitRecipientSelection();
  449. } else {
  450. syncRecipientSummary();
  451. }
  452. }
  453. document.querySelectorAll('[data-chat-block]').forEach(initChatBlock);
  454. </script>
  455. @endpush
  456. @endonce