feat(tasks): add notifications and wide board mode

This commit is contained in:
lizerk 2026-07-16 17:22:43 +03:00
parent 786987efe5
commit b9598c2731
13 changed files with 941 additions and 24 deletions

View File

@ -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",

View File

@ -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

View File

@ -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

View 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",
]

View 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}".',
)

View 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}

View 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
View 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()

View 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

View File

@ -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}>

View File

@ -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>

View File

@ -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

View File

@ -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: 'Сотрудник покинул отдел',