This commit is contained in:
Игорь Надеин 2026-01-05 15:04:45 +03:00
parent b074c91549
commit e2fe9fa66a
3 changed files with 771 additions and 628 deletions

View File

@ -10,6 +10,7 @@ Signals для автоматической генерации уведомле
"""
import logging
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models.signals import m2m_changed, post_save, pre_save
@ -51,7 +52,9 @@ def create_request_notifications(sender, instance, created, **kwargs):
)
_pending_new_requests.add(request_obj.id)
else:
print(f"⏭️ [SIGNAL] Пропускаем: created={created}, status={request_obj.status}")
print(
f"⏭️ [SIGNAL] Пропускаем: created={created}, status={request_obj.status}"
)
if not created:
# Проверяем изменение статуса через сохраненный атрибут _old_status
@ -216,26 +219,30 @@ def notify_new_request(request_obj):
При sent_to_all_department=True отправляет всем сотрудникам отделов
"""
logger.info(
f"\n{'='*80}\n"
f"\n{'=' * 80}\n"
f"[notify_new_request] 📨 НАЧАЛО ОТПРАВКИ УВЕДОМЛЕНИЙ О НОВОМ ЗАЯВЛЕНИИ\n"
f" Request ID: {request_obj.id}\n"
f" Тип: {request_obj.get_type_display()}\n"
f" Сотрудник: {request_obj.employee}\n"
f" Статус: {request_obj.status}\n"
f"{'='*80}"
f"{'=' * 80}"
)
print(f"📨 [notify_new_request] Начинаем обработку заявления #{request_obj.id}")
recipients_set = set()
# 1. Основные получатели
recipients_count = request_obj.recipients.count()
logger.info(f"[notify_new_request] Основные получатели (recipients): {recipients_count}")
logger.info(
f"[notify_new_request] Основные получатели (recipients): {recipients_count}"
)
print(f" Recipients в БД: {recipients_count}")
for recipient in request_obj.recipients.filter(is_active=True):
recipients_set.add(recipient)
logger.info(f"[notify_new_request] ✅ Основной получатель: {recipient.username} (ID={recipient.id}, email={recipient.email})")
logger.info(
f"[notify_new_request] ✅ Основной получатель: {recipient.username} (ID={recipient.id}, email={recipient.email})"
)
print(
f" ✅ Добавлен основной получатель: {recipient.username} (ID: {recipient.id})"
)
@ -246,7 +253,9 @@ def notify_new_request(request_obj):
print(f" CC users в БД: {cc_count}")
for cc_user in request_obj.cc_users.filter(is_active=True):
recipients_set.add(cc_user)
logger.info(f"[notify_new_request] ✅ CC получатель: {cc_user.username} (ID={cc_user.id}, email={cc_user.email})")
logger.info(
f"[notify_new_request] ✅ CC получатель: {cc_user.username} (ID={cc_user.id}, email={cc_user.email})"
)
print(f" ✅ Добавлен CC: {cc_user.username} (ID: {cc_user.id})")
# 3. Если sent_to_all_department - все сотрудники отделов
@ -297,9 +306,7 @@ def notify_new_request(request_obj):
recipients_set.update(dept_processors)
# Итоговое логирование
logger.info(
f"[notify_new_request] 📊 ИТОГО получателей: {len(recipients_set)}"
)
logger.info(f"[notify_new_request] 📊 ИТОГО получателей: {len(recipients_set)}")
print(
f"📊 [notify_new_request] Всего получателей для заявления #{request_obj.id}: {len(recipients_set)}"
)
@ -313,7 +320,9 @@ def notify_new_request(request_obj):
return
for r in recipients_set:
logger.info(f"[notify_new_request] 👤 {r.username} (ID={r.id}, email={r.email})")
logger.info(
f"[notify_new_request] 👤 {r.username} (ID={r.id}, email={r.email})"
)
print(f" 👤 {r.username} (ID: {r.id})")
# Определяем тип уведомления для каждого получателя
@ -322,7 +331,7 @@ def notify_new_request(request_obj):
comment_preview = (
request_obj.comment[:150] if request_obj.comment else "Без комментария"
)
logger.info(
f"[notify_new_request] Начало отправки {len(recipients_set)} уведомлений..."
)
@ -362,7 +371,7 @@ def notify_new_request(request_obj):
title = f"📝 Новое заявление в отделе от {author_name}"
message = f'Тип: "{request_type}". {comment_preview}'
notification_type = "request_new" # Используем общий тип
logger.info(
f"[notify_new_request] ➡️ Вызов NotificationService.create_notification для {recipient.username}"
)

View File

@ -2,187 +2,193 @@
* Управление страницей списка уведомлений
*/
class NotificationListManager {
constructor() {
constructor() {
this.currentPage = 1;
this.pageSize = 20;
this.currentFilter = {
category: "",
is_read: "",
search: "",
};
this.init();
}
init() {
this.setupFilters();
this.setupSearch();
this.setupMarkAllButton();
this.loadNotifications();
}
setupFilters() {
// Фильтр по категории
const categoryFilter = document.getElementById("categoryFilter");
if (categoryFilter) {
categoryFilter.addEventListener("change", (e) => {
this.currentFilter.category = e.target.value;
this.currentPage = 1;
this.pageSize = 20;
this.currentFilter = {
category: '',
is_read: '',
search: ''
};
this.init();
}
init() {
this.setupFilters();
this.setupSearch();
this.setupMarkAllButton();
this.loadNotifications();
});
}
setupFilters() {
// Фильтр по категории
const categoryFilter = document.getElementById('categoryFilter');
if (categoryFilter) {
categoryFilter.addEventListener('change', (e) => {
this.currentFilter.category = e.target.value;
this.currentPage = 1;
this.loadNotifications();
});
}
// Фильтр по статусу прочитанности
const statusFilter = document.getElementById('statusFilter');
if (statusFilter) {
statusFilter.addEventListener('change', (e) => {
this.currentFilter.is_read = e.target.value;
this.currentPage = 1;
this.loadNotifications();
});
}
// Фильтр по статусу прочитанности
const statusFilter = document.getElementById("statusFilter");
if (statusFilter) {
statusFilter.addEventListener("change", (e) => {
this.currentFilter.is_read = e.target.value;
this.currentPage = 1;
this.loadNotifications();
});
}
setupSearch() {
const searchInput = document.getElementById('notificationSearch');
const searchBtn = document.getElementById('searchBtn');
if (searchInput && searchBtn) {
searchBtn.addEventListener('click', () => {
this.currentFilter.search = searchInput.value.trim();
this.currentPage = 1;
this.loadNotifications();
});
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.currentFilter.search = searchInput.value.trim();
this.currentPage = 1;
this.loadNotifications();
}
});
}
setupSearch() {
const searchInput = document.getElementById("notificationSearch");
const searchBtn = document.getElementById("searchBtn");
if (searchInput && searchBtn) {
searchBtn.addEventListener("click", () => {
this.currentFilter.search = searchInput.value.trim();
this.currentPage = 1;
this.loadNotifications();
});
searchInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
this.currentFilter.search = searchInput.value.trim();
this.currentPage = 1;
this.loadNotifications();
}
});
}
setupMarkAllButton() {
const markAllBtn = document.getElementById('markAllReadBtn');
if (markAllBtn) {
markAllBtn.addEventListener('click', async () => {
await this.markAllAsRead();
});
}
}
setupMarkAllButton() {
const markAllBtn = document.getElementById("markAllReadBtn");
if (markAllBtn) {
markAllBtn.addEventListener("click", async () => {
await this.markAllAsRead();
});
}
async loadNotifications() {
const container = document.getElementById('notificationListContainer');
if (!container) return;
// Показать загрузку
container.innerHTML = `
}
async loadNotifications() {
const container = document.getElementById("notificationListContainer");
if (!container) return;
// Показать загрузку
container.innerHTML = `
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
`;
try {
const params = new URLSearchParams({
page: this.currentPage,
page_size: this.pageSize
});
if (this.currentFilter.category) {
params.append('category', this.currentFilter.category);
}
if (this.currentFilter.is_read !== '') {
params.append('is_read', this.currentFilter.is_read);
}
if (this.currentFilter.search) {
params.append('search', this.currentFilter.search);
}
const response = await fetch(`/api/v1/notifications/?${params}`, {
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.renderNotifications(data);
} catch (error) {
console.error('[NotificationList] Error loading:', error);
container.innerHTML = `
try {
const params = new URLSearchParams({
page: this.currentPage,
page_size: this.pageSize,
});
if (this.currentFilter.category) {
params.append("category", this.currentFilter.category);
}
if (this.currentFilter.is_read !== "") {
params.append("is_read", this.currentFilter.is_read);
}
if (this.currentFilter.search) {
params.append("search", this.currentFilter.search);
}
const response = await fetch(`/api/v1/notifications/?${params}`, {
credentials: "same-origin",
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.renderNotifications(data);
} catch (error) {
console.error("[NotificationList] Error loading:", error);
container.innerHTML = `
<div class="alert alert-danger">
<i class="bi-exclamation-triangle"></i>
Ошибка загрузки уведомлений: ${error.message}
</div>
`;
}
}
renderNotifications(data) {
const container = document.getElementById('notificationListContainer');
if (!container) return;
if (!data.notifications || data.notifications.length === 0) {
container.innerHTML = `
}
renderNotifications(data) {
const container = document.getElementById("notificationListContainer");
if (!container) return;
if (!data.notifications || data.notifications.length === 0) {
container.innerHTML = `
<div class="text-center py-5">
<i class="bi-bell-slash" style="font-size: 3rem; color: #ccc;"></i>
<p class="text-muted mt-3">Уведомлений нет</p>
</div>
`;
return;
}
const notificationsHtml = data.notifications.map(n => this.renderNotificationCard(n)).join('');
const paginationHtml = this.renderPagination(data);
container.innerHTML = `
return;
}
const notificationsHtml = data.notifications
.map((n) => this.renderNotificationCard(n))
.join("");
const paginationHtml = this.renderPagination(data);
container.innerHTML = `
<div class="row row-cols-1 g-3">
${notificationsHtml}
</div>
${paginationHtml}
`;
// Добавить обработчики событий
this.attachEventHandlers();
}
renderNotificationCard(notification) {
const categoryColors = {
'communications': 'primary',
'documents': 'success',
'requests': 'warning',
'calendar': 'info',
'department': 'secondary',
'profile': 'dark',
'feed': 'danger',
'system': 'light'
};
const categoryIcons = {
'communications': 'bi-chat-dots',
'documents': 'bi-file-earmark-text',
'requests': 'bi-clipboard-check',
'calendar': 'bi-calendar-event',
'department': 'bi-people',
'profile': 'bi-person',
'feed': 'bi-newspaper',
'system': 'bi-gear'
};
const color = categoryColors[notification.category] || 'secondary';
const icon = categoryIcons[notification.category] || 'bi-bell';
const readClass = notification.is_read ? 'notification-read' : 'notification-unread';
return `
// Добавить обработчики событий
this.attachEventHandlers();
}
renderNotificationCard(notification) {
const categoryColors = {
communications: "primary",
documents: "success",
requests: "warning",
calendar: "info",
department: "secondary",
profile: "dark",
feed: "danger",
system: "light",
};
const categoryIcons = {
communications: "bi-chat-dots",
documents: "bi-file-earmark-text",
requests: "bi-clipboard-check",
calendar: "bi-calendar-event",
department: "bi-people",
profile: "bi-person",
feed: "bi-newspaper",
system: "bi-gear",
};
const color = categoryColors[notification.category] || "secondary";
const icon = categoryIcons[notification.category] || "bi-bell";
const readClass = notification.is_read
? "notification-read"
: "notification-unread";
return `
<div class="col">
<div class="card ${readClass}" data-notification-id="${notification.id}">
<div class="card ${readClass}" data-notification-id="${
notification.id
}">
<div class="card-body">
<div class="d-flex align-items-start">
<div class="flex-shrink-0">
@ -192,22 +198,43 @@ class NotificationListManager {
</div>
<div class="flex-grow-1 ms-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="mb-0">${this.escapeHtml(notification.title)}</h6>
<small class="text-muted ms-2">${this.formatTime(notification.created_at)}</small>
<h6 class="mb-0">${this.escapeHtml(
notification.title
)}</h6>
<small class="text-muted ms-2">${this.formatTime(
notification.created_at
)}</small>
</div>
<p class="mb-2 text-muted">${this.escapeHtml(notification.message)}</p>
<p class="mb-2 text-muted">${this.escapeHtml(
notification.message
)}</p>
<div class="d-flex gap-2">
${notification.action_url ? `
<a href="${notification.action_url}" class="btn btn-sm btn-outline-primary">
<i class="bi-arrow-right-circle"></i> ${notification.action_text || 'Посмотреть'}
${
notification.action_url
? `
<a href="${
notification.action_url
}" class="btn btn-sm btn-outline-primary">
<i class="bi-arrow-right-circle"></i> ${
notification.action_text ||
"Посмотреть"
}
</a>
` : ''}
${!notification.is_read ? `
`
: ""
}
${
!notification.is_read
? `
<button class="btn btn-sm btn-outline-secondary mark-read-btn" data-id="${notification.id}">
<i class="bi-check2"></i> Прочитано
</button>
` : ''}
<button class="btn btn-sm btn-outline-danger delete-btn" data-id="${notification.id}">
`
: ""
}
<button class="btn btn-sm btn-outline-danger delete-btn" data-id="${
notification.id
}">
<i class="bi-trash"></i>
</button>
</div>
@ -217,232 +244,248 @@ class NotificationListManager {
</div>
</div>
`;
}
renderPagination(data) {
if (data.total <= this.pageSize) {
return "";
}
renderPagination(data) {
if (data.total <= this.pageSize) {
return '';
}
const totalPages = Math.ceil(data.total / this.pageSize);
const pages = [];
// Всегда показываем первую страницу
pages.push(1);
// Показываем страницы вокруг текущей
for (let i = Math.max(2, this.currentPage - 1); i <= Math.min(totalPages - 1, this.currentPage + 1); i++) {
if (!pages.includes(i)) {
pages.push(i);
}
}
// Всегда показываем последнюю страницу
if (!pages.includes(totalPages)) {
pages.push(totalPages);
}
let paginationHtml = '<nav class="mt-4"><ul class="pagination justify-content-center">';
// Кнопка "Предыдущая"
paginationHtml += `
<li class="page-item ${this.currentPage === 1 ? 'disabled' : ''}">
<a class="page-link" href="#" data-page="${this.currentPage - 1}">
const totalPages = Math.ceil(data.total / this.pageSize);
const pages = [];
// Всегда показываем первую страницу
pages.push(1);
// Показываем страницы вокруг текущей
for (
let i = Math.max(2, this.currentPage - 1);
i <= Math.min(totalPages - 1, this.currentPage + 1);
i++
) {
if (!pages.includes(i)) {
pages.push(i);
}
}
// Всегда показываем последнюю страницу
if (!pages.includes(totalPages)) {
pages.push(totalPages);
}
let paginationHtml =
'<nav class="mt-4"><ul class="pagination justify-content-center">';
// Кнопка "Предыдущая"
paginationHtml += `
<li class="page-item ${this.currentPage === 1 ? "disabled" : ""}">
<a class="page-link" href="#" data-page="${
this.currentPage - 1
}">
<i class="bi-chevron-left"></i>
</a>
</li>
`;
// Страницы
let prevPage = 0;
pages.forEach(page => {
if (page - prevPage > 1) {
paginationHtml += '<li class="page-item disabled"><span class="page-link">...</span></li>';
}
paginationHtml += `
<li class="page-item ${page === this.currentPage ? 'active' : ''}">
// Страницы
let prevPage = 0;
pages.forEach((page) => {
if (page - prevPage > 1) {
paginationHtml +=
'<li class="page-item disabled"><span class="page-link">...</span></li>';
}
paginationHtml += `
<li class="page-item ${
page === this.currentPage ? "active" : ""
}">
<a class="page-link" href="#" data-page="${page}">${page}</a>
</li>
`;
prevPage = page;
});
// Кнопка "Следующая"
paginationHtml += `
<li class="page-item ${this.currentPage === totalPages ? 'disabled' : ''}">
<a class="page-link" href="#" data-page="${this.currentPage + 1}">
prevPage = page;
});
// Кнопка "Следующая"
paginationHtml += `
<li class="page-item ${
this.currentPage === totalPages ? "disabled" : ""
}">
<a class="page-link" href="#" data-page="${
this.currentPage + 1
}">
<i class="bi-chevron-right"></i>
</a>
</li>
`;
paginationHtml += '</ul></nav>';
return paginationHtml;
}
attachEventHandlers() {
// Обработчики для кнопок "Прочитано"
document.querySelectorAll('.mark-read-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const id = btn.dataset.id;
await this.markAsRead(id);
});
});
// Обработчики для кнопок удаления
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const id = btn.dataset.id;
if (confirm('Удалить это уведомление?')) {
await this.deleteNotification(id);
}
});
});
// Обработчики для пагинации
document.querySelectorAll('.pagination .page-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const page = parseInt(link.dataset.page);
if (page && page !== this.currentPage) {
this.currentPage = page;
this.loadNotifications();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
});
}
async markAsRead(id) {
try {
const response = await fetch(`/api/v1/notifications/${id}/read/`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
}
});
if (response.ok) {
// Обновить карточку
const card = document.querySelector(`[data-notification-id="${id}"]`);
if (card) {
card.classList.remove('notification-unread');
card.classList.add('notification-read');
card.querySelector('.mark-read-btn')?.remove();
}
// Обновить счетчик в navbar
if (window.notificationManager) {
const badge = document.getElementById('notificationBadge');
if (badge) {
const currentCount = parseInt(badge.textContent) || 0;
if (currentCount > 0) {
window.notificationManager.updateBadge(currentCount - 1);
}
}
}
paginationHtml += "</ul></nav>";
return paginationHtml;
}
attachEventHandlers() {
// Обработчики для кнопок "Прочитано"
document.querySelectorAll(".mark-read-btn").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
const id = btn.dataset.id;
await this.markAsRead(id);
});
});
// Обработчики для кнопок удаления
document.querySelectorAll(".delete-btn").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
const id = btn.dataset.id;
if (confirm("Удалить это уведомление?")) {
await this.deleteNotification(id);
}
});
});
// Обработчики для пагинации
document.querySelectorAll(".pagination .page-link").forEach((link) => {
link.addEventListener("click", (e) => {
e.preventDefault();
const page = parseInt(link.dataset.page);
if (page && page !== this.currentPage) {
this.currentPage = page;
this.loadNotifications();
window.scrollTo({ top: 0, behavior: "smooth" });
}
});
});
}
async markAsRead(id) {
try {
const response = await fetch(`/api/v1/notifications/${id}/read/`, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": this.getCsrfToken(),
},
});
if (response.ok) {
// Обновить карточку
const card = document.querySelector(`[data-notification-id="${id}"]`);
if (card) {
card.classList.remove("notification-unread");
card.classList.add("notification-read");
card.querySelector(".mark-read-btn")?.remove();
}
// Обновить счетчик в navbar
if (window.notificationManager) {
const badge = document.getElementById("notificationBadge");
if (badge) {
const currentCount = parseInt(badge.textContent) || 0;
if (currentCount > 0) {
window.notificationManager.updateBadge(currentCount - 1);
}
} catch (error) {
console.error('[NotificationList] Error marking as read:', error);
alert('Ошибка при обновлении уведомления');
}
}
}
} catch (error) {
console.error("[NotificationList] Error marking as read:", error);
alert("Ошибка при обновлении уведомления");
}
async markAllAsRead() {
if (!confirm('Отметить все уведомления как прочитанные?')) {
return;
}
async markAllAsRead() {
if (!confirm("Отметить все уведомления как прочитанные?")) {
return;
}
try {
const response = await fetch("/api/v1/notifications/read-all/", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": this.getCsrfToken(),
},
});
if (response.ok) {
// Перезагрузить список
this.loadNotifications();
// Обновить счетчик
if (window.notificationManager) {
window.notificationManager.updateBadge(0);
}
try {
const response = await fetch('/api/v1/notifications/read-all/', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
}
});
if (response.ok) {
// Перезагрузить список
this.loadNotifications();
// Обновить счетчик
if (window.notificationManager) {
window.notificationManager.updateBadge(0);
}
}
} catch (error) {
console.error('[NotificationList] Error marking all as read:', error);
alert('Ошибка при обновлении уведомлений');
}
} catch (error) {
console.error("[NotificationList] Error marking all as read:", error);
alert("Ошибка при обновлении уведомлений");
}
}
async deleteNotification(id) {
try {
const response = await fetch(`/api/v1/notifications/${id}/`, {
method: "DELETE",
credentials: "same-origin",
headers: {
"X-CSRFToken": this.getCsrfToken(),
},
});
if (response.ok) {
// Удалить карточку
const card = document
.querySelector(`[data-notification-id="${id}"]`)
?.closest(".col");
if (card) {
card.remove();
}
}
async deleteNotification(id) {
try {
const response = await fetch(`/api/v1/notifications/${id}/`, {
method: 'DELETE',
credentials: 'same-origin',
headers: {
'X-CSRFToken': this.getCsrfToken()
}
});
if (response.ok) {
// Удалить карточку
const card = document.querySelector(`[data-notification-id="${id}"]`)?.closest('.col');
if (card) {
card.remove();
}
// Если список пуст, показать заглушку
const container = document.getElementById('notificationListContainer');
if (container && !container.querySelector('.card')) {
this.loadNotifications();
}
}
} catch (error) {
console.error('[NotificationList] Error deleting:', error);
alert('Ошибка при удалении уведомления');
// Если список пуст, показать заглушку
const container = document.getElementById("notificationListContainer");
if (container && !container.querySelector(".card")) {
this.loadNotifications();
}
}
} catch (error) {
console.error("[NotificationList] Error deleting:", error);
alert("Ошибка при удалении уведомления");
}
getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]')?.value || '';
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
formatTime(dateString) {
const date = new Date(dateString);
const now = new Date();
const diff = Math.floor((now - date) / 1000); // секунды
if (diff < 60) return 'только что';
if (diff < 3600) return `${Math.floor(diff / 60)} мин назад`;
if (diff < 86400) return `${Math.floor(diff / 3600)} ч назад`;
if (diff < 604800) return `${Math.floor(diff / 86400)} дн назад`;
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
}
}
getCsrfToken() {
return document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
}
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
formatTime(dateString) {
const date = new Date(dateString);
const now = new Date();
const diff = Math.floor((now - date) / 1000); // секунды
if (diff < 60) return "только что";
if (diff < 3600) return `${Math.floor(diff / 60)} мин назад`;
if (diff < 86400) return `${Math.floor(diff / 3600)} ч назад`;
if (diff < 604800) return `${Math.floor(diff / 86400)} дн назад`;
return date.toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
}
// Инициализация при загрузке страницы
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('notificationListContainer')) {
window.notificationListManager = new NotificationListManager();
}
document.addEventListener("DOMContentLoaded", () => {
if (document.getElementById("notificationListContainer")) {
window.notificationListManager = new NotificationListManager();
}
});

View File

@ -2,73 +2,113 @@
* Управление настройками уведомлений
*/
class NotificationSettingsManager {
constructor() {
this.settings = {};
this.init();
}
init() {
this.loadSettings();
this.setupSoundToggle();
}
async loadSettings() {
const container = document.getElementById('settingsContainer');
if (!container) return;
try {
const response = await fetch('/api/v1/notifications/settings/', {
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.settings = data.settings || {};
this.renderSettings();
} catch (error) {
console.error('[Settings] Error loading:', error);
container.innerHTML = `
constructor() {
this.settings = {};
this.init();
}
init() {
this.loadSettings();
this.setupSoundToggle();
}
async loadSettings() {
const container = document.getElementById("settingsContainer");
if (!container) return;
try {
const response = await fetch("/api/v1/notifications/settings/", {
credentials: "same-origin",
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.settings = data.settings || {};
this.renderSettings();
} catch (error) {
console.error("[Settings] Error loading:", error);
container.innerHTML = `
<div class="alert alert-danger">
<i class="bi-exclamation-triangle"></i>
Ошибка загрузки настроек: ${error.message}
</div>
`;
}
}
renderSettings() {
const container = document.getElementById('settingsContainer');
if (!container) return;
const categories = [
{ code: 'communications', name: 'Коммуникации', icon: 'bi-chat-dots', description: 'Сообщения, упоминания, ответы в чатах' },
{ code: 'documents', name: 'Документы', icon: 'bi-file-earmark-text', description: 'Новые документы, ознакомления' },
{ code: 'requests', name: 'Заявления', icon: 'bi-clipboard-check', description: 'Заявки, согласования, комментарии' },
{ code: 'calendar', name: 'Календарь', icon: 'bi-calendar-event', description: 'События, напоминания, изменения' },
{ code: 'department', name: 'Отдел', icon: 'bi-people', description: 'Изменения в структуре отдела' },
{ code: 'profile', name: 'Профиль', icon: 'bi-person', description: 'Изменения профиля и безопасности' },
{ code: 'feed', name: 'Новости', icon: 'bi-newspaper', description: 'Публикации, комментарии, реакции' },
{ code: 'system', name: 'Система', icon: 'bi-gear', description: 'Системные уведомления и обновления' }
];
let html = '<div class="row g-4">';
categories.forEach(cat => {
const setting = this.settings[cat.code] || {
is_enabled: true,
web_enabled: true,
email_enabled: false,
email_frequency: 'instant',
telegram_enabled: false
};
html += `
}
renderSettings() {
const container = document.getElementById("settingsContainer");
if (!container) return;
const categories = [
{
code: "communications",
name: "Коммуникации",
icon: "bi-chat-dots",
description: "Сообщения, упоминания, ответы в чатах",
},
{
code: "documents",
name: "Документы",
icon: "bi-file-earmark-text",
description: "Новые документы, ознакомления",
},
{
code: "requests",
name: "Заявления",
icon: "bi-clipboard-check",
description: "Заявки, согласования, комментарии",
},
{
code: "calendar",
name: "Календарь",
icon: "bi-calendar-event",
description: "События, напоминания, изменения",
},
{
code: "department",
name: "Отдел",
icon: "bi-people",
description: "Изменения в структуре отдела",
},
{
code: "profile",
name: "Профиль",
icon: "bi-person",
description: "Изменения профиля и безопасности",
},
{
code: "feed",
name: "Новости",
icon: "bi-newspaper",
description: "Публикации, комментарии, реакции",
},
{
code: "system",
name: "Система",
icon: "bi-gear",
description: "Системные уведомления и обновления",
},
];
let html = '<div class="row g-4">';
categories.forEach((cat) => {
const setting = this.settings[cat.code] || {
is_enabled: true,
web_enabled: true,
email_enabled: false,
email_frequency: "instant",
telegram_enabled: false,
};
html += `
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
@ -80,66 +120,113 @@ class NotificationSettingsManager {
</div>
<div class="flex-grow-1 ms-3">
<h5 class="card-title mb-1">${cat.name}</h5>
<p class="text-muted small mb-3">${cat.description}</p>
<p class="text-muted small mb-3">${
cat.description
}</p>
<div class="form-check form-switch mb-2">
<input class="form-check-input setting-toggle"
type="checkbox"
<input class="form-check-input setting-toggle"
type="checkbox"
id="enable_${cat.code}"
data-category="${cat.code}"
data-field="is_enabled"
${setting.is_enabled ? 'checked' : ''}>
<label class="form-check-label" for="enable_${cat.code}">
${
setting.is_enabled
? "checked"
: ""
}>
<label class="form-check-label" for="enable_${
cat.code
}">
<strong>Включить уведомления</strong>
</label>
</div>
<div class="ps-4 ${setting.is_enabled ? '' : 'd-none'}" id="channels_${cat.code}">
<div class="ps-4 ${
setting.is_enabled ? "" : "d-none"
}" id="channels_${cat.code}">
<div class="form-check mb-2">
<input class="form-check-input channel-toggle"
type="checkbox"
<input class="form-check-input channel-toggle"
type="checkbox"
id="web_${cat.code}"
data-category="${cat.code}"
data-field="web_enabled"
${setting.web_enabled ? 'checked' : ''}>
<label class="form-check-label" for="web_${cat.code}">
${
setting.web_enabled
? "checked"
: ""
}>
<label class="form-check-label" for="web_${
cat.code
}">
<i class="bi-globe"></i> Веб-уведомления
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input channel-toggle"
type="checkbox"
<input class="form-check-input channel-toggle"
type="checkbox"
id="email_${cat.code}"
data-category="${cat.code}"
data-field="email_enabled"
${setting.email_enabled ? 'checked' : ''}>
<label class="form-check-label" for="email_${cat.code}">
${
setting.email_enabled
? "checked"
: ""
}>
<label class="form-check-label" for="email_${
cat.code
}">
<i class="bi-envelope"></i> Email уведомления
</label>
</div>
${setting.email_enabled ? `
${
setting.email_enabled
? `
<div class="ps-4 mb-2">
<label class="form-label small text-muted mb-1">Частота отправки:</label>
<select class="form-select form-select-sm email-frequency-select"
<select class="form-select form-select-sm email-frequency-select"
data-category="${cat.code}"
id="email_freq_${cat.code}">
<option value="instant" ${setting.email_frequency === 'instant' ? 'selected' : ''}>Мгновенно</option>
<option value="daily" ${setting.email_frequency === 'daily' ? 'selected' : ''}>Ежедневный дайджест</option>
<option value="weekly" ${setting.email_frequency === 'weekly' ? 'selected' : ''}>Еженедельный дайджест</option>
<option value="instant" ${
setting.email_frequency ===
"instant"
? "selected"
: ""
}>Мгновенно</option>
<option value="daily" ${
setting.email_frequency ===
"daily"
? "selected"
: ""
}>Ежедневный дайджест</option>
<option value="weekly" ${
setting.email_frequency ===
"weekly"
? "selected"
: ""
}>Еженедельный дайджест</option>
</select>
</div>
` : ''}
`
: ""
}
<div class="form-check mb-2">
<input class="form-check-input channel-toggle"
type="checkbox"
<input class="form-check-input channel-toggle"
type="checkbox"
id="telegram_${cat.code}"
data-category="${cat.code}"
data-field="telegram_enabled"
${setting.telegram_enabled ? 'checked' : ''}>
<label class="form-check-label" for="telegram_${cat.code}">
${
setting.telegram_enabled
? "checked"
: ""
}>
<label class="form-check-label" for="telegram_${
cat.code
}">
<i class="bi-telegram"></i> Telegram
</label>
</div>
@ -150,176 +237,180 @@ class NotificationSettingsManager {
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
// Добавить обработчики
this.attachEventHandlers();
}
attachEventHandlers() {
// Обработчики для главных переключателей
document.querySelectorAll('.setting-toggle').forEach(toggle => {
toggle.addEventListener('change', async (e) => {
const category = toggle.dataset.category;
const field = toggle.dataset.field;
const value = toggle.checked;
// Показать/скрыть каналы
const channels = document.getElementById(`channels_${category}`);
if (channels) {
if (value) {
channels.classList.remove('d-none');
} else {
channels.classList.add('d-none');
}
}
await this.updateSetting(category, field, value);
});
});
// Обработчики для каналов
document.querySelectorAll('.channel-toggle').forEach(toggle => {
toggle.addEventListener('change', async (e) => {
const category = toggle.dataset.category;
const field = toggle.dataset.field;
const value = toggle.checked;
// Если включили email, перерисовать карточку для показа частоты
if (field === 'email_enabled') {
await this.updateSetting(category, field, value);
this.renderSettings(); // Перерисовать для показа select
} else {
await this.updateSetting(category, field, value);
}
});
});
// Обработчики для частоты email
document.querySelectorAll('.email-frequency-select').forEach(select => {
select.addEventListener('change', async (e) => {
const category = select.dataset.category;
const value = select.value;
await this.updateSetting(category, 'email_frequency', value);
});
});
}
async updateSetting(category, field, value) {
try {
const response = await fetch('/api/v1/notifications/settings/category/update/', {
method: 'PUT',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({
category: category,
[field]: value
})
});
if (!response.ok) {
throw new Error('Failed to update setting');
}
// Обновить локальные настройки
if (!this.settings[category]) {
this.settings[category] = {};
}
this.settings[category][field] = value;
// Показать уведомление об успехе
this.showToast('Настройки сохранены', 'success');
} catch (error) {
console.error('[Settings] Error updating:', error);
this.showToast('Ошибка сохранения настроек', 'danger');
});
html += "</div>";
container.innerHTML = html;
// Добавить обработчики
this.attachEventHandlers();
}
attachEventHandlers() {
// Обработчики для главных переключателей
document.querySelectorAll(".setting-toggle").forEach((toggle) => {
toggle.addEventListener("change", async (e) => {
const category = toggle.dataset.category;
const field = toggle.dataset.field;
const value = toggle.checked;
// Показать/скрыть каналы
const channels = document.getElementById(`channels_${category}`);
if (channels) {
if (value) {
channels.classList.remove("d-none");
} else {
channels.classList.add("d-none");
}
}
}
setupSoundToggle() {
const soundToggle = document.getElementById('soundToggle');
if (soundToggle && window.notificationManager) {
soundToggle.checked = window.notificationManager.soundEnabled;
soundToggle.addEventListener('change', (e) => {
window.notificationManager.saveSoundPreference(e.target.checked);
this.showToast(
e.target.checked ? 'Звук включен' : 'Звук выключен',
'info'
);
});
await this.updateSetting(category, field, value);
});
});
// Обработчики для каналов
document.querySelectorAll(".channel-toggle").forEach((toggle) => {
toggle.addEventListener("change", async (e) => {
const category = toggle.dataset.category;
const field = toggle.dataset.field;
const value = toggle.checked;
// Если включили email, перерисовать карточку для показа частоты
if (field === "email_enabled") {
await this.updateSetting(category, field, value);
this.renderSettings(); // Перерисовать для показа select
} else {
await this.updateSetting(category, field, value);
}
});
});
// Обработчики для частоты email
document.querySelectorAll(".email-frequency-select").forEach((select) => {
select.addEventListener("change", async (e) => {
const category = select.dataset.category;
const value = select.value;
await this.updateSetting(category, "email_frequency", value);
});
});
}
async updateSetting(category, field, value) {
try {
const response = await fetch(
"/api/v1/notifications/settings/category/update/",
{
method: "PUT",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": this.getCsrfToken(),
},
body: JSON.stringify({
category: category,
[field]: value,
}),
}
);
if (!response.ok) {
throw new Error("Failed to update setting");
}
// Обновить локальные настройки
if (!this.settings[category]) {
this.settings[category] = {};
}
this.settings[category][field] = value;
// Показать уведомление об успехе
this.showToast("Настройки сохранены", "success");
} catch (error) {
console.error("[Settings] Error updating:", error);
this.showToast("Ошибка сохранения настроек", "danger");
}
getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]')?.value || '';
}
setupSoundToggle() {
const soundToggle = document.getElementById("soundToggle");
if (soundToggle && window.notificationManager) {
soundToggle.checked = window.notificationManager.soundEnabled;
soundToggle.addEventListener("change", (e) => {
window.notificationManager.saveSoundPreference(e.target.checked);
this.showToast(
e.target.checked ? "Звук включен" : "Звук выключен",
"info"
);
});
}
showToast(message, type = 'info') {
// Создать toast уведомление
const toastContainer = document.getElementById('toastContainer') || this.createToastContainer();
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0`;
toast.setAttribute('role', 'alert');
toast.innerHTML = `
}
getCsrfToken() {
return document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
}
showToast(message, type = "info") {
// Создать toast уведомление
const toastContainer =
document.getElementById("toastContainer") || this.createToastContainer();
const toast = document.createElement("div");
toast.className = `toast align-items-center text-white bg-${type} border-0`;
toast.setAttribute("role", "alert");
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
toastContainer.appendChild(toast);
const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => {
toast.remove();
});
}
createToastContainer() {
const container = document.createElement('div');
container.id = 'toastContainer';
container.className = 'toast-container position-fixed bottom-0 end-0 p-3';
document.body.appendChild(container);
return container;
}
toastContainer.appendChild(toast);
const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
bsToast.show();
toast.addEventListener("hidden.bs.toast", () => {
toast.remove();
});
}
createToastContainer() {
const container = document.createElement("div");
container.id = "toastContainer";
container.className = "toast-container position-fixed bottom-0 end-0 p-3";
document.body.appendChild(container);
return container;
}
}
/**
* Управление привязкой Telegram (упрощенная версия)
*/
class TelegramLinkManager {
constructor() {
this.container = document.getElementById('telegramLinkContainer');
if (!this.container) return;
this.init();
}
async init() {
this.renderInstructions();
}
renderInstructions() {
const botUsername = window.TELEGRAM_BOT_USERNAME || 'eusrr_bot';
this.container.innerHTML = `
constructor() {
this.container = document.getElementById("telegramLinkContainer");
if (!this.container) return;
this.init();
}
async init() {
this.renderInstructions();
}
renderInstructions() {
const botUsername = window.TELEGRAM_BOT_USERNAME || "eusrr_bot";
this.container.innerHTML = `
<div class="row">
<div class="col-md-10">
<h6 class="mb-3">
<i class="bi-telegram text-primary"></i>
Настройка Telegram уведомлений
</h6>
<div class="alert alert-info">
<strong>📱 Как получать уведомления в Telegram:</strong>
<ol class="mb-0 mt-3">
@ -349,20 +440,20 @@ class TelegramLinkManager {
</li>
</ol>
</div>
</div>
</div>
`;
}
}
}
// Инициализация
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('settingsContainer')) {
window.notificationSettingsManager = new NotificationSettingsManager();
}
if (document.getElementById('telegramLinkContainer')) {
window.telegramLinkManager = new TelegramLinkManager();
}
document.addEventListener("DOMContentLoaded", () => {
if (document.getElementById("settingsContainer")) {
window.notificationSettingsManager = new NotificationSettingsManager();
}
if (document.getElementById("telegramLinkContainer")) {
window.telegramLinkManager = new TelegramLinkManager();
}
});