feat(tasks): add notifications and wide board mode
This commit is contained in:
parent
786987efe5
commit
b9598c2731
@ -48,6 +48,13 @@ from tasks.models import (
|
||||
TaskLinkedObjectKind,
|
||||
TaskUserSettings,
|
||||
)
|
||||
from tasks.notifications import (
|
||||
notify_board_members_added,
|
||||
notify_task_comment,
|
||||
notify_task_created,
|
||||
notify_task_moved,
|
||||
notify_task_updated,
|
||||
)
|
||||
from tasks.realtime import (
|
||||
get_task_board_recipient_ids,
|
||||
send_task_board_update,
|
||||
@ -143,12 +150,27 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
def perform_create(self, serializer):
|
||||
board = serializer.save(created_by=self.request.user)
|
||||
create_default_columns(board)
|
||||
notify_board_members_added(
|
||||
board,
|
||||
self.request.user,
|
||||
board.members.filter(is_active=True),
|
||||
)
|
||||
send_task_board_update(board, "created", "board", board.id)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
board = self.get_object()
|
||||
previous_recipient_ids = get_task_board_recipient_ids(board)
|
||||
previous_member_ids = set(board.members.values_list("id", flat=True))
|
||||
board = serializer.save()
|
||||
added_member_ids = set(board.members.values_list("id", flat=True)) - (
|
||||
previous_member_ids
|
||||
)
|
||||
if added_member_ids:
|
||||
notify_board_members_added(
|
||||
board,
|
||||
self.request.user,
|
||||
Employee.objects.filter(id__in=added_member_ids, is_active=True),
|
||||
)
|
||||
recipient_ids = previous_recipient_ids | get_task_board_recipient_ids(board)
|
||||
send_task_board_update(
|
||||
board,
|
||||
@ -408,6 +430,7 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
TaskActivityAction.CREATED,
|
||||
metadata={"column": task.column.name},
|
||||
)
|
||||
notify_task_created(task, self.request.user)
|
||||
send_task_board_update(task.board, "created", "task", task.id)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
@ -439,6 +462,7 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
TaskActivityAction.UPDATED,
|
||||
metadata={"fields": changed_fields},
|
||||
)
|
||||
notify_task_updated(task, self.request.user, previous, current)
|
||||
send_task_board_update(task.board, "updated", "task", task.id)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
@ -509,6 +533,7 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
"to_column": column.name,
|
||||
},
|
||||
)
|
||||
notify_task_moved(task, request.user, old_column, column)
|
||||
send_task_board_update(
|
||||
task.board,
|
||||
"moved",
|
||||
@ -559,6 +584,7 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
author=request.user,
|
||||
content=text,
|
||||
)
|
||||
notify_task_comment(task, message)
|
||||
send_task_board_update(
|
||||
task.board,
|
||||
"commented",
|
||||
|
||||
@ -719,6 +719,10 @@ CELERY_BEAT_SCHEDULE = {
|
||||
"schedule": crontab(hour=9, minute=0, day_of_week="monday"),
|
||||
"args": ("weekly",),
|
||||
},
|
||||
"tasks-due-notifications": {
|
||||
"task": "tasks.dispatch_task_due_notifications",
|
||||
"schedule": crontab(hour=9, minute=5),
|
||||
},
|
||||
"process-ldap-sync-queue": {
|
||||
"task": "employees.tasks.process_ldap_queue",
|
||||
"schedule": 60.0, # Каждую минуту проверяем очередь LDAP retry
|
||||
|
||||
@ -5,3 +5,6 @@ class TasksConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "tasks"
|
||||
verbose_name = "Задачи"
|
||||
|
||||
def ready(self):
|
||||
import tasks.notifications.signals # noqa: F401
|
||||
|
||||
21
backend/tasks/notifications/__init__.py
Normal file
21
backend/tasks/notifications/__init__.py
Normal file
@ -0,0 +1,21 @@
|
||||
from .config import ActionURLs, MessageTemplates, NotificationVerbs
|
||||
from .handlers import (
|
||||
notify_board_members_added,
|
||||
notify_task_comment,
|
||||
notify_task_created,
|
||||
notify_task_linked_object,
|
||||
notify_task_moved,
|
||||
notify_task_updated,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ActionURLs",
|
||||
"MessageTemplates",
|
||||
"NotificationVerbs",
|
||||
"notify_board_members_added",
|
||||
"notify_task_comment",
|
||||
"notify_task_created",
|
||||
"notify_task_linked_object",
|
||||
"notify_task_moved",
|
||||
"notify_task_updated",
|
||||
]
|
||||
111
backend/tasks/notifications/config.py
Normal file
111
backend/tasks/notifications/config.py
Normal file
@ -0,0 +1,111 @@
|
||||
class NotificationVerbs:
|
||||
TASK_ASSIGNED = "task_assigned"
|
||||
TASK_REASSIGNED = "task_reassigned"
|
||||
TASK_COMMENT = "task_comment"
|
||||
TASK_DUE_DATE_CHANGED = "task_due_date_changed"
|
||||
TASK_DUE_SOON = "task_due_soon"
|
||||
TASK_OVERDUE = "task_overdue"
|
||||
TASK_COMPLETED = "task_completed"
|
||||
TASK_REOPENED = "task_reopened"
|
||||
TASK_LINKED_OBJECT_ADDED = "task_linked_object_added"
|
||||
TASK_BOARD_MEMBER_ADDED = "task_board_member_added"
|
||||
|
||||
|
||||
class ActionURLs:
|
||||
@staticmethod
|
||||
def task_detail(task) -> str:
|
||||
return f"/tasks?board={task.board_id}&task={task.id}"
|
||||
|
||||
@staticmethod
|
||||
def board_detail(board) -> str:
|
||||
return f"/tasks?board={board.id}"
|
||||
|
||||
|
||||
class MessageTemplates:
|
||||
@staticmethod
|
||||
def task_assigned(task, actor_name: str) -> tuple[str, str]:
|
||||
return (
|
||||
"Вам назначена задача",
|
||||
f'{actor_name} назначил вам задачу #{task.id} "{task.title}".',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def task_reassigned(task, actor_name: str) -> tuple[str, str]:
|
||||
return (
|
||||
"Исполнитель задачи изменен",
|
||||
f'{actor_name} изменил исполнителя задачи #{task.id} "{task.title}".',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def task_comment(task, actor_name: str, text: str) -> tuple[str, str]:
|
||||
preview = text[:160]
|
||||
return (
|
||||
"Новый комментарий к задаче",
|
||||
f'{actor_name} прокомментировал задачу #{task.id} "{task.title}": {preview}',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def task_due_date_changed(
|
||||
task,
|
||||
actor_name: str,
|
||||
old_due_date,
|
||||
new_due_date,
|
||||
) -> tuple[str, str]:
|
||||
old_value = old_due_date or "без срока"
|
||||
new_value = new_due_date or "без срока"
|
||||
return (
|
||||
"Срок задачи изменен",
|
||||
(
|
||||
f'{actor_name} изменил срок задачи #{task.id} "{task.title}": '
|
||||
f"{old_value} -> {new_value}."
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def task_due_soon(task) -> tuple[str, str]:
|
||||
return (
|
||||
"Срок задачи сегодня",
|
||||
f'Сегодня срок задачи #{task.id} "{task.title}".',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def task_overdue(task) -> tuple[str, str]:
|
||||
return (
|
||||
"Задача просрочена",
|
||||
f'Просрочена задача #{task.id} "{task.title}".',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def task_completed(task, actor_name: str) -> tuple[str, str]:
|
||||
return (
|
||||
"Задача завершена",
|
||||
f'{actor_name} завершил задачу #{task.id} "{task.title}".',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def task_reopened(task, actor_name: str) -> tuple[str, str]:
|
||||
return (
|
||||
"Задача возвращена в работу",
|
||||
f'{actor_name} вернул в работу задачу #{task.id} "{task.title}".',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def task_linked_object_added(
|
||||
task,
|
||||
actor_name: str,
|
||||
object_type: str,
|
||||
) -> tuple[str, str]:
|
||||
return (
|
||||
"К задаче добавлен связанный объект",
|
||||
(
|
||||
f'{actor_name} добавил объект "{object_type}" '
|
||||
f'к задаче #{task.id} "{task.title}".'
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def board_member_added(board, actor_name: str) -> tuple[str, str]:
|
||||
return (
|
||||
"Вас добавили на доску",
|
||||
f'{actor_name} добавил вас на доску "{board.name}".',
|
||||
)
|
||||
379
backend/tasks/notifications/handlers.py
Normal file
379
backend/tasks/notifications/handlers.py
Normal file
@ -0,0 +1,379 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import timezone
|
||||
from notifications.models import Notification
|
||||
from notifications.signals import notify
|
||||
|
||||
from tasks.models import Task, TaskLinkedObject, TaskLinkedObjectKind
|
||||
|
||||
from .config import ActionURLs, MessageTemplates, NotificationVerbs
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def _actor_id(actor) -> int | None:
|
||||
return getattr(actor, "id", None)
|
||||
|
||||
|
||||
def _actor_name(actor) -> str:
|
||||
if not actor:
|
||||
return "Система"
|
||||
return actor.get_full_name() or getattr(actor, "email", "") or str(actor)
|
||||
|
||||
|
||||
def _task_data(task: Task, title: str, extra: dict | None = None) -> dict:
|
||||
data = {
|
||||
"title": title,
|
||||
"task_id": task.id,
|
||||
"task_title": task.title,
|
||||
"board_id": task.board_id,
|
||||
"board_name": task.board.name,
|
||||
"column_id": task.column_id,
|
||||
"column_name": task.column.name,
|
||||
}
|
||||
if extra:
|
||||
data.update(extra)
|
||||
return data
|
||||
|
||||
|
||||
def _send_task_notification(
|
||||
*,
|
||||
recipient,
|
||||
verb: str,
|
||||
task: Task,
|
||||
title: str,
|
||||
description: str,
|
||||
actor=None,
|
||||
action_object=None,
|
||||
extra_data: dict | None = None,
|
||||
):
|
||||
if not recipient or not getattr(recipient, "is_active", False):
|
||||
return
|
||||
if _actor_id(actor) and recipient.id == _actor_id(actor):
|
||||
return
|
||||
|
||||
notify.send(
|
||||
sender=actor,
|
||||
recipient=recipient,
|
||||
verb=verb,
|
||||
action_object=action_object or task,
|
||||
target=task,
|
||||
description=description,
|
||||
action_url=ActionURLs.task_detail(task),
|
||||
data=_task_data(task, title, extra_data),
|
||||
)
|
||||
|
||||
|
||||
def _notify_many(
|
||||
recipients,
|
||||
*,
|
||||
verb: str,
|
||||
task: Task,
|
||||
title: str,
|
||||
description: str,
|
||||
actor=None,
|
||||
action_object=None,
|
||||
extra_data: dict | None = None,
|
||||
):
|
||||
seen = set()
|
||||
for recipient in recipients:
|
||||
if not recipient or recipient.id in seen:
|
||||
continue
|
||||
seen.add(recipient.id)
|
||||
_send_task_notification(
|
||||
recipient=recipient,
|
||||
verb=verb,
|
||||
task=task,
|
||||
title=title,
|
||||
description=description,
|
||||
actor=actor,
|
||||
action_object=action_object,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
|
||||
def _linked_employee_ids(task: Task) -> set[int]:
|
||||
employee_ct = ContentType.objects.get_for_model(User)
|
||||
return set(
|
||||
task.linked_objects.filter(
|
||||
kind=TaskLinkedObjectKind.EMPLOYEE,
|
||||
content_type=employee_ct,
|
||||
).values_list("object_id", flat=True)
|
||||
)
|
||||
|
||||
|
||||
def _task_recipient_ids(
|
||||
task: Task,
|
||||
*,
|
||||
include_created_by: bool = True,
|
||||
include_assignee: bool = True,
|
||||
include_linked_employees: bool = True,
|
||||
include_commenters: bool = False,
|
||||
) -> set[int]:
|
||||
recipient_ids = set()
|
||||
if include_created_by and task.created_by_id:
|
||||
recipient_ids.add(task.created_by_id)
|
||||
if include_assignee and task.assignee_id:
|
||||
recipient_ids.add(task.assignee_id)
|
||||
if include_linked_employees:
|
||||
recipient_ids.update(_linked_employee_ids(task))
|
||||
if include_commenters:
|
||||
from communications import comments_helpers
|
||||
|
||||
recipient_ids.update(
|
||||
message.author_id
|
||||
for message in comments_helpers.get_comments(task)
|
||||
if message.author_id
|
||||
)
|
||||
return recipient_ids
|
||||
|
||||
|
||||
def _task_recipients(task: Task, **kwargs):
|
||||
recipient_ids = _task_recipient_ids(task, **kwargs)
|
||||
if not recipient_ids:
|
||||
return []
|
||||
return User.objects.filter(id__in=recipient_ids, is_active=True)
|
||||
|
||||
|
||||
def notify_task_created(task: Task, actor=None):
|
||||
if not task.assignee_id:
|
||||
return
|
||||
title, description = MessageTemplates.task_assigned(task, _actor_name(actor))
|
||||
_send_task_notification(
|
||||
recipient=task.assignee,
|
||||
verb=NotificationVerbs.TASK_ASSIGNED,
|
||||
task=task,
|
||||
title=title,
|
||||
description=description,
|
||||
actor=actor,
|
||||
extra_data={"assignee_id": task.assignee_id},
|
||||
)
|
||||
|
||||
|
||||
def notify_task_updated(task: Task, actor, previous: dict, current: dict):
|
||||
previous_assignee_id = previous.get("assignee_id")
|
||||
current_assignee_id = current.get("assignee_id")
|
||||
if previous_assignee_id != current_assignee_id:
|
||||
title, description = (
|
||||
MessageTemplates.task_assigned(task, _actor_name(actor))
|
||||
if previous_assignee_id is None
|
||||
else MessageTemplates.task_reassigned(task, _actor_name(actor))
|
||||
)
|
||||
recipients = []
|
||||
if current_assignee_id:
|
||||
recipients.extend(User.objects.filter(id=current_assignee_id))
|
||||
if previous_assignee_id:
|
||||
recipients.extend(User.objects.filter(id=previous_assignee_id))
|
||||
_notify_many(
|
||||
recipients,
|
||||
verb=(
|
||||
NotificationVerbs.TASK_ASSIGNED
|
||||
if previous_assignee_id is None
|
||||
else NotificationVerbs.TASK_REASSIGNED
|
||||
),
|
||||
task=task,
|
||||
title=title,
|
||||
description=description,
|
||||
actor=actor,
|
||||
extra_data={
|
||||
"old_assignee_id": previous_assignee_id,
|
||||
"assignee_id": current_assignee_id,
|
||||
},
|
||||
)
|
||||
|
||||
if previous.get("due_date") != current.get("due_date"):
|
||||
title, description = MessageTemplates.task_due_date_changed(
|
||||
task,
|
||||
_actor_name(actor),
|
||||
previous.get("due_date"),
|
||||
current.get("due_date"),
|
||||
)
|
||||
_notify_many(
|
||||
_task_recipients(task, include_commenters=True),
|
||||
verb=NotificationVerbs.TASK_DUE_DATE_CHANGED,
|
||||
task=task,
|
||||
title=title,
|
||||
description=description,
|
||||
actor=actor,
|
||||
extra_data={
|
||||
"old_due_date": previous.get("due_date"),
|
||||
"due_date": current.get("due_date"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def notify_task_moved(task: Task, actor, old_column, new_column):
|
||||
if old_column.id == new_column.id:
|
||||
return
|
||||
if new_column.is_done and not old_column.is_done:
|
||||
title, description = MessageTemplates.task_completed(
|
||||
task,
|
||||
_actor_name(actor),
|
||||
)
|
||||
verb = NotificationVerbs.TASK_COMPLETED
|
||||
elif old_column.is_done and not new_column.is_done:
|
||||
title, description = MessageTemplates.task_reopened(
|
||||
task,
|
||||
_actor_name(actor),
|
||||
)
|
||||
verb = NotificationVerbs.TASK_REOPENED
|
||||
else:
|
||||
return
|
||||
|
||||
_notify_many(
|
||||
_task_recipients(task, include_commenters=True),
|
||||
verb=verb,
|
||||
task=task,
|
||||
title=title,
|
||||
description=description,
|
||||
actor=actor,
|
||||
extra_data={
|
||||
"from_column_id": old_column.id,
|
||||
"from_column": old_column.name,
|
||||
"to_column_id": new_column.id,
|
||||
"to_column": new_column.name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def notify_task_comment(task: Task, message):
|
||||
actor = message.author
|
||||
title, description = MessageTemplates.task_comment(
|
||||
task,
|
||||
_actor_name(actor),
|
||||
message.content or "",
|
||||
)
|
||||
_notify_many(
|
||||
_task_recipients(task, include_commenters=True),
|
||||
verb=NotificationVerbs.TASK_COMMENT,
|
||||
task=task,
|
||||
title=title,
|
||||
description=description,
|
||||
actor=actor,
|
||||
action_object=message,
|
||||
extra_data={"comment_id": message.id},
|
||||
)
|
||||
|
||||
|
||||
def notify_task_linked_object(link: TaskLinkedObject):
|
||||
task = link.task
|
||||
actor = link.created_by
|
||||
object_type = link.get_kind_display()
|
||||
title, description = MessageTemplates.task_linked_object_added(
|
||||
task,
|
||||
_actor_name(actor),
|
||||
object_type,
|
||||
)
|
||||
recipient_ids = _task_recipient_ids(task, include_commenters=True)
|
||||
if link.kind == TaskLinkedObjectKind.EMPLOYEE:
|
||||
recipient_ids.add(int(link.object_id))
|
||||
recipients = User.objects.filter(id__in=recipient_ids, is_active=True)
|
||||
_notify_many(
|
||||
recipients,
|
||||
verb=NotificationVerbs.TASK_LINKED_OBJECT_ADDED,
|
||||
task=task,
|
||||
title=title,
|
||||
description=description,
|
||||
actor=actor,
|
||||
action_object=link,
|
||||
extra_data={
|
||||
"linked_object_id": link.id,
|
||||
"linked_object_kind": link.kind,
|
||||
"linked_object_type": object_type,
|
||||
"linked_object_pk": link.object_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def notify_board_members_added(board, actor, members):
|
||||
title, description = MessageTemplates.board_member_added(
|
||||
board,
|
||||
_actor_name(actor),
|
||||
)
|
||||
for member in members:
|
||||
if not member or not getattr(member, "is_active", False):
|
||||
continue
|
||||
if _actor_id(actor) and member.id == _actor_id(actor):
|
||||
continue
|
||||
notify.send(
|
||||
sender=actor,
|
||||
recipient=member,
|
||||
verb=NotificationVerbs.TASK_BOARD_MEMBER_ADDED,
|
||||
action_object=board,
|
||||
description=description,
|
||||
action_url=ActionURLs.board_detail(board),
|
||||
data={
|
||||
"title": title,
|
||||
"board_id": board.id,
|
||||
"board_name": board.name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _already_sent_today(*, recipient_id: int, verb: str, task: Task) -> bool:
|
||||
task_ct = ContentType.objects.get_for_model(Task)
|
||||
today = timezone.localdate()
|
||||
start = timezone.make_aware(
|
||||
datetime.combine(today, time.min)
|
||||
)
|
||||
end = start + timedelta(days=1)
|
||||
return Notification.objects.filter(
|
||||
recipient_id=recipient_id,
|
||||
verb=verb,
|
||||
target_content_type=task_ct,
|
||||
target_object_id=str(task.id),
|
||||
timestamp__gte=start,
|
||||
timestamp__lt=end,
|
||||
).exists()
|
||||
|
||||
|
||||
def dispatch_task_due_notifications() -> dict[str, int]:
|
||||
today = timezone.localdate()
|
||||
due_soon_count = 0
|
||||
overdue_count = 0
|
||||
queryset = (
|
||||
Task.objects.filter(
|
||||
due_date__isnull=False,
|
||||
assignee__isnull=False,
|
||||
completed_at__isnull=True,
|
||||
column__is_done=False,
|
||||
board__is_archived=False,
|
||||
)
|
||||
.select_related("board", "column", "assignee", "created_by")
|
||||
.order_by("id")
|
||||
)
|
||||
for task in queryset:
|
||||
if task.due_date < today:
|
||||
verb = NotificationVerbs.TASK_OVERDUE
|
||||
title, description = MessageTemplates.task_overdue(task)
|
||||
elif task.due_date == today:
|
||||
verb = NotificationVerbs.TASK_DUE_SOON
|
||||
title, description = MessageTemplates.task_due_soon(task)
|
||||
else:
|
||||
continue
|
||||
|
||||
if _already_sent_today(
|
||||
recipient_id=task.assignee_id,
|
||||
verb=verb,
|
||||
task=task,
|
||||
):
|
||||
continue
|
||||
_send_task_notification(
|
||||
recipient=task.assignee,
|
||||
verb=verb,
|
||||
task=task,
|
||||
title=title,
|
||||
description=description,
|
||||
actor=None,
|
||||
extra_data={"due_date": task.due_date.isoformat()},
|
||||
)
|
||||
if verb == NotificationVerbs.TASK_OVERDUE:
|
||||
overdue_count += 1
|
||||
else:
|
||||
due_soon_count += 1
|
||||
|
||||
return {"due_soon": due_soon_count, "overdue": overdue_count}
|
||||
22
backend/tasks/notifications/signals.py
Normal file
22
backend/tasks/notifications/signals.py
Normal file
@ -0,0 +1,22 @@
|
||||
import logging
|
||||
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from tasks.models import TaskLinkedObject
|
||||
from .handlers import notify_task_linked_object
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(post_save, sender=TaskLinkedObject)
|
||||
def notify_on_task_link_created(sender, instance, created, **kwargs):
|
||||
if not created:
|
||||
return
|
||||
try:
|
||||
notify_task_linked_object(instance)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to send task linked object notification for link %s",
|
||||
instance.id,
|
||||
)
|
||||
8
backend/tasks/tasks.py
Normal file
8
backend/tasks/tasks.py
Normal file
@ -0,0 +1,8 @@
|
||||
from celery import shared_task
|
||||
|
||||
from tasks.notifications.handlers import dispatch_task_due_notifications
|
||||
|
||||
|
||||
@shared_task(name="tasks.dispatch_task_due_notifications")
|
||||
def dispatch_task_due_notifications_task():
|
||||
return dispatch_task_due_notifications()
|
||||
224
backend/tests/api/v1/tasks/test_task_notifications.py
Normal file
224
backend/tests/api/v1/tasks/test_task_notifications.py
Normal file
@ -0,0 +1,224 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from notifications.models import Notification
|
||||
from tasks.models import Task, TaskBoard, TaskColumn
|
||||
from tasks.notifications.config import NotificationVerbs
|
||||
from tasks.notifications.handlers import dispatch_task_due_notifications
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
def make_user(email, phone_number, **extra):
|
||||
defaults = {
|
||||
"password": "testpass123",
|
||||
"phone_number": phone_number,
|
||||
"first_name": "Тест",
|
||||
"last_name": "Сотрудник",
|
||||
"is_active": True,
|
||||
"email_verified": True,
|
||||
"send_activation_email": False,
|
||||
}
|
||||
defaults.update(extra)
|
||||
return User.objects.create_user(email=email, **defaults)
|
||||
|
||||
|
||||
def make_board(owner, *, name="Доска уведомлений"):
|
||||
board = TaskBoard.objects.create(name=name, created_by=owner)
|
||||
column = TaskColumn.objects.create(
|
||||
board=board,
|
||||
name="Новые",
|
||||
position=1000,
|
||||
)
|
||||
done = TaskColumn.objects.create(
|
||||
board=board,
|
||||
name="Готово",
|
||||
position=2000,
|
||||
is_done=True,
|
||||
)
|
||||
return board, column, done
|
||||
|
||||
|
||||
def make_task(owner, column, **extra):
|
||||
return Task.objects.create(
|
||||
board=column.board,
|
||||
column=column,
|
||||
title=extra.pop("title", "Проверить уведомления"),
|
||||
created_by=owner,
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
def test_create_task_notifies_assignee(api_client):
|
||||
owner = make_user("task-notify-owner@example.com", "+79995550001")
|
||||
assignee = make_user("task-notify-assignee@example.com", "+79995550002")
|
||||
board, column, _done = make_board(owner)
|
||||
api_client.force_authenticate(user=owner)
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:v1:tasks:task-list"),
|
||||
{
|
||||
"board": board.id,
|
||||
"column": column.id,
|
||||
"title": "Назначенная задача",
|
||||
"assignee_id": assignee.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
notification = Notification.objects.get(
|
||||
recipient=assignee,
|
||||
verb=NotificationVerbs.TASK_ASSIGNED,
|
||||
)
|
||||
assert notification.target_object_id == str(response.data["id"])
|
||||
assert notification.data["task_id"] == response.data["id"]
|
||||
assert notification.action_url == (
|
||||
f"/tasks?board={board.id}&task={response.data['id']}"
|
||||
)
|
||||
assert not Notification.objects.filter(
|
||||
recipient=owner,
|
||||
verb=NotificationVerbs.TASK_ASSIGNED,
|
||||
).exists()
|
||||
|
||||
|
||||
def test_reassign_task_notifies_old_and_new_assignees(api_client):
|
||||
owner = make_user("task-reassign-owner@example.com", "+79995550003")
|
||||
old_assignee = make_user("task-reassign-old@example.com", "+79995550004")
|
||||
new_assignee = make_user("task-reassign-new@example.com", "+79995550005")
|
||||
board, column, _done = make_board(owner)
|
||||
task = make_task(owner, column, assignee=old_assignee)
|
||||
Notification.objects.all().delete()
|
||||
api_client.force_authenticate(user=owner)
|
||||
|
||||
response = api_client.patch(
|
||||
reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}),
|
||||
{"assignee_id": new_assignee.id},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
recipients = set(
|
||||
Notification.objects.filter(
|
||||
verb=NotificationVerbs.TASK_REASSIGNED,
|
||||
).values_list("recipient_id", flat=True)
|
||||
)
|
||||
assert recipients == {old_assignee.id, new_assignee.id}
|
||||
|
||||
|
||||
def test_task_comment_notifies_connected_users_except_author(api_client):
|
||||
owner = make_user("task-comment-owner@example.com", "+79995550006")
|
||||
assignee = make_user("task-comment-assignee@example.com", "+79995550007")
|
||||
board, column, _done = make_board(owner)
|
||||
task = make_task(owner, column, assignee=assignee)
|
||||
Notification.objects.all().delete()
|
||||
api_client.force_authenticate(user=assignee)
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}),
|
||||
{"text": "Нужно проверить детали"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
notification = Notification.objects.get(
|
||||
recipient=owner,
|
||||
verb=NotificationVerbs.TASK_COMMENT,
|
||||
)
|
||||
assert notification.data["comment_id"] == response.data["id"]
|
||||
assert not Notification.objects.filter(
|
||||
recipient=assignee,
|
||||
verb=NotificationVerbs.TASK_COMMENT,
|
||||
).exists()
|
||||
|
||||
|
||||
def test_board_member_added_notification(api_client):
|
||||
owner = make_user("task-board-notify-owner@example.com", "+79995550008")
|
||||
member = make_user("task-board-notify-member@example.com", "+79995550009")
|
||||
board, _column, _done = make_board(owner)
|
||||
api_client.force_authenticate(user=owner)
|
||||
|
||||
response = api_client.patch(
|
||||
reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id}),
|
||||
{"members": [member.id]},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
notification = Notification.objects.get(
|
||||
recipient=member,
|
||||
verb=NotificationVerbs.TASK_BOARD_MEMBER_ADDED,
|
||||
)
|
||||
assert notification.data["board_id"] == board.id
|
||||
assert notification.action_url == f"/tasks?board={board.id}"
|
||||
|
||||
|
||||
def test_link_employee_notifies_linked_employee(api_client):
|
||||
owner = make_user("task-link-notify-owner@example.com", "+79995550010")
|
||||
employee = make_user("task-link-notify-employee@example.com", "+79995550011")
|
||||
board, column, _done = make_board(owner)
|
||||
task = make_task(owner, column)
|
||||
api_client.force_authenticate(user=owner)
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:v1:tasks:task-linked-employees", kwargs={"pk": task.id}),
|
||||
{"employee_id": employee.id},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
notification = Notification.objects.get(
|
||||
recipient=employee,
|
||||
verb=NotificationVerbs.TASK_LINKED_OBJECT_ADDED,
|
||||
)
|
||||
assert notification.data["linked_object_kind"] == "employee"
|
||||
assert notification.data["task_id"] == task.id
|
||||
|
||||
|
||||
def test_due_notification_dispatches_once_per_day():
|
||||
owner = make_user("task-due-owner@example.com", "+79995550012")
|
||||
assignee = make_user("task-due-assignee@example.com", "+79995550013")
|
||||
board, column, _done = make_board(owner)
|
||||
today = timezone.localdate()
|
||||
make_task(
|
||||
owner,
|
||||
column,
|
||||
title="Срок сегодня",
|
||||
assignee=assignee,
|
||||
due_date=today,
|
||||
)
|
||||
make_task(
|
||||
owner,
|
||||
column,
|
||||
title="Просрочена",
|
||||
assignee=assignee,
|
||||
due_date=today - timedelta(days=1),
|
||||
)
|
||||
|
||||
first_result = dispatch_task_due_notifications()
|
||||
second_result = dispatch_task_due_notifications()
|
||||
|
||||
assert first_result == {"due_soon": 1, "overdue": 1}
|
||||
assert second_result == {"due_soon": 0, "overdue": 0}
|
||||
assert Notification.objects.filter(
|
||||
recipient=assignee,
|
||||
verb=NotificationVerbs.TASK_DUE_SOON,
|
||||
).count() == 1
|
||||
assert Notification.objects.filter(
|
||||
recipient=assignee,
|
||||
verb=NotificationVerbs.TASK_OVERDUE,
|
||||
).count() == 1
|
||||
@ -26,7 +26,9 @@ import {
|
||||
Kanban,
|
||||
Link2,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
MessageSquare,
|
||||
Minimize2,
|
||||
Newspaper,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
@ -108,6 +110,8 @@ type TaskBoardSocketEvent = {
|
||||
};
|
||||
};
|
||||
|
||||
const DESKTOP_WIDE_MODE_STORAGE_PREFIX = "tasks.desktop-wide-mode";
|
||||
|
||||
const emptyForm: TaskFormState = {
|
||||
id: null,
|
||||
title: "",
|
||||
@ -467,6 +471,7 @@ function BoardColumn({
|
||||
menuRef,
|
||||
onToggleTaskMenu,
|
||||
onColumnMount,
|
||||
fillAvailableHeight = false,
|
||||
}: {
|
||||
column: TaskColumn;
|
||||
tasks: TaskCard[];
|
||||
@ -478,6 +483,7 @@ function BoardColumn({
|
||||
menuRef: RefObject<HTMLDivElement | null>;
|
||||
onToggleTaskMenu: (taskId: number) => void;
|
||||
onColumnMount: (columnId: number, node: HTMLElement | null) => void;
|
||||
fillAvailableHeight?: boolean;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `column-${column.id}`,
|
||||
@ -491,7 +497,7 @@ function BoardColumn({
|
||||
return (
|
||||
<section
|
||||
ref={setColumnNodeRef}
|
||||
className={`flex max-h-[calc(100vh-14rem)] min-h-[28rem] min-w-[18rem] flex-col rounded-xl border bg-[var(--surface-muted)] transition ${
|
||||
className={`flex max-h-[calc(100vh-14rem)] min-h-[28rem] min-w-[18rem] flex-col rounded-xl border bg-[var(--surface-muted)] transition ${fillAvailableHeight ? "lg:h-full lg:min-h-0 lg:max-h-none" : ""} ${
|
||||
isOver ? "border-[var(--accent-primary)]" : "border-[var(--border-subtle)]"
|
||||
}`}
|
||||
>
|
||||
@ -542,12 +548,18 @@ function BoardColumn({
|
||||
);
|
||||
}
|
||||
|
||||
function AddColumnCard({ onClick }: { onClick: () => void }) {
|
||||
function AddColumnCard({
|
||||
onClick,
|
||||
fillAvailableHeight = false,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
fillAvailableHeight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group flex max-h-[calc(100vh-14rem)] min-h-[28rem] min-w-[18rem] flex-col items-center justify-center rounded-xl border border-dashed border-[var(--border-subtle)] bg-[var(--surface-muted)] p-4 text-center transition hover:border-[var(--accent-primary)] hover:bg-[var(--surface-elevated)]"
|
||||
className={`group flex max-h-[calc(100vh-14rem)] min-h-[28rem] min-w-[18rem] flex-col items-center justify-center rounded-xl border border-dashed border-[var(--border-subtle)] bg-[var(--surface-muted)] p-4 text-center transition hover:border-[var(--accent-primary)] hover:bg-[var(--surface-elevated)] ${fillAvailableHeight ? "lg:h-full lg:min-h-0 lg:max-h-none" : ""}`}
|
||||
>
|
||||
<span className="app-selected mb-3 flex h-10 w-10 items-center justify-center rounded-xl transition group-hover:scale-105">
|
||||
<Plus size={18} />
|
||||
@ -1680,6 +1692,7 @@ function TasksPageContent() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [onlyMine, setOnlyMine] = useState(false);
|
||||
const [desktopWideMode, setDesktopWideMode] = useState(false);
|
||||
const [selectedColumnTarget, setSelectedColumnTarget] = useState<number | "all">("all");
|
||||
const [activeTaskId, setActiveTaskId] = useState<number | null>(null);
|
||||
const [viewTaskId, setViewTaskId] = useState<number | null>(null);
|
||||
@ -1731,6 +1744,23 @@ function TasksPageContent() {
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
setDesktopWideMode(
|
||||
window.localStorage.getItem(`${DESKTOP_WIDE_MODE_STORAGE_PREFIX}.${user.id}`) === "1",
|
||||
);
|
||||
}, [user?.id]);
|
||||
|
||||
const changeDesktopWideMode = useCallback((enabled: boolean) => {
|
||||
setDesktopWideMode(enabled);
|
||||
if (user?.id) {
|
||||
window.localStorage.setItem(
|
||||
`${DESKTOP_WIDE_MODE_STORAGE_PREFIX}.${user.id}`,
|
||||
enabled ? "1" : "0",
|
||||
);
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
const loadBoards = useCallback(async () => {
|
||||
const response = await apiClient.getTaskBoards();
|
||||
const results = response.results || response || [];
|
||||
@ -2793,7 +2823,10 @@ function TasksPageContent() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell
|
||||
desktopWideMode={desktopWideMode}
|
||||
onDesktopWideModeChange={changeDesktopWideMode}
|
||||
>
|
||||
<section className="app-surface rounded-2xl p-8 text-center">
|
||||
<Loader2 size={28} className="mx-auto mb-3 animate-spin text-sky-500" />
|
||||
<p className="app-text-muted text-sm">Загрузка задач...</p>
|
||||
@ -2803,8 +2836,11 @@ function TasksPageContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-[1600px] space-y-4">
|
||||
<AppShell
|
||||
desktopWideMode={desktopWideMode}
|
||||
onDesktopWideModeChange={changeDesktopWideMode}
|
||||
>
|
||||
<div className={`${desktopWideMode ? "w-full lg:flex lg:min-h-0 lg:flex-1 lg:flex-col lg:gap-4 lg:space-y-0" : "mx-auto max-w-[1600px]"} space-y-4`}>
|
||||
<section className="app-surface rounded-2xl p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
@ -2814,6 +2850,16 @@ function TasksPageContent() {
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeDesktopWideMode(!desktopWideMode)}
|
||||
className="app-action-secondary hidden h-10 w-10 items-center justify-center rounded-lg lg:inline-flex"
|
||||
title={desktopWideMode ? "Вернуть обычный вид" : "Развернуть доску"}
|
||||
aria-label={desktopWideMode ? "Вернуть обычный вид" : "Развернуть доску"}
|
||||
aria-pressed={desktopWideMode}
|
||||
>
|
||||
{desktopWideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateBoard}
|
||||
@ -3045,8 +3091,11 @@ function TasksPageContent() {
|
||||
onDragCancel={handleDragCancel}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div ref={boardScrollRef} className="tasks-board-scroll overflow-x-auto pb-3">
|
||||
<div className="flex min-w-max gap-3">
|
||||
<div
|
||||
ref={boardScrollRef}
|
||||
className={`tasks-board-scroll overflow-x-auto pb-3 ${desktopWideMode ? "lg:min-h-0 lg:flex-1" : ""}`}
|
||||
>
|
||||
<div className={`flex min-w-max gap-3 ${desktopWideMode ? "lg:h-full" : ""}`}>
|
||||
{activeColumns.map((column) => (
|
||||
<BoardColumn
|
||||
key={column.id}
|
||||
@ -3060,9 +3109,13 @@ function TasksPageContent() {
|
||||
menuRef={cardMenuRef}
|
||||
onToggleTaskMenu={toggleCardTaskMenu}
|
||||
onColumnMount={registerColumnNode}
|
||||
fillAvailableHeight={desktopWideMode}
|
||||
/>
|
||||
))}
|
||||
<AddColumnCard onClick={openCreateColumn} />
|
||||
<AddColumnCard
|
||||
onClick={openCreateColumn}
|
||||
fillAvailableHeight={desktopWideMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay zIndex={1000} dropAnimation={null}>
|
||||
|
||||
@ -20,6 +20,8 @@ import type { GuestVisit, GuestVisitComment } from "@/types/api";
|
||||
|
||||
type AppShellProps = {
|
||||
children: ReactNode;
|
||||
desktopWideMode?: boolean;
|
||||
onDesktopWideModeChange?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
type PageHeaderProps = {
|
||||
@ -38,6 +40,7 @@ type HeaderProps = {
|
||||
|
||||
type LeftNavContentProps = {
|
||||
onNavigate?: () => void;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
type PaginatedResponse<T> = {
|
||||
@ -325,7 +328,7 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa
|
||||
);
|
||||
}
|
||||
|
||||
function LeftNavContent({ onNavigate }: LeftNavContentProps) {
|
||||
function LeftNavContent({ onNavigate, compact = false }: LeftNavContentProps) {
|
||||
const pathname = usePathname();
|
||||
const { user } = useUser();
|
||||
const { unreadCategoryCounts, markCategoryAsRead } = useNotifications();
|
||||
@ -348,13 +351,13 @@ function LeftNavContent({ onNavigate }: LeftNavContentProps) {
|
||||
};
|
||||
|
||||
const navLinkClass = (href: string) =>
|
||||
`flex items-center gap-3 rounded-lg px-3 py-2 transition ${pathname === href ? "app-selected" : "text-[var(--foreground)] hover:bg-[var(--surface-secondary)]"
|
||||
`relative flex items-center rounded-lg transition ${compact ? "h-10 w-10 justify-center p-0" : "gap-3 px-3 py-2"} ${pathname === href ? "app-selected" : "text-[var(--foreground)] hover:bg-[var(--surface-secondary)]"
|
||||
}`;
|
||||
|
||||
const navIconClass = (href: string) => (pathname === href ? "app-accent-text" : "app-text-muted");
|
||||
|
||||
return (
|
||||
<div className="app-surface rounded-2xl p-5">
|
||||
<div className={`app-surface rounded-2xl ${compact ? "p-2" : "p-5"}`}>
|
||||
<div className="space-y-2 text-sm">
|
||||
{visibleNavItems.map(({ href, label, icon: Icon, category, autoReadOnNavigate }) => {
|
||||
const count = category ? unreadCategoryCounts[category] || 0 : 0;
|
||||
@ -365,11 +368,13 @@ function LeftNavContent({ onNavigate }: LeftNavContentProps) {
|
||||
href={href}
|
||||
className={navLinkClass(href)}
|
||||
onClick={() => handleNavClick(category, autoReadOnNavigate)}
|
||||
title={compact ? label : undefined}
|
||||
aria-label={compact ? label : undefined}
|
||||
>
|
||||
<Icon size={18} className={navIconClass(href)} />
|
||||
<span className="flex-1">{label}</span>
|
||||
<span className={compact ? "sr-only" : "flex-1"}>{label}</span>
|
||||
{count > 0 && (
|
||||
<span className="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-sky-500 px-1.5 text-[10px] font-bold text-white">
|
||||
<span className={`flex items-center justify-center rounded-full bg-sky-500 text-[10px] font-bold text-white ${compact ? "absolute -right-1 -top-1 h-4 min-w-4 px-1" : "h-5 min-w-[20px] px-1.5"}`}>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
@ -381,17 +386,17 @@ function LeftNavContent({ onNavigate }: LeftNavContentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function LeftNav({ fixedDesktop = false }: { fixedDesktop?: boolean }) {
|
||||
function LeftNav({ fixedDesktop = false, compact = false }: { fixedDesktop?: boolean; compact?: boolean }) {
|
||||
return (
|
||||
<aside className="hidden w-64 flex-shrink-0 lg:block">
|
||||
<aside className={`hidden flex-shrink-0 transition-[width] duration-200 lg:block ${compact ? "w-14" : "w-64"}`}>
|
||||
<div
|
||||
className={`space-y-4 lg:overflow-y-auto ${
|
||||
fixedDesktop
|
||||
? "lg:fixed lg:top-0 lg:bottom-0 lg:w-64 lg:pt-[4.5rem] lg:pb-8"
|
||||
? `lg:fixed lg:top-0 lg:bottom-0 lg:pt-[4.5rem] lg:pb-8 ${compact ? "lg:w-14" : "lg:w-64"}`
|
||||
: "lg:sticky lg:top-8 lg:max-h-[calc(100dvh-7.5rem)] lg:pb-2"
|
||||
}`}
|
||||
>
|
||||
<LeftNavContent />
|
||||
<LeftNavContent compact={compact} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
@ -559,7 +564,7 @@ function GuestInfoRequestPrompt({ userId }: { userId?: number | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
export function AppShell({ children, desktopWideMode = false, onDesktopWideModeChange }: AppShellProps) {
|
||||
const { user, loading } = useUser();
|
||||
const { mobileNavPlacement } = useMobileNavPlacement();
|
||||
const router = useRouter();
|
||||
@ -640,16 +645,16 @@ export function AppShell({ children }: AppShellProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden lg:pt-14' : 'min-h-screen pt-10 lg:pt-14'} app-shell flex flex-col ${mobileNavPlacement === "bottom" && !isMessageDialogPage ? "pb-[calc(env(safe-area-inset-bottom)+3.75rem)] lg:pb-0" : ""}`}>
|
||||
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden lg:pt-14' : 'min-h-screen pt-10 lg:pt-14'} ${desktopWideMode ? "lg:h-[100dvh] lg:overflow-hidden" : ""} app-shell flex flex-col ${mobileNavPlacement === "bottom" && !isMessageDialogPage ? "pb-[calc(env(safe-area-inset-bottom)+3.75rem)] lg:pb-0" : ""}`}>
|
||||
<Header
|
||||
mobileNavPlacement={mobileNavPlacement}
|
||||
suppressMobileChrome={isMessageDialogPage}
|
||||
onOpenLeftNav={() => setIsMobileLeftNavOpen(true)}
|
||||
onOpenCalendar={() => setIsMobileCalendarOpen(true)}
|
||||
/>
|
||||
<div className={`mx-auto flex w-full flex-1 min-h-0 max-w-6xl ${isMessageDialogPage ? 'gap-0 px-0 py-0 lg:gap-6 lg:px-8 lg:py-4' : 'gap-6 px-4 py-4 sm:px-8 lg:py-4'}`}>
|
||||
<LeftNav fixedDesktop />
|
||||
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${isMessageDialogPage ? 'overflow-visible' : ''}`}>
|
||||
<div className={`mx-auto flex w-full flex-1 min-h-0 max-w-6xl ${desktopWideMode ? "lg:max-w-none" : ""} ${isMessageDialogPage ? 'gap-0 px-0 py-0 lg:gap-6 lg:px-8 lg:py-4' : `gap-6 px-4 py-4 sm:px-8 lg:py-4 ${desktopWideMode ? "lg:gap-3 lg:px-3" : ""}`}`}>
|
||||
<LeftNav fixedDesktop compact={desktopWideMode} />
|
||||
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${isMessageDialogPage ? 'overflow-visible' : ''} ${desktopWideMode ? "lg:flex lg:flex-col lg:gap-4 lg:space-y-0 lg:overflow-hidden" : ""}`}>
|
||||
{!isMessageDialogPage ? <PushOnboardingPrompt /> : null}
|
||||
{children}
|
||||
</main>
|
||||
@ -662,6 +667,8 @@ export function AppShell({ children }: AppShellProps) {
|
||||
setSidebarEvents={cal.handleSetSidebarEvents}
|
||||
onCalendarChange={cal.handleCalendarChange}
|
||||
fixedDesktop
|
||||
compact={desktopWideMode}
|
||||
onExpand={() => onDesktopWideModeChange?.(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { CalendarCard } from "@/components/calendar/CalendarCard";
|
||||
import { type CalendarParticipantsTarget } from "@/lib/calendar/ui";
|
||||
@ -14,13 +15,47 @@ interface CalendarSidebarProps {
|
||||
setSidebarEvents: (events: CalendarEvent[]) => void;
|
||||
onCalendarChange: (calendarId: number | null) => void;
|
||||
fixedDesktop?: boolean;
|
||||
compact?: boolean;
|
||||
onExpand?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обертка для календаря в правом сайдбаре (десктоп)
|
||||
*/
|
||||
export const CalendarSidebar = memo(function CalendarSidebar(props: CalendarSidebarProps) {
|
||||
const { fixedDesktop = false, ...calendarProps } = props;
|
||||
const {
|
||||
fixedDesktop = false,
|
||||
compact = false,
|
||||
onExpand,
|
||||
...calendarProps
|
||||
} = props;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<aside className="hidden w-14 flex-shrink-0 transition-[width] duration-200 lg:block">
|
||||
<div
|
||||
className={`lg:w-14 ${
|
||||
fixedDesktop
|
||||
? "lg:fixed lg:top-0 lg:bottom-0 lg:pt-[4.5rem] lg:pb-8"
|
||||
: "lg:sticky lg:top-8 lg:max-h-[calc(100dvh-7.5rem)] lg:pb-2"
|
||||
}`}
|
||||
>
|
||||
<div className="app-surface flex justify-center rounded-2xl p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
className="app-icon-button flex h-10 w-10 items-center justify-center rounded-lg"
|
||||
title="Развернуть календарь"
|
||||
aria-label="Развернуть календарь"
|
||||
>
|
||||
<CalendarDays size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden w-72 flex-shrink-0 space-y-4 lg:block">
|
||||
<div
|
||||
|
||||
@ -87,6 +87,18 @@ export const VERB_CATEGORIES: Record<string, string> = {
|
||||
feed_post_comment: 'Новости',
|
||||
feed_post_reaction: 'Новости',
|
||||
|
||||
// Доска
|
||||
task_assigned: 'Доска',
|
||||
task_reassigned: 'Доска',
|
||||
task_comment: 'Доска',
|
||||
task_due_date_changed: 'Доска',
|
||||
task_due_soon: 'Доска',
|
||||
task_overdue: 'Доска',
|
||||
task_completed: 'Доска',
|
||||
task_reopened: 'Доска',
|
||||
task_linked_object_added: 'Доска',
|
||||
task_board_member_added: 'Доска',
|
||||
|
||||
// Отделы
|
||||
department_new_employee: 'Отдел',
|
||||
department_employee_left: 'Отдел',
|
||||
@ -193,6 +205,18 @@ export const VERB_NAMES: Record<string, string> = {
|
||||
feed_post_comment: 'Комментарий к публикации',
|
||||
feed_post_reaction: 'Реакция на публикацию',
|
||||
|
||||
// Доска
|
||||
task_assigned: 'Вам назначена задача',
|
||||
task_reassigned: 'Исполнитель задачи изменён',
|
||||
task_comment: 'Комментарий к задаче',
|
||||
task_due_date_changed: 'Срок задачи изменён',
|
||||
task_due_soon: 'Срок задачи сегодня',
|
||||
task_overdue: 'Задача просрочена',
|
||||
task_completed: 'Задача завершена',
|
||||
task_reopened: 'Задача возвращена в работу',
|
||||
task_linked_object_added: 'К задаче добавлен связанный объект',
|
||||
task_board_member_added: 'Вас добавили на доску',
|
||||
|
||||
// Отделы
|
||||
department_new_employee: 'Новый сотрудник в отделе',
|
||||
department_employee_left: 'Сотрудник покинул отдел',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user