From 87a9a6fd08f4b89563dd4c9c1a198b1a807678f4 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 24 Aug 2026 12:51:58 +0300 Subject: [PATCH] Implement application updates and supporting changes --- backend/api/v1/schedule/permissions.py | 4 + .../schedule_serializers/calendars.py | 14 + backend/api/v1/schedule/viewsets/calendars.py | 108 +++++ backend/api/v1/tasks/serializers/boards.py | 68 ++- backend/api/v1/tasks/viewsets/board_groups.py | 306 +++++++++++- .../v1/tasks/viewsets/board_mixins/actions.py | 63 ++- backend/api/v1/tasks/viewsets/boards.py | 12 +- ...4_calendarbinding_notification_settings.py | 34 ++ backend/scheduling/models.py | 12 + backend/scheduling/notifications/handlers.py | 15 + backend/scheduling/services.py | 17 + backend/tasks/admin.py | 12 +- backend/tasks/group_ranking.py | 151 ++++++ .../0038_taskboardgroupmembership.py | 139 ++++++ .../0039_taskboardgroupmembership_rank_key.py | 82 ++++ backend/tasks/models.py | 36 ++ backend/tests/api/v1/tasks/test_tasks_api.py | 339 +++++++++++++ backend/tests/test_schedule_permissions.py | 112 +++++ frontend/src/app/guests/page.tsx | 252 +++++----- frontend/src/app/procurement/page.tsx | 167 +++---- frontend/src/app/tasks/page.tsx | 350 +++++++++++++- frontend/src/components/AvatarCropper.tsx | 2 +- frontend/src/components/BigCalendar.tsx | 55 ++- .../CalendarNotificationSettingsModal.tsx | 165 +++++++ .../components/CalendarParticipantsModal.tsx | 449 ++++++++---------- frontend/src/components/EventModal.tsx | 55 ++- .../src/components/calendar/CalendarCard.tsx | 37 +- .../src/components/layout/CalendarModals.tsx | 1 + .../components/tasks/TaskBoardNavigator.tsx | 426 ++++++++++++++--- .../src/components/tasks/TaskLinkPill.tsx | 2 +- .../components/ui/AutoRevealStickyHeader.tsx | 5 +- frontend/src/hooks/useCalendarModals.ts | 4 - frontend/src/lib/api/calendar.ts | 8 + frontend/src/lib/api/tasks.ts | 24 +- frontend/src/lib/calendar/ui.ts | 4 + frontend/src/types/api.ts | 14 + 36 files changed, 2920 insertions(+), 624 deletions(-) create mode 100644 backend/scheduling/migrations/0004_calendarbinding_notification_settings.py create mode 100644 backend/tasks/group_ranking.py create mode 100644 backend/tasks/migrations/0038_taskboardgroupmembership.py create mode 100644 backend/tasks/migrations/0039_taskboardgroupmembership_rank_key.py create mode 100644 frontend/src/components/CalendarNotificationSettingsModal.tsx diff --git a/backend/api/v1/schedule/permissions.py b/backend/api/v1/schedule/permissions.py index feda8f30..05ac6699 100644 --- a/backend/api/v1/schedule/permissions.py +++ b/backend/api/v1/schedule/permissions.py @@ -9,6 +9,7 @@ from scheduling.services import ( user_can_create_event, user_can_edit_calendar, user_can_edit_event, + user_can_manage_calendar_notifications, user_can_view_event, user_can_view_calendar, ) @@ -26,6 +27,9 @@ class IsOwnerOfCalendar(permissions.BasePermission): if request.method in permissions.SAFE_METHODS: return user_can_view_calendar(request.user, obj) + if getattr(view, "action", None) == "notification_settings": + return user_can_manage_calendar_notifications(request.user, obj) + # Только owner может изменять/удалять календарь return user_can_edit_calendar(request.user, obj) diff --git a/backend/api/v1/schedule/schedule_serializers/calendars.py b/backend/api/v1/schedule/schedule_serializers/calendars.py index 887c3912..c9de897c 100644 --- a/backend/api/v1/schedule/schedule_serializers/calendars.py +++ b/backend/api/v1/schedule/schedule_serializers/calendars.py @@ -17,6 +17,7 @@ from scheduling.services import ( is_company_wide_calendar, user_can_create_event, user_can_edit_calendar, + user_can_manage_calendar_notifications, user_can_manage_participants, ) @@ -73,6 +74,11 @@ class CalendarRelationSerializer(serializers.ModelSerializer): raise serializers.ValidationError("Выберите активного сотрудника.") return value + def validate_distinction(self, value): + if value not in {"owner", "editor", "viewer"}: + raise serializers.ValidationError("Выберите роль владельца, редактора или просмотра.") + return value + def create(self, validated_data): """Создать связь календаря с пользователем.""" from django.contrib.auth import get_user_model @@ -116,6 +122,7 @@ class CalendarSerializer(serializers.ModelSerializer): can_create_events = serializers.SerializerMethodField() can_edit_calendar = serializers.SerializerMethodField() can_manage_participants = serializers.SerializerMethodField() + can_manage_notification_settings = serializers.SerializerMethodField() class Meta: model = Calendar @@ -136,6 +143,7 @@ class CalendarSerializer(serializers.ModelSerializer): "can_create_events", "can_edit_calendar", "can_manage_participants", + "can_manage_notification_settings", ] read_only_fields = ["id", "owner", "participants", "user_role"] extra_kwargs = { @@ -277,6 +285,12 @@ class CalendarSerializer(serializers.ModelSerializer): user = getattr(request, "user", None) return user_can_manage_participants(user, obj) if user else False + @extend_schema_field(serializers.BooleanField()) + def get_can_manage_notification_settings(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + return user_can_manage_calendar_notifications(user, obj) if user else False + def create(self, validated_data): """Создать календарь с автогенерацией slug из name/title.""" from django.utils.text import slugify diff --git a/backend/api/v1/schedule/viewsets/calendars.py b/backend/api/v1/schedule/viewsets/calendars.py index 147ee144..d8d793c9 100644 --- a/backend/api/v1/schedule/viewsets/calendars.py +++ b/backend/api/v1/schedule/viewsets/calendars.py @@ -27,6 +27,7 @@ from scheduling.models import CalendarBinding from scheduling.services import ( get_calendar_binding, user_can_edit_calendar, + user_can_manage_calendar_notifications, user_can_manage_participants, ) @@ -68,6 +69,12 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet): renderer_classes = [JSONRenderer] pagination_class = None # Отключаем пагинацию - календарей немного, нужны все + NOTIFICATION_FIELDS = ( + "notify_event_created", + "notify_event_changed", + "notify_event_cancelled", + ) + def get_queryset(self): """Фильтрация календарей по доступу пользователя.""" qs = ( @@ -152,6 +159,47 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet): calendar.refresh_from_db() return Response(self.get_serializer(calendar).data) + @action( + detail=True, + methods=["get", "patch"], + url_path="notification-settings", + ) + def notification_settings(self, request, pk=None): + """Получить или изменить общие правила уведомлений календаря.""" + calendar = self.get_object() + binding, _ = CalendarBinding.objects.get_or_create( + calendar=calendar, + defaults={"type": CalendarBinding.BindingType.DEFAULT}, + ) + can_manage = user_can_manage_calendar_notifications(request.user, calendar) + + if request.method == "PATCH": + if not can_manage: + return Response( + {"detail": "Недостаточно прав для настройки уведомлений."}, + status=status.HTTP_403_FORBIDDEN, + ) + + changed_fields = [] + for field in self.NOTIFICATION_FIELDS: + if field not in request.data: + continue + value = request.data[field] + if not isinstance(value, bool): + return Response( + {field: "Значение должно быть логическим."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if getattr(binding, field) != value: + setattr(binding, field, value) + changed_fields.append(field) + if changed_fields: + binding.save(update_fields=[*changed_fields, "updated_at"]) + + payload = {field: getattr(binding, field) for field in self.NOTIFICATION_FIELDS} + payload["can_manage"] = can_manage + return Response(payload) + @action(detail=True, methods=["post"], url_path="add-participant") def add_participant(self, request, pk=None): """Добавить участника к календарю. @@ -254,6 +302,66 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet): relation.delete() return Response(status=status.HTTP_204_NO_CONTENT) + @action( + detail=True, + methods=["patch"], + url_path="participant-role/(?P[0-9]+)", + ) + def participant_role(self, request, pk=None, user_id=None): + """Изменить роль существующего участника календаря.""" + calendar = self.get_object() + binding = get_calendar_binding(calendar) + if binding and binding.type == CalendarBinding.BindingType.DEPARTMENT: + return Response( + {"detail": "Роли календаря отдела синхронизируются автоматически."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not user_can_manage_participants(request.user, calendar): + return Response( + {"detail": "Только владелец может изменять роли участников."}, + status=status.HTTP_403_FORBIDDEN, + ) + + role = request.data.get("role") + if role not in {"owner", "editor", "viewer"}: + return Response( + {"role": "Выберите роль владельца, редактора или просмотра."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + from django.contrib.auth import get_user_model + + user_ct = ContentType.objects.get_for_model(get_user_model()) + relations = CalendarRelation.objects.filter( + calendar=calendar, + content_type=user_ct, + object_id=user_id, + ).order_by("id") + relation = relations.first() + if relation is None: + return Response( + {"detail": "Участник не найден."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if relation.distinction == "owner" and role != "owner": + other_owners_exist = CalendarRelation.objects.filter( + calendar=calendar, + content_type=user_ct, + distinction="owner", + ).exclude(object_id=user_id).exists() + if not other_owners_exist: + return Response( + {"role": "У календаря должен остаться хотя бы один владелец."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + relation.distinction = role + relation.inheritable = True + relation.save(update_fields=["distinction", "inheritable"]) + relations.exclude(pk=relation.pk).delete() + return Response(CalendarRelationSerializer(relation).data) + @action(detail=True, methods=["get"]) def participants(self, request, pk=None): """Получить список всех участников календаря.""" diff --git a/backend/api/v1/tasks/serializers/boards.py b/backend/api/v1/tasks/serializers/boards.py index 7852f98e..b92f3ef5 100644 --- a/backend/api/v1/tasks/serializers/boards.py +++ b/backend/api/v1/tasks/serializers/boards.py @@ -6,10 +6,12 @@ from rest_framework import serializers from api.v1.employees.serializers import EmployeeBriefSerializer from api.v1.employees.serializers.department import DepartmentBriefSerializer from employees.models import Department +from tasks.group_ranking import visible_group_position_map from tasks.models import ( TaskBoard, TaskBoardAccessScope, TaskBoardGroup, + TaskBoardGroupMembership, TaskBoardPlacement, TaskColumnRow, TaskLabel, @@ -56,6 +58,8 @@ class TaskBoardSerializer(serializers.ModelSerializer): is_default_for_current_user = serializers.SerializerMethodField() group_ids = serializers.SerializerMethodField() group_id = serializers.SerializerMethodField() + group_positions = serializers.SerializerMethodField() + group_sort_keys = serializers.SerializerMethodField() is_pinned = serializers.BooleanField( source="current_user_is_pinned", read_only=True, @@ -90,6 +94,8 @@ class TaskBoardSerializer(serializers.ModelSerializer): "is_default_for_current_user", "group_ids", "group_id", + "group_positions", + "group_sort_keys", "is_pinned", "notifications_enabled", "view_state", @@ -105,27 +111,67 @@ class TaskBoardSerializer(serializers.ModelSerializer): self._global_labels_cache = labels return TaskLabelSerializer(labels, many=True).data - @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) - def get_group_ids(self, obj): - groups = getattr(obj, "current_user_groups", None) - if groups is None: + def _get_group_memberships(self, obj): + memberships = getattr(obj, "current_user_group_memberships", None) + if memberships is None: request = self.context.get("request") user = getattr(request, "user", None) if not user or not user.is_authenticated: return [] - groups = TaskBoardGroup.objects.filter( - Q(user=user) | Q(is_shared=True), - boards=obj, - ).exclude( - hidden_by=user, + memberships = list( + TaskBoardGroupMembership.objects.filter( + board=obj, + ).filter( + Q(group__user=user) | Q(group__is_shared=True), + ).exclude( + group__hidden_by=user, + ).select_related("group") ) - return sorted(group.id for group in groups) + return memberships + + @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) + def get_group_ids(self, obj): + return sorted( + membership.group_id + for membership in self._get_group_memberships(obj) + ) @extend_schema_field(serializers.IntegerField(allow_null=True)) def get_group_id(self, obj): group_ids = self.get_group_ids(obj) return group_ids[0] if group_ids else None + @extend_schema_field( + serializers.DictField(child=serializers.IntegerField(min_value=1)) + ) + def get_group_positions(self, obj): + position_map = getattr(self, "_group_position_map_cache", None) + if position_map is None: + request = self.context.get("request") + user = getattr(request, "user", None) + position_map = ( + visible_group_position_map(user) + if user and user.is_authenticated + else {} + ) + self._group_position_map_cache = position_map + return { + str(membership.group_id): position_map[ + (membership.group_id, obj.id) + ] + for membership in self._get_group_memberships(obj) + if (membership.group_id, obj.id) in position_map + } + + @extend_schema_field( + serializers.DictField(child=serializers.CharField()) + ) + def get_group_sort_keys(self, obj): + return { + str(membership.group_id): membership.rank_key + for membership in self._get_group_memberships(obj) + } + def validate(self, attrs): attrs = super().validate(attrs) access_scope = attrs.get("access_scope") @@ -250,6 +296,8 @@ class TaskBoardSummarySerializer(TaskBoardSerializer): "is_default_for_current_user", "group_ids", "group_id", + "group_positions", + "group_sort_keys", "is_pinned", "notifications_enabled", "created_at", diff --git a/backend/api/v1/tasks/viewsets/board_groups.py b/backend/api/v1/tasks/viewsets/board_groups.py index a355e11f..c33e30dc 100644 --- a/backend/api/v1/tasks/viewsets/board_groups.py +++ b/backend/api/v1/tasks/viewsets/board_groups.py @@ -1,21 +1,31 @@ """Board and board-group HTTP adapters for the tasks API.""" from django.contrib.auth import get_user_model +from django.db import transaction from django.db.models import ( Count, Max, Prefetch, Q, ) -from rest_framework import viewsets +from rest_framework import status, viewsets from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from tasks.access import task_board_access_q, task_board_group_access_q +from tasks.group_ranking import ( + RankSpaceExhausted, + allocate_group_tail_rank_keys, + rank_between, + rebalance_group_memberships, + visible_group_position_map, +) from tasks.models import ( TaskBoard, TaskBoardGroup, + TaskBoardGroupMembership, TaskUserSettings, ) @@ -26,6 +36,99 @@ from ..serializers import ( Employee = get_user_model() +def _parse_reorder_board_ids(data): + try: + board_id = int(data.get("board")) + except (TypeError, ValueError) as error: + raise ValidationError( + {"board": ["Укажите идентификатор перемещаемой доски."]} + ) from error + + neighbours = {} + for field_name in ("previous_board", "next_board"): + raw_value = data.get(field_name) + if raw_value in (None, ""): + neighbours[field_name] = None + continue + try: + neighbours[field_name] = int(raw_value) + except (TypeError, ValueError) as error: + raise ValidationError( + {field_name: ["Идентификатор соседней доски должен быть числом."]} + ) from error + + previous_board_id = neighbours["previous_board"] + next_board_id = neighbours["next_board"] + if board_id in (previous_board_id, next_board_id): + raise ValidationError({"board": ["Доска не может быть соседней сама себе."]}) + if previous_board_id is not None and previous_board_id == next_board_id: + raise ValidationError( + {"next_board": ["Предыдущая и следующая доски должны отличаться."]} + ) + return board_id, previous_board_id, next_board_id + + +def _visible_neighbours_are_current( + visible_board_ids, + previous_board_id, + next_board_id, +): + if previous_board_id is None: + return (next_board_id is None and not visible_board_ids) or ( + next_board_id is not None + and bool(visible_board_ids) + and visible_board_ids[0] == next_board_id + ) + + previous_index = visible_board_ids.index(previous_board_id) + return ( + next_board_id is None and previous_index == len(visible_board_ids) - 1 + ) or ( + next_board_id is not None + and previous_index + 1 < len(visible_board_ids) + and visible_board_ids[previous_index + 1] == next_board_id + ) + + +def _membership_rank_bounds( + memberships, + moving_board_id, + previous_board_id, + next_board_id, +): + memberships_without_moving = [ + membership + for membership in memberships + if membership.board_id != moving_board_id + ] + if previous_board_id is not None: + insertion_index = next( + index + 1 + for index, membership in enumerate(memberships_without_moving) + if membership.board_id == previous_board_id + ) + elif next_board_id is not None: + insertion_index = next( + index + for index, membership in enumerate(memberships_without_moving) + if membership.board_id == next_board_id + ) + else: + insertion_index = 0 + + lower_key = ( + memberships_without_moving[insertion_index - 1].rank_key + if insertion_index > 0 + else None + ) + upper_key = ( + memberships_without_moving[insertion_index].rank_key + if insertion_index < len(memberships_without_moving) + else None + ) + return lower_key, upper_key + + class TaskBoardGroupViewSet(viewsets.ModelViewSet): queryset = TaskBoardGroup.objects.none() permission_classes = [IsAuthenticated] @@ -61,6 +164,207 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): ) serializer.save(user=self.request.user, position=max_position + 1000) + @action(detail=True, methods=["post"], url_path="boards") + def set_boards(self, request, pk=None): + """Replace the boards visible to this user in one group atomically.""" + group = self.get_object() + raw_board_ids = request.data.get("boards", []) + if not isinstance(raw_board_ids, list): + return Response( + {"boards": ["Передайте список идентификаторов досок."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + board_ids = list( + dict.fromkeys(int(board_id) for board_id in raw_board_ids) + ) + except (TypeError, ValueError): + return Response( + {"boards": ["Идентификаторы досок должны быть числами."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + accessible_boards = TaskBoard.objects.filter( + task_board_access_q(request.user) + ).distinct() + selected_board_ids = set( + accessible_boards.filter(id__in=board_ids).values_list("id", flat=True) + ) + if len(selected_board_ids) != len(board_ids): + return Response( + {"boards": ["Одна или несколько досок не найдены."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + with transaction.atomic(): + locked_group = TaskBoardGroup.objects.select_for_update().get(pk=group.pk) + + # A participant of a shared group can only change boards available to + # them. Memberships of inaccessible boards remain untouched. + TaskBoardGroupMembership.objects.filter( + group=locked_group, + board__in=accessible_boards, + ).exclude(board_id__in=selected_board_ids).delete() + + existing_board_ids = set( + TaskBoardGroupMembership.objects.filter( + group=locked_group, + board_id__in=selected_board_ids, + ).values_list("board_id", flat=True) + ) + new_board_ids = [ + board_id + for board_id in board_ids + if board_id not in existing_board_ids + ] + new_rank_keys = allocate_group_tail_rank_keys( + locked_group, + len(new_board_ids), + ) + memberships_to_create = [ + TaskBoardGroupMembership( + group=locked_group, + board_id=board_id, + rank_key=rank_key, + ) + for board_id, rank_key in zip( + new_board_ids, + new_rank_keys, + strict=True, + ) + ] + if memberships_to_create: + TaskBoardGroupMembership.objects.bulk_create(memberships_to_create) + + rank_keys = dict( + TaskBoardGroupMembership.objects.filter( + group=locked_group, + board_id__in=selected_board_ids, + ).values_list("board_id", "rank_key") + ) + + positions = visible_group_position_map(request.user, [locked_group.id]) + + return Response( + { + "group": locked_group.id, + "board_ids": board_ids, + "positions": { + str(board_id): positions[(locked_group.id, board_id)] + for board_id in board_ids + }, + "rank_keys": { + str(board_id): rank_keys[board_id] + for board_id in board_ids + }, + } + ) + + @action(detail=True, methods=["post"], url_path="reorder-board") + def reorder_board(self, request, pk=None): + """Move one accessible board between its visible neighbours.""" + group = self.get_object() + board_id, previous_board_id, next_board_id = _parse_reorder_board_ids( + request.data + ) + + accessible_boards = TaskBoard.objects.filter( + task_board_access_q(request.user) + ).distinct() + accessible_board_ids = set( + accessible_boards.filter(board_groups=group).values_list("id", flat=True) + ) + requested_board_ids = { + item + for item in (board_id, previous_board_id, next_board_id) + if item is not None + } + if not requested_board_ids.issubset(accessible_board_ids): + return Response( + {"board": ["Одна или несколько досок не найдены в этой группе."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + with transaction.atomic(): + locked_group = TaskBoardGroup.objects.select_for_update().get(pk=group.pk) + memberships = list( + TaskBoardGroupMembership.objects.select_for_update() + .filter(group=locked_group) + .order_by("rank_key", "id") + ) + memberships_by_board = { + membership.board_id: membership for membership in memberships + } + if not requested_board_ids.issubset(memberships_by_board): + return Response( + {"board": ["Порядок досок изменился. Обновите список и повторите перенос."]}, + status=status.HTTP_409_CONFLICT, + ) + moving_membership = memberships_by_board[board_id] + + visible_without_moving = [ + membership.board_id + for membership in memberships + if membership.board_id in accessible_board_ids + and membership.board_id != board_id + ] + if not _visible_neighbours_are_current( + visible_without_moving, + previous_board_id, + next_board_id, + ): + return Response( + {"board": ["Порядок досок изменился. Обновите список и повторите перенос."]}, + status=status.HTTP_409_CONFLICT, + ) + + lower_key, upper_key = _membership_rank_bounds( + memberships, + board_id, + previous_board_id, + next_board_id, + ) + try: + new_rank_key = rank_between(lower_key, upper_key) + except RankSpaceExhausted: + rebalance_group_memberships(locked_group, memberships) + lower_key, upper_key = _membership_rank_bounds( + memberships, + board_id, + previous_board_id, + next_board_id, + ) + new_rank_key = rank_between(lower_key, upper_key) + + moving_membership.rank_key = new_rank_key + moving_membership.save(update_fields=["rank_key"]) + + accessible_rank_keys = dict( + TaskBoardGroupMembership.objects.filter( + group=locked_group, + board_id__in=accessible_board_ids, + ).values_list("board_id", "rank_key") + ) + positions = visible_group_position_map(request.user, [locked_group.id]) + + return Response( + { + "group": locked_group.id, + "board": board_id, + "positions": { + str(accessible_board_id): positions[ + (locked_group.id, accessible_board_id) + ] + for accessible_board_id in accessible_rank_keys + }, + "rank_keys": { + str(accessible_board_id): rank_key + for accessible_board_id, rank_key in accessible_rank_keys.items() + }, + } + ) + @action(detail=True, methods=["post"], url_path="set-default") def set_default(self, request, pk=None): group = self.get_object() diff --git a/backend/api/v1/tasks/viewsets/board_mixins/actions.py b/backend/api/v1/tasks/viewsets/board_mixins/actions.py index dac57a15..b61dab6b 100644 --- a/backend/api/v1/tasks/viewsets/board_mixins/actions.py +++ b/backend/api/v1/tasks/viewsets/board_mixins/actions.py @@ -2,9 +2,7 @@ from django.contrib.auth import get_user_model from django.db import transaction -from django.db.models import ( - Q, -) +from django.db.models import Q from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import status @@ -12,9 +10,14 @@ from rest_framework.decorators import action from rest_framework.response import Response from tasks.access import task_board_group_access_q +from tasks.group_ranking import ( + allocate_group_tail_rank_keys, + visible_group_position_map, +) from tasks.models import ( TaskBoard, TaskBoardGroup, + TaskBoardGroupMembership, TaskBoardPlacement, TaskColumn, TaskColumnRow, @@ -148,22 +151,56 @@ class TaskBoardActionsMixin: desired_ids = {group.id for group in groups} removable_groups = list(editable_groups.exclude(id__in=desired_ids)) if removable_groups: - board.board_groups.remove(*removable_groups) - if groups: - board.board_groups.add(*groups) - group_ids = sorted( - TaskBoardGroup.objects.filter( - Q(user=self.request.user) | Q(is_shared=True), - boards=board, + TaskBoardGroupMembership.objects.filter( + board=board, + group__in=removable_groups, + ).delete() + + locked_groups = list( + TaskBoardGroup.objects.select_for_update() + .filter(id__in=desired_ids) + .order_by("id") ) - .exclude(hidden_by=self.request.user) - .values_list("id", flat=True) - .distinct() + existing_group_ids = set( + TaskBoardGroupMembership.objects.filter( + board=board, + group_id__in=desired_ids, + ).values_list("group_id", flat=True) + ) + for group in locked_groups: + if group.id in existing_group_ids: + continue + TaskBoardGroupMembership.objects.create( + group=group, + board=board, + rank_key=allocate_group_tail_rank_keys(group, 1)[0], + ) + + memberships = list( + TaskBoardGroupMembership.objects.filter( + Q(group__user=self.request.user) | Q(group__is_shared=True), + board=board, + ) + .exclude(group__hidden_by=self.request.user) + .order_by("group_id") ) + group_ids = [membership.group_id for membership in memberships] + position_map = visible_group_position_map(self.request.user, group_ids) return { "board": board.id, "group_ids": group_ids, "group_id": group_ids[0] if group_ids else None, + "group_positions": { + str(membership.group_id): position_map[ + (membership.group_id, board.id) + ] + for membership in memberships + if (membership.group_id, board.id) in position_map + }, + "group_sort_keys": { + str(membership.group_id): membership.rank_key + for membership in memberships + }, } @action(detail=True, methods=["post"]) diff --git a/backend/api/v1/tasks/viewsets/boards.py b/backend/api/v1/tasks/viewsets/boards.py index 53a5cc98..7946a16b 100644 --- a/backend/api/v1/tasks/viewsets/boards.py +++ b/backend/api/v1/tasks/viewsets/boards.py @@ -25,6 +25,7 @@ from tasks.models import ( TaskAttachment, TaskBoard, TaskBoardGroup, + TaskBoardGroupMembership, TaskBoardPlacement, TaskChecklistItem, TaskColumn, @@ -78,15 +79,16 @@ class TaskBoardViewSet(TaskBoardActionsMixin, viewsets.ModelViewSet): to_attr="current_user_placements", ), Prefetch( - "board_groups", + "group_memberships", queryset=( - TaskBoardGroup.objects.filter( - Q(user=self.request.user) | Q(is_shared=True), + TaskBoardGroupMembership.objects.filter( + Q(group__user=self.request.user) | Q(group__is_shared=True), ) - .exclude(hidden_by=self.request.user) + .exclude(group__hidden_by=self.request.user) + .select_related("group") .distinct() ), - to_attr="current_user_groups", + to_attr="current_user_group_memberships", ), ) .annotate( diff --git a/backend/scheduling/migrations/0004_calendarbinding_notification_settings.py b/backend/scheduling/migrations/0004_calendarbinding_notification_settings.py new file mode 100644 index 00000000..1d46f745 --- /dev/null +++ b/backend/scheduling/migrations/0004_calendarbinding_notification_settings.py @@ -0,0 +1,34 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("scheduling", "0003_calendarbinding_access_scope"), + ] + + operations = [ + migrations.AddField( + model_name="calendarbinding", + name="notify_event_cancelled", + field=models.BooleanField( + default=True, + verbose_name="Уведомлять об удалении события", + ), + ), + migrations.AddField( + model_name="calendarbinding", + name="notify_event_changed", + field=models.BooleanField( + default=True, + verbose_name="Уведомлять об изменении события", + ), + ), + migrations.AddField( + model_name="calendarbinding", + name="notify_event_created", + field=models.BooleanField( + default=True, + verbose_name="Уведомлять о создании события", + ), + ), + ] diff --git a/backend/scheduling/models.py b/backend/scheduling/models.py index 5ad103a8..2c970b85 100644 --- a/backend/scheduling/models.py +++ b/backend/scheduling/models.py @@ -53,6 +53,18 @@ class CalendarBinding(models.Model): extra_data = models.JSONField( default=dict, blank=True, verbose_name="Доп. данные" ) + notify_event_created = models.BooleanField( + default=True, + verbose_name="Уведомлять о создании события", + ) + notify_event_changed = models.BooleanField( + default=True, + verbose_name="Уведомлять об изменении события", + ) + notify_event_cancelled = models.BooleanField( + default=True, + verbose_name="Уведомлять об удалении события", + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/backend/scheduling/notifications/handlers.py b/backend/scheduling/notifications/handlers.py index f812e9d2..e807764b 100644 --- a/backend/scheduling/notifications/handlers.py +++ b/backend/scheduling/notifications/handlers.py @@ -53,6 +53,11 @@ def notify_event_created(event, creator=None): event: Объект schedule.Event creator: Пользователь, создавший событие (исключается из получателей) """ + from scheduling.services import calendar_notification_enabled + + if not calendar_notification_enabled(event.calendar, "notify_event_created"): + return + try: from notifications.signals import notify from .config import ( @@ -127,6 +132,11 @@ def notify_event_changed(event, changed_fields: list[str] = None, modifier=None) changed_fields: Список изменённых полей modifier: Пользователь, изменивший событие """ + from scheduling.services import calendar_notification_enabled + + if not calendar_notification_enabled(event.calendar, "notify_event_changed"): + return + try: from notifications.signals import notify from .config import ( @@ -198,6 +208,11 @@ def notify_event_cancelled(event, canceller=None): event: Объект schedule.Event canceller: Пользователь, отменивший событие """ + from scheduling.services import calendar_notification_enabled + + if not calendar_notification_enabled(event.calendar, "notify_event_cancelled"): + return + try: from notifications.signals import notify from .config import ( diff --git a/backend/scheduling/services.py b/backend/scheduling/services.py index 88a26429..fd56bd7d 100644 --- a/backend/scheduling/services.py +++ b/backend/scheduling/services.py @@ -410,6 +410,23 @@ def user_can_manage_participants(user, calendar: Calendar) -> bool: return user_relation_distinction(calendar, user) == "owner" +def user_can_manage_calendar_notifications(user, calendar: Calendar) -> bool: + """Проверить право менять общие правила уведомлений календаря.""" + if not getattr(user, "is_authenticated", False): + return False + if user.is_staff or user.is_superuser: + return True + return user_relation_distinction(calendar, user) in {"owner", "editor"} + + +def calendar_notification_enabled(calendar: Calendar | None, field: str) -> bool: + """Вернуть настройку уведомления, сохраняя прежнее поведение без binding.""" + binding = get_calendar_binding(calendar) + if binding is None: + return True + return bool(getattr(binding, field, True)) + + def user_can_edit_event(user, event: Event) -> bool: if not getattr(user, "is_authenticated", False): return False diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 00c9a657..3c340d9f 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -8,6 +8,7 @@ from .models import ( TaskAutomationRun, TaskBoard, TaskBoardGroup, + TaskBoardGroupMembership, TaskBoardPlacement, TaskChecklistItem, TaskColumn, @@ -26,6 +27,14 @@ class TaskColumnInline(admin.TabularInline): fields = ["parent", "name", "position", "color", "is_done", "is_archived"] +class TaskBoardGroupMembershipInline(admin.TabularInline): + model = TaskBoardGroupMembership + extra = 0 + fields = ["board", "rank_key", "added_at"] + readonly_fields = ["added_at"] + autocomplete_fields = ["board"] + + @admin.register(TaskBoard) class TaskBoardAdmin(admin.ModelAdmin): list_display = [ @@ -46,7 +55,8 @@ class TaskBoardGroupAdmin(admin.ModelAdmin): list_display = ["name", "user", "is_shared", "position", "created_at"] list_filter = ["is_shared"] search_fields = ["name", "user__email"] - autocomplete_fields = ["user", "boards", "hidden_by"] + autocomplete_fields = ["user", "hidden_by"] + inlines = [TaskBoardGroupMembershipInline] @admin.register(TaskBoardPlacement) diff --git a/backend/tasks/group_ranking.py b/backend/tasks/group_ranking.py new file mode 100644 index 00000000..945e4a8a --- /dev/null +++ b/backend/tasks/group_ranking.py @@ -0,0 +1,151 @@ +"""Lexicographically sortable rank keys for boards inside a group.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from django.db.models import QuerySet + +from .models import TaskBoard, TaskBoardGroup, TaskBoardGroupMembership + + +RANK_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +RANK_BASE = len(RANK_ALPHABET) +RANK_WIDTH = 32 +RANK_MAX_VALUE = RANK_BASE**RANK_WIDTH - 1 +_RANK_DIGITS = {character: index for index, character in enumerate(RANK_ALPHABET)} + + +class RankSpaceExhausted(ValueError): + """There is no representable key between two adjacent rank keys.""" + + +def encode_rank(value: int) -> str: + if not 0 <= value <= RANK_MAX_VALUE: + raise ValueError("Rank value is outside the supported range.") + characters = [RANK_ALPHABET[0]] * RANK_WIDTH + for index in range(RANK_WIDTH - 1, -1, -1): + value, remainder = divmod(value, RANK_BASE) + characters[index] = RANK_ALPHABET[remainder] + return "".join(characters) + + +def decode_rank(rank_key: str) -> int: + if len(rank_key) != RANK_WIDTH: + raise ValueError("Rank key has an invalid length.") + value = 0 + for character in rank_key: + try: + digit = _RANK_DIGITS[character] + except KeyError as error: + raise ValueError("Rank key contains an invalid character.") from error + value = value * RANK_BASE + digit + return value + + +def rank_between(lower_key: str | None, upper_key: str | None) -> str: + lower_value = decode_rank(lower_key) if lower_key is not None else 0 + upper_value = decode_rank(upper_key) if upper_key is not None else RANK_MAX_VALUE + if lower_value >= upper_value: + raise ValueError("The lower rank must precede the upper rank.") + if upper_value - lower_value <= 1: + raise RankSpaceExhausted("No rank key remains between the neighbours.") + return encode_rank((lower_value + upper_value) // 2) + + +def evenly_spaced_rank_keys(count: int) -> list[str]: + if count <= 0: + return [] + step = RANK_MAX_VALUE // (count + 1) + if step <= 0: + raise RankSpaceExhausted("The group contains too many boards to rank.") + return [encode_rank(step * index) for index in range(1, count + 1)] + + +def allocate_rank_keys_after(last_key: str | None, count: int) -> list[str]: + if count <= 0: + return [] + lower_value = decode_rank(last_key) if last_key is not None else 0 + step = (RANK_MAX_VALUE - lower_value) // (count + 1) + if step <= 0: + raise RankSpaceExhausted("No rank keys remain after the last board.") + return [encode_rank(lower_value + step * index) for index in range(1, count + 1)] + + +def rebalance_group_memberships( + group: TaskBoardGroup, + memberships: Sequence[TaskBoardGroupMembership] | QuerySet | None = None, +) -> list[TaskBoardGroupMembership]: + ordered_memberships = list( + memberships + if memberships is not None + else TaskBoardGroupMembership.objects.filter(group=group).order_by( + "rank_key", "id" + ) + ) + for membership, rank_key in zip( + ordered_memberships, + evenly_spaced_rank_keys(len(ordered_memberships)), + strict=True, + ): + membership.rank_key = rank_key + if ordered_memberships: + TaskBoardGroupMembership.objects.bulk_update( + ordered_memberships, + ["rank_key"], + ) + return ordered_memberships + + +def allocate_group_tail_rank_keys( + group: TaskBoardGroup, + count: int, +) -> list[str]: + if count <= 0: + return [] + last_key = ( + TaskBoardGroupMembership.objects.filter(group=group) + .order_by("-rank_key", "-id") + .values_list("rank_key", flat=True) + .first() + ) + try: + return allocate_rank_keys_after(last_key, count) + except RankSpaceExhausted: + memberships = rebalance_group_memberships(group) + last_key = memberships[-1].rank_key if memberships else None + return allocate_rank_keys_after(last_key, count) + + +def visible_group_position_map( + user, + group_ids: Sequence[int] | None = None, +) -> dict[tuple[int, int], int]: + """Return consecutive positions for boards visible to one user.""" + from django.db.models import Q + + from .access import task_board_access_q + + accessible_boards = TaskBoard.objects.filter(task_board_access_q(user)) + memberships = ( + TaskBoardGroupMembership.objects.filter( + board__in=accessible_boards, + ).filter( + Q(group__user=user) | Q(group__is_shared=True), + ) + .exclude(group__hidden_by=user) + .order_by("group_id", "rank_key", "id") + ) + if group_ids is not None: + memberships = memberships.filter(group_id__in=group_ids) + + result: dict[tuple[int, int], int] = {} + current_group_id = None + position = 0 + for group_id, board_id in memberships.values_list("group_id", "board_id"): + if group_id != current_group_id: + current_group_id = group_id + position = 0 + position += 1 + result[(group_id, board_id)] = position + return result diff --git a/backend/tasks/migrations/0038_taskboardgroupmembership.py b/backend/tasks/migrations/0038_taskboardgroupmembership.py new file mode 100644 index 00000000..8c7ddbd9 --- /dev/null +++ b/backend/tasks/migrations/0038_taskboardgroupmembership.py @@ -0,0 +1,139 @@ +from datetime import timedelta + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +def copy_group_boards_to_memberships(apps, schema_editor): + group_model = apps.get_model("tasks", "TaskBoardGroup") + membership_model = apps.get_model("tasks", "TaskBoardGroupMembership") + old_through = group_model.boards.through + base_time = django.utils.timezone.now() + positions = {} + memberships = [] + + for link in old_through.objects.order_by( + "taskboardgroup_id", + "id", + ).iterator(): + group_id = link.taskboardgroup_id + positions[group_id] = positions.get(group_id, 0) + 1 + memberships.append( + membership_model( + group_id=group_id, + board_id=link.taskboard_id, + position=positions[group_id], + added_at=base_time + timedelta(microseconds=link.id), + ) + ) + + membership_model.objects.bulk_create(memberships) + + +def restore_implicit_group_boards(apps, schema_editor): + group_model = apps.get_model("tasks", "TaskBoardGroup") + membership_model = apps.get_model("tasks", "TaskBoardGroupMembership") + old_through = group_model.boards.through + old_through.objects.bulk_create( + [ + old_through( + taskboardgroup_id=membership.group_id, + taskboard_id=membership.board_id, + ) + for membership in membership_model.objects.order_by( + "group_id", + "position", + "id", + ).iterator() + ], + ignore_conflicts=True, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0037_shared_task_board_groups"), + ] + + operations = [ + migrations.CreateModel( + name="TaskBoardGroupMembership", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("position", models.PositiveIntegerField(verbose_name="Позиция")), + ( + "added_at", + models.DateTimeField( + default=django.utils.timezone.now, + verbose_name="Добавлено", + ), + ), + ( + "board", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="group_memberships", + to="tasks.taskboard", + verbose_name="Доска", + ), + ), + ( + "group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="board_memberships", + to="tasks.taskboardgroup", + verbose_name="Группа", + ), + ), + ], + options={ + "verbose_name": "Доска в группе", + "verbose_name_plural": "Доски в группах", + "ordering": ["group_id", "position", "id"], + "constraints": [ + models.UniqueConstraint( + fields=("group", "board"), + name="uniq_task_board_group_membership", + ), + models.UniqueConstraint( + fields=("group", "position"), + name="uniq_task_board_group_position", + ), + ], + }, + ), + migrations.RunPython( + copy_group_boards_to_memberships, + restore_implicit_group_boards, + ), + migrations.RemoveField( + model_name="taskboardgroup", + name="boards", + ), + migrations.AddField( + model_name="taskboardgroup", + name="boards", + field=models.ManyToManyField( + blank=True, + related_name="board_groups", + through="tasks.TaskBoardGroupMembership", + to="tasks.taskboard", + verbose_name="Доски", + ), + ), + migrations.AlterField( + model_name="taskboardgroupmembership", + name="added_at", + field=models.DateTimeField(auto_now_add=True, verbose_name="Добавлено"), + ), + ] diff --git a/backend/tasks/migrations/0039_taskboardgroupmembership_rank_key.py b/backend/tasks/migrations/0039_taskboardgroupmembership_rank_key.py new file mode 100644 index 00000000..26108e0d --- /dev/null +++ b/backend/tasks/migrations/0039_taskboardgroupmembership_rank_key.py @@ -0,0 +1,82 @@ +from django.db import migrations, models + + +RANK_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +RANK_BASE = len(RANK_ALPHABET) +RANK_WIDTH = 32 +RANK_MAX_VALUE = RANK_BASE**RANK_WIDTH - 1 + + +def encode_rank(value): + characters = [RANK_ALPHABET[0]] * RANK_WIDTH + for index in range(RANK_WIDTH - 1, -1, -1): + value, remainder = divmod(value, RANK_BASE) + characters[index] = RANK_ALPHABET[remainder] + return "".join(characters) + + +def populate_rank_keys(apps, schema_editor): + membership_model = apps.get_model("tasks", "TaskBoardGroupMembership") + group_ids = membership_model.objects.order_by().values_list( + "group_id", flat=True + ).distinct() + for group_id in group_ids.iterator(): + memberships = list( + membership_model.objects.filter(group_id=group_id).order_by( + "position", "added_at", "id" + ) + ) + step = RANK_MAX_VALUE // (len(memberships) + 1) + for index, membership in enumerate(memberships, start=1): + membership.rank_key = encode_rank(step * index) + membership_model.objects.bulk_update(memberships, ["rank_key"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0038_taskboardgroupmembership"), + ] + + operations = [ + migrations.AddField( + model_name="taskboardgroupmembership", + name="rank_key", + field=models.CharField( + max_length=32, + null=True, + verbose_name="Ключ порядка", + ), + ), + migrations.RunPython(populate_rank_keys, migrations.RunPython.noop), + migrations.RemoveConstraint( + model_name="taskboardgroupmembership", + name="uniq_task_board_group_position", + ), + migrations.AlterModelOptions( + name="taskboardgroupmembership", + options={ + "ordering": ["group_id", "rank_key", "id"], + "verbose_name": "Доска в группе", + "verbose_name_plural": "Доски в группах", + }, + ), + migrations.RemoveField( + model_name="taskboardgroupmembership", + name="position", + ), + migrations.AlterField( + model_name="taskboardgroupmembership", + name="rank_key", + field=models.CharField( + max_length=32, + verbose_name="Ключ порядка", + ), + ), + migrations.AddConstraint( + model_name="taskboardgroupmembership", + constraint=models.UniqueConstraint( + fields=("group", "rank_key"), + name="uniq_task_board_group_rank_key", + ), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 4794e005..0a3642a3 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -116,6 +116,7 @@ class TaskBoardGroup(models.Model): ) boards = models.ManyToManyField( TaskBoard, + through="TaskBoardGroupMembership", blank=True, related_name="board_groups", verbose_name="Доски", @@ -146,6 +147,41 @@ class TaskBoardGroup(models.Model): return f"{self.user}: {self.name}" +class TaskBoardGroupMembership(models.Model): + group = models.ForeignKey( + TaskBoardGroup, + on_delete=models.CASCADE, + related_name="board_memberships", + verbose_name="Группа", + ) + board = models.ForeignKey( + TaskBoard, + on_delete=models.CASCADE, + related_name="group_memberships", + verbose_name="Доска", + ) + rank_key = models.CharField("Ключ порядка", max_length=32) + added_at = models.DateTimeField("Добавлено", auto_now_add=True) + + class Meta: + verbose_name = "Доска в группе" + verbose_name_plural = "Доски в группах" + ordering = ["group_id", "rank_key", "id"] + constraints = [ + models.UniqueConstraint( + fields=["group", "board"], + name="uniq_task_board_group_membership", + ), + models.UniqueConstraint( + fields=["group", "rank_key"], + name="uniq_task_board_group_rank_key", + ), + ] + + def __str__(self): + return f"{self.group}: {self.rank_key}. {self.board}" + + class TaskBoardPlacement(models.Model): user = models.ForeignKey( settings.AUTH_USER_MODEL, diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index e98a871b..66861f21 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -32,6 +32,7 @@ from tasks.models import ( TaskAttachment, TaskBoard, TaskBoardGroup, + TaskBoardGroupMembership, TaskBoardPlacement, TaskChecklistItem, TaskColumn, @@ -44,6 +45,7 @@ from tasks.models import ( TaskLinkedObjectKind, TaskUserSettings, ) +from tasks.group_ranking import evenly_spaced_rank_keys, rank_between from tasks.realtime import get_task_board_recipient_ids from api.v1.tasks.views import TaskViewSet @@ -566,6 +568,343 @@ def test_shared_board_group_is_collaborative_without_granting_board_access( assert not TaskBoardGroup.objects.filter(id=group_id).exists() +def test_board_group_positions_are_unique_and_follow_addition_order( + api_client, + user, +): + first_board = TaskBoard.objects.create(name="Первая", created_by=user) + second_board = TaskBoard.objects.create(name="Вторая", created_by=user) + third_board = TaskBoard.objects.create(name="Третья", created_by=user) + first_group = TaskBoardGroup.objects.create(name="Основная", user=user) + second_group = TaskBoardGroup.objects.create(name="Дополнительная", user=user) + api_client.force_authenticate(user=user) + + for board, groups in ( + (first_board, [first_group.id]), + (second_board, [first_group.id]), + (third_board, [first_group.id, second_group.id]), + ): + response = api_client.post( + reverse( + "api:v1:tasks:task-board-set-groups", + kwargs={"pk": board.id}, + ), + {"groups": groups}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + + first_group_memberships = list( + TaskBoardGroupMembership.objects.filter(group=first_group) + .order_by("rank_key") + .values_list("board_id", "rank_key") + ) + assert [board_id for board_id, _rank_key in first_group_memberships] == [ + first_board.id, + second_board.id, + third_board.id, + ] + assert [rank_key for _board_id, rank_key in first_group_memberships] == sorted( + rank_key for _board_id, rank_key in first_group_memberships + ) + assert len({rank_key for _board_id, rank_key in first_group_memberships}) == 3 + second_group_membership = TaskBoardGroupMembership.objects.get( + group=second_group, + board=third_board, + ) + assert second_group_membership.rank_key + + remove_response = api_client.post( + reverse( + "api:v1:tasks:task-board-set-groups", + kwargs={"pk": first_board.id}, + ), + {"groups": []}, + format="json", + ) + assert remove_response.status_code == status.HTTP_200_OK + + readd_response = api_client.post( + reverse( + "api:v1:tasks:task-board-set-groups", + kwargs={"pk": first_board.id}, + ), + {"groups": [first_group.id]}, + format="json", + ) + assert readd_response.status_code == status.HTTP_200_OK + assert readd_response.data["group_positions"] == {str(first_group.id): 3} + + summary_response = api_client.get( + reverse("api:v1:tasks:task-board-list"), + {"view": "summary"}, + ) + summaries = {item["id"]: item for item in summary_response.data["results"]} + assert summaries[first_board.id]["group_positions"] == { + str(first_group.id): 3, + } + assert summaries[third_board.id]["group_positions"] == { + str(first_group.id): 2, + str(second_group.id): 1, + } + + +def test_rank_keys_support_one_hundred_insertions_between_neighbours(): + lower_key, upper_key = evenly_spaced_rank_keys(2) + + generated_keys = [] + for _index in range(100): + upper_key = rank_between(lower_key, upper_key) + generated_keys.append(upper_key) + + assert len(set(generated_keys)) == 100 + assert all(lower_key < rank_key for rank_key in generated_keys) + + +def test_board_group_boards_are_replaced_in_one_bulk_request( + api_client, + user, +): + first_board = TaskBoard.objects.create(name="Первая массовая", created_by=user) + second_board = TaskBoard.objects.create(name="Вторая массовая", created_by=user) + third_board = TaskBoard.objects.create(name="Третья массовая", created_by=user) + group = TaskBoardGroup.objects.create(name="Массовая группа", user=user) + initial_rank_key = evenly_spaced_rank_keys(1)[0] + TaskBoardGroupMembership.objects.create( + group=group, + board=second_board, + rank_key=initial_rank_key, + ) + api_client.force_authenticate(user=user) + + bulk_response = api_client.post( + reverse( + "api:v1:tasks:task-board-group-set-boards", + kwargs={"pk": group.id}, + ), + {"boards": [second_board.id, first_board.id, third_board.id]}, + format="json", + ) + + assert bulk_response.status_code == status.HTTP_200_OK + assert bulk_response.data["group"] == group.id + assert bulk_response.data["board_ids"] == [ + second_board.id, + first_board.id, + third_board.id, + ] + assert bulk_response.data["positions"] == { + str(second_board.id): 1, + str(first_board.id): 2, + str(third_board.id): 3, + } + assert list(bulk_response.data["rank_keys"].values()) == sorted( + bulk_response.data["rank_keys"].values() + ) + assert list( + TaskBoardGroupMembership.objects.filter(group=group) + .order_by("rank_key") + .values_list("board_id", flat=True) + ) == [second_board.id, first_board.id, third_board.id] + + replace_response = api_client.post( + reverse( + "api:v1:tasks:task-board-group-set-boards", + kwargs={"pk": group.id}, + ), + {"boards": [third_board.id]}, + format="json", + ) + + assert replace_response.status_code == status.HTTP_200_OK + assert replace_response.data["positions"] == {str(third_board.id): 1} + assert list( + TaskBoardGroupMembership.objects.filter(group=group).values_list( + "board_id", flat=True + ) + ) == [third_board.id] + + invalid_response = api_client.post( + reverse( + "api:v1:tasks:task-board-group-set-boards", + kwargs={"pk": group.id}, + ), + {"boards": [third_board.id, 999999]}, + format="json", + ) + + assert invalid_response.status_code == status.HTTP_400_BAD_REQUEST + assert list( + TaskBoardGroupMembership.objects.filter(group=group).values_list( + "board_id", flat=True + ) + ) == [third_board.id] + + +def test_board_can_be_reordered_inside_one_group_by_changing_only_its_rank( + api_client, + user, +): + boards = [ + TaskBoard.objects.create(name=f"Доска {index}", created_by=user) + for index in range(1, 5) + ] + primary_group = TaskBoardGroup.objects.create(name="Приоритеты", user=user) + secondary_group = TaskBoardGroup.objects.create(name="Другая группа", user=user) + primary_rank_keys = evenly_spaced_rank_keys(len(boards)) + TaskBoardGroupMembership.objects.bulk_create( + [ + TaskBoardGroupMembership( + group=primary_group, + board=board, + rank_key=rank_key, + ) + for board, rank_key in zip(boards, primary_rank_keys, strict=True) + ] + ) + secondary_rank_key = evenly_spaced_rank_keys(1)[0] + TaskBoardGroupMembership.objects.create( + group=secondary_group, + board=boards[3], + rank_key=secondary_rank_key, + ) + api_client.force_authenticate(user=user) + + response = api_client.post( + reverse( + "api:v1:tasks:task-board-group-reorder-board", + kwargs={"pk": primary_group.id}, + ), + { + "board": boards[3].id, + "previous_board": boards[0].id, + "next_board": boards[1].id, + }, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["positions"] == { + str(boards[0].id): 1, + str(boards[3].id): 2, + str(boards[1].id): 3, + str(boards[2].id): 4, + } + ordered_memberships = list( + TaskBoardGroupMembership.objects.filter(group=primary_group) + .order_by("rank_key") + .values_list("board_id", "rank_key") + ) + assert [board_id for board_id, _rank_key in ordered_memberships] == [ + boards[0].id, + boards[3].id, + boards[1].id, + boards[2].id, + ] + rank_keys_by_board = dict(ordered_memberships) + assert rank_keys_by_board[boards[0].id] == primary_rank_keys[0] + assert rank_keys_by_board[boards[1].id] == primary_rank_keys[1] + assert rank_keys_by_board[boards[2].id] == primary_rank_keys[2] + assert primary_rank_keys[0] < rank_keys_by_board[boards[3].id] < primary_rank_keys[1] + assert TaskBoardGroupMembership.objects.get( + group=secondary_group, + board=boards[3], + ).rank_key == secondary_rank_key + + +def test_board_reorder_rejects_stale_visible_neighbours(api_client, user): + boards = [ + TaskBoard.objects.create(name=f"Сосед {index}", created_by=user) + for index in range(1, 4) + ] + group = TaskBoardGroup.objects.create(name="Проверка порядка", user=user) + TaskBoardGroupMembership.objects.bulk_create( + [ + TaskBoardGroupMembership(group=group, board=board, rank_key=rank_key) + for board, rank_key in zip( + boards, + evenly_spaced_rank_keys(len(boards)), + strict=True, + ) + ] + ) + api_client.force_authenticate(user=user) + + response = api_client.post( + reverse( + "api:v1:tasks:task-board-group-reorder-board", + kwargs={"pk": group.id}, + ), + { + "board": boards[2].id, + "previous_board": boards[0].id, + "next_board": None, + }, + format="json", + ) + + assert response.status_code == status.HTTP_409_CONFLICT + assert list( + TaskBoardGroupMembership.objects.filter(group=group) + .order_by("rank_key") + .values_list("board_id", flat=True) + ) == [board.id for board in boards] + +def test_bulk_group_update_preserves_boards_inaccessible_to_shared_group_member( + api_client, +): + owner = make_user("bulk-group-owner@example.com", "+79994440120") + member = make_user("bulk-group-member@example.com", "+79994440121") + visible_board = TaskBoard.objects.create( + name="Доступная участнику", + created_by=owner, + access_scope="restricted", + ) + visible_board.members.add(member) + owner_only_board = TaskBoard.objects.create( + name="Только для владельца", + created_by=owner, + access_scope="restricted", + ) + group = TaskBoardGroup.objects.create( + name="Общая массовая группа", + user=owner, + is_shared=True, + ) + visible_rank_key, owner_only_rank_key = evenly_spaced_rank_keys(2) + TaskBoardGroupMembership.objects.bulk_create( + [ + TaskBoardGroupMembership( + group=group, + board=visible_board, + rank_key=visible_rank_key, + ), + TaskBoardGroupMembership( + group=group, + board=owner_only_board, + rank_key=owner_only_rank_key, + ), + ] + ) + api_client.force_authenticate(user=member) + + response = api_client.post( + reverse( + "api:v1:tasks:task-board-group-set-boards", + kwargs={"pk": group.id}, + ), + {"boards": []}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + assert list( + TaskBoardGroupMembership.objects.filter(group=group).values_list( + "board_id", flat=True + ) + ) == [owner_only_board.id] + + def test_user_can_toggle_personal_default_board_group(api_client, user): other_user = make_user("task-default-group-other@example.com", "+79994440095") api_client.force_authenticate(user=user) diff --git a/backend/tests/test_schedule_permissions.py b/backend/tests/test_schedule_permissions.py index 9ed6048b..02f717de 100644 --- a/backend/tests/test_schedule_permissions.py +++ b/backend/tests/test_schedule_permissions.py @@ -3,6 +3,7 @@ Тесты для системы прав доступа календарей django-scheduler. """ from datetime import timedelta +from types import SimpleNamespace import pytest from django.contrib.contenttypes.models import ContentType @@ -18,6 +19,7 @@ from schedule.models import ( Occurrence, Rule, ) +from scheduling.models import CalendarBinding User = get_user_model() @@ -86,6 +88,32 @@ def admin_user(db): ) +@pytest.mark.django_db +@pytest.mark.parametrize( + ('field', 'handler_name'), + [ + ('notify_event_created', 'notify_event_created'), + ('notify_event_changed', 'notify_event_changed'), + ('notify_event_cancelled', 'notify_event_cancelled'), + ], +) +def test_disabled_calendar_notification_rule_stops_dispatch( + monkeypatch, field, handler_name +): + from scheduling.notifications import handlers + + calendar = Calendar.objects.create(name='Silent', slug=f'silent-{field}') + CalendarBinding.objects.create(calendar=calendar, **{field: False}) + event = SimpleNamespace(calendar=calendar) + monkeypatch.setattr( + handlers, + 'get_event_recipients', + lambda _event: pytest.fail('Recipients must not be loaded for a disabled rule'), + ) + + getattr(handlers, handler_name)(event) + + @pytest.mark.django_db class TestCalendarPermissions: """Тесты прав доступа к календарям.""" @@ -363,6 +391,90 @@ class TestCalendarPermissions: assert roles[user2.id] == 'editor' assert roles[user3.id] == 'viewer' + def test_calendar_notification_settings_keep_defaults_and_allow_editor( + self, api_client, user1, user2, user3 + ): + api_client.force_authenticate(user=user1) + create_response = api_client.post( + '/api/v1/schedule/calendars/', + {'name': 'Notification settings'}, + ) + calendar_id = create_response.data['id'] + api_client.post( + f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', + {'user_id': user2.id, 'role': 'editor'}, + ) + api_client.post( + f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', + {'user_id': user3.id, 'role': 'viewer'}, + ) + + defaults_response = api_client.get( + f'/api/v1/schedule/calendars/{calendar_id}/notification-settings/' + ) + assert defaults_response.status_code == status.HTTP_200_OK + assert defaults_response.data == { + 'notify_event_created': True, + 'notify_event_changed': True, + 'notify_event_cancelled': True, + 'can_manage': True, + } + + api_client.force_authenticate(user=user2) + update_response = api_client.patch( + f'/api/v1/schedule/calendars/{calendar_id}/notification-settings/', + {'notify_event_changed': False}, + format='json', + ) + assert update_response.status_code == status.HTTP_200_OK + assert update_response.data['notify_event_changed'] is False + + api_client.force_authenticate(user=user3) + forbidden_response = api_client.patch( + f'/api/v1/schedule/calendars/{calendar_id}/notification-settings/', + {'notify_event_created': False}, + format='json', + ) + assert forbidden_response.status_code == status.HTTP_403_FORBIDDEN + + def test_owner_can_change_roles_but_cannot_remove_last_owner_role( + self, api_client, user1, user2 + ): + api_client.force_authenticate(user=user1) + create_response = api_client.post( + '/api/v1/schedule/calendars/', + {'name': 'Role settings'}, + ) + calendar_id = create_response.data['id'] + api_client.post( + f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', + {'user_id': user2.id, 'role': 'viewer'}, + ) + + update_response = api_client.patch( + f'/api/v1/schedule/calendars/{calendar_id}/participant-role/{user2.id}/', + {'role': 'editor'}, + format='json', + ) + assert update_response.status_code == status.HTTP_200_OK + assert update_response.data['distinction'] == 'editor' + + last_owner_response = api_client.patch( + f'/api/v1/schedule/calendars/{calendar_id}/participant-role/{user1.id}/', + {'role': 'viewer'}, + format='json', + ) + assert last_owner_response.status_code == status.HTTP_400_BAD_REQUEST + assert 'role' in last_owner_response.data + + api_client.force_authenticate(user=user2) + forbidden_response = api_client.patch( + f'/api/v1/schedule/calendars/{calendar_id}/participant-role/{user1.id}/', + {'role': 'viewer'}, + format='json', + ) + assert forbidden_response.status_code == status.HTTP_403_FORBIDDEN + def test_admin_list_scope_defaults_to_accessible_only( self, api_client, admin_user, user1 ): diff --git a/frontend/src/app/guests/page.tsx b/frontend/src/app/guests/page.tsx index 7f5fc0d3..537ad85f 100644 --- a/frontend/src/app/guests/page.tsx +++ b/frontend/src/app/guests/page.tsx @@ -40,6 +40,7 @@ import { } from "lucide-react"; import { AppShell } from "@/components/AppShell"; +import { DocumentDetailModal } from "@/components/documents/DocumentDetailModal"; import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; import TaskLinkPill from "@/components/tasks/TaskLinkPill"; @@ -381,6 +382,7 @@ function GuestsPageContent() { const [visits, setVisits] = useState([]); const [guests, setGuests] = useState([]); const [documents, setDocuments] = useState([]); + const [selectedDocument, setSelectedDocument] = useState(null); const [comments, setComments] = useState>({}); const [guestComments, setGuestComments] = useState>({}); const [scopeCounts, setScopeCounts] = useState>({ @@ -442,6 +444,33 @@ function GuestsPageContent() { const openedVisitParamRef = useRef(null); const openedGuestParamRef = useRef(null); const visitMenuRef = useRef(null); + const documentRequestRef = useRef(0); + + const openDocumentDetails = useCallback(async (document: Document | number) => { + const documentId = typeof document === "number" ? document : document.id; + const requestId = ++documentRequestRef.current; + if (typeof document !== "number") setSelectedDocument(document); + try { + const fullDocument = await apiClient.getDocument(documentId) as Document; + if (documentRequestRef.current === requestId) setSelectedDocument(fullDocument); + } catch (err) { + if (documentRequestRef.current === requestId) { + setSelectedDocument((current) => current?.id === documentId ? null : current); + setActionError(getReadableError(err, "Не удалось открыть документ")); + } + } + }, []); + + const refreshSelectedDocument = useCallback(async () => { + const documentId = selectedDocument?.id; + if (!documentId) return; + try { + const fullDocument = await apiClient.getDocument(documentId) as Document; + setSelectedDocument((current) => current?.id === documentId ? fullDocument : current); + } catch (err) { + setActionError(getReadableError(err, "Не удалось обновить документ")); + } + }, [selectedDocument?.id]); useEffect(() => { if (visitMenuOpenId === null) return; @@ -1397,6 +1426,7 @@ function GuestsPageContent() { setVisitMenuOpenId(null); setTaskLinkVisit(nextVisit); }} + onOpenDocument={openDocumentDetails} onOpen={openDetails} onAddComment={addListComment} onCommentDraftChange={updateVisitCommentDraft} @@ -1440,6 +1470,7 @@ function GuestsPageContent() { setTaskLinkGuest(guest); }} onOpenGuest={openGuestDetails} + onOpenDocument={openDocumentDetails} onOpenGuestPhoto={setPhotoGuest} onOpenGuestVisits={openGuestVisits} onAddComment={addGuestComment} @@ -1487,6 +1518,7 @@ function GuestsPageContent() { setDetailsVisit(null); setTaskLinkVisit(visit); }} + onOpenDocument={openDocumentDetails} onRunAction={runVisitAction} /> @@ -1563,6 +1595,7 @@ function GuestsPageContent() { setDetailsGuest(null); setTaskLinkGuest(guest); }} + onOpenDocument={openDocumentDetails} /> + { + documentRequestRef.current += 1; + setSelectedDocument(null); + }} + onUpdate={() => void refreshSelectedDocument()} + onNavigateToRelated={(documentId) => { + void openDocumentDetails(documentId); + }} + /> + void | Promise; + className?: string; +}) { + const label = document.title || document.file_name || `Документ #${document.id}`; + return ( + + ); +} + function GuestVisitRow({ busyKey, canDeleteAllComments, @@ -1809,6 +1882,7 @@ function GuestVisitRow({ onEdit, onLinkTask, onOpen, + onOpenDocument, onOpenGuestPhoto, onRunAction, onToggleComments, @@ -1830,6 +1904,7 @@ function GuestVisitRow({ onEdit: (visit: GuestVisit) => void; onLinkTask: (visit: GuestVisit) => void; onOpen: (visit: GuestVisit) => void | Promise; + onOpenDocument: (document: Document) => void | Promise; onOpenGuestPhoto: (guest: Guest) => void; onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise; onToggleComments: (visit: GuestVisit) => void | Promise; @@ -1904,36 +1979,13 @@ function GuestVisitRow({ {visit.documents?.length ? (
- {visit.documents.map((document) => { - const label = document.title || document.file_name || `Документ #${document.id}`; - const content = ( - <> - - {label} - - ); - - return document.file_url ? ( - - {content} - - ) : ( - - {content} - - ); - })} + {visit.documents.map((document) => ( + + ))}
) : null} void | Promise; onEdit: (visit: GuestVisit) => void; onLinkTask: (visit: GuestVisit) => void; + onOpenDocument: (document: Document) => void | Promise; onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise; }) { const [commentDrafts, setCommentDrafts] = useState>({}); @@ -3214,36 +3268,14 @@ function GuestVisitDetailModal({ {visit.documents.length > 0 ? (
- {visit.documents.map((doc) => { - const label = doc.title || doc.file_name || `Документ #${doc.id}`; - const content = ( - <> - - {label} - - ); - - return doc.file_url ? ( - - {content} - - ) : ( - - {content} - - ); - })} + {visit.documents.map((document) => ( + + ))}
) : (

Документы не прикреплены.

@@ -3553,6 +3585,7 @@ function GuestRegistryPanel({ onDeleteComment, onEditGuest, onLinkTask, + onOpenDocument, onOpenGuest, onOpenGuestPhoto, onOpenGuestVisits, @@ -3574,6 +3607,7 @@ function GuestRegistryPanel({ onDeleteComment: (guest: Guest, commentId: number) => void | Promise; onEditGuest: (guest: Guest) => void; onLinkTask: (guest: Guest) => void; + onOpenDocument: (document: Document) => void | Promise; onOpenGuest: (guest: Guest) => void; onOpenGuestPhoto: (guest: Guest) => void; onOpenGuestVisits: (guest: Guest) => void; @@ -3784,35 +3818,13 @@ function GuestRegistryPanel({ {guest.documents?.length ? (
- {(guest.documents || []).map((document) => { - const label = document.title || document.file_name || `Документ #${document.id}`; - const content = ( - <> - - {label} - - ); - return document.file_url ? ( - - {content} - - ) : ( - - {content} - - ); - })} + {(guest.documents || []).map((document) => ( + + ))}
) : null} {isGuestCommentsOpen ? ( @@ -3940,12 +3952,14 @@ function GuestDetailModal({ onClose, onEdit, onLinkTask, + onOpenDocument, }: { canEdit: boolean; guest: Guest | null; onClose: () => void; onEdit: (guest: Guest) => void; onLinkTask: (guest: Guest) => void; + onOpenDocument: (document: Document) => void | Promise; }) { if (!guest) return null; @@ -4071,35 +4085,13 @@ function GuestDetailModal({ {guest.documents?.length ? (
- {guest.documents.map((document) => { - const label = document.title || document.file_name || `Документ #${document.id}`; - const content = ( - <> - - {label} - - ); - return document.file_url ? ( - - {content} - - ) : ( - - {content} - - ); - })} + {guest.documents.map((document) => ( + + ))}
) : (

Документы не прикреплены.

@@ -4121,6 +4113,7 @@ function GuestEditModal({ onClose, onReloadDocuments, onRemoveDocument, + onOpenDocument, onSubmit, onUploadDocument, setForm, @@ -4135,6 +4128,7 @@ function GuestEditModal({ onClose: () => void; onReloadDocuments: () => void | Promise; onRemoveDocument: (guest: Guest, documentId: number) => void | Promise; + onOpenDocument: (document: Document) => void | Promise; onSubmit: () => void | Promise; onUploadDocument: (guest: Guest, file: File) => void | Promise; setForm: (updater: GuestEditState | null | ((current: GuestEditState | null) => GuestEditState | null)) => void; @@ -4239,23 +4233,11 @@ function GuestEditModal({
{(guest.documents || []).map((document) => (
- {document.file_url ? ( - - {document.title || document.file_name || `Документ #${document.id}`} - - ) : ( - - )} + - - -
*/} + {activeSection === "requests" && ( + } + label="Создать заявку" + /> + )} +
-
- - {activeSection === "requests" && ( - } - label="Создать заявку" - /> - )} -
- + + {activeSection === "requests" ? ( + <> +
+
+ + setSearchQuery(event.target.value)} + onKeyDown={(event) => { if (event.key === "Enter") loadPage1(); }} + placeholder="Поиск по заявкам..." + className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" + /> +
+ + +
+ + + {scopeTabs.map((tab) => ( + + ))} + + + ) : null} {/* ── alerts ── */} @@ -983,61 +1027,6 @@ export default function ProcurementPage() { {activeSection === "requests" && ( <> - {/* ── search + filter ── */} -
-
- - setSearchQuery(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") loadPage1(); }} - placeholder="Поиск по заявкам..." - className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" - /> -
- - -
- - {/* ── scope tabs ── */} - - {scopeTabs.map((tab) => ( - - ))} - - {/* ── filters panel ── */} setFiltersOpen(false)} ariaLabel="Фильтры закупок">
diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 5daa4c2a..4f852ad9 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -220,6 +220,15 @@ type QuickTaskTarget = { const DESKTOP_WIDE_MODE_STORAGE_PREFIX = "tasks.desktop-wide-mode"; const TASK_SUBCOLUMN_WIDTH = "18rem"; +// Board-local layer scale. The application chrome is z=40, standard modals +// start at z=100, and full-screen media previews live at z=300. +const TASK_BOARD_LAYERS = { + draggedItem: 20, + columnMenu: 60, + boardHeaderMenu: 80, + pagePopover: 90, + modalPopover: 110, +} as const; const EMPTY_BOARD_VIEW_STATE: TaskBoardViewState = { hidden_column_ids: [], collapsed_base_column_ids: [], @@ -1200,12 +1209,13 @@ function TaskAddMenuPopover({ return createPortal(
{children} @@ -1214,13 +1224,19 @@ function TaskAddMenuPopover({ ); } -function TaskCardMenuPopover({ +function TaskAnchoredMenuPopover({ anchorRef, menuRef, + menuWidth = 176, + widthClassName = "w-44", + zIndex = TASK_BOARD_LAYERS.pagePopover, children, }: { anchorRef: RefObject; menuRef: RefObject; + menuWidth?: number; + widthClassName?: string; + zIndex?: number; children: ReactNode; }) { const [position, setPosition] = useState<{ @@ -1238,7 +1254,6 @@ function TaskCardMenuPopover({ const rect = anchor.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - const menuWidth = 176; const viewportGap = 8; const menuGap = 6; const spaceBelow = viewportHeight - rect.bottom - viewportGap - menuGap; @@ -1267,7 +1282,7 @@ function TaskCardMenuPopover({ window.removeEventListener("resize", updatePosition); window.removeEventListener("scroll", updatePosition, true); }; - }, [anchorRef]); + }, [anchorRef, menuWidth]); if (!position || typeof document === "undefined") return null; @@ -1275,12 +1290,13 @@ function TaskCardMenuPopover({
{children} @@ -1427,7 +1443,11 @@ function BoardColumn({ const style = { transform: CSS.Transform.toString(transform), transition, - zIndex: isDragging ? 20 : undefined, + zIndex: isDragging + ? TASK_BOARD_LAYERS.draggedItem + : columnMenuOpen + ? TASK_BOARD_LAYERS.columnMenu + : undefined, }; const setColumnNodeRef = useCallback((node: HTMLElement | null) => { setNodeRef(node); @@ -1654,7 +1674,11 @@ function BoardSubcolumnHeader({ ? CSS.Transform.toString({ ...transform, y: 0 }) : undefined, transition: sortable && synchronizedColumnDrag ? transition : undefined, - zIndex: isDragging ? 20 : undefined, + zIndex: isDragging + ? TASK_BOARD_LAYERS.draggedItem + : columnMenuOpen + ? TASK_BOARD_LAYERS.columnMenu + : undefined, }} className={`flex w-full min-w-0 flex-col transition ${isDragging ? "opacity-70" : ""}`} > @@ -1805,6 +1829,7 @@ function BoardColumnGroup({ onEditColumn, onDeleteColumn, columnMenuOpen, + menuLayerOpen, columnMenuRef, onToggleColumnMenu, onColumnMount, @@ -1822,6 +1847,7 @@ function BoardColumnGroup({ onEditColumn: (column: TaskColumn) => void; onDeleteColumn: (column: TaskColumn) => void; columnMenuOpen: boolean; + menuLayerOpen: boolean; columnMenuRef: RefObject; onToggleColumnMenu: () => void; onColumnMount: (columnId: number, node: HTMLElement | null) => void; @@ -1853,7 +1879,11 @@ function BoardColumnGroup({ style={{ transform: CSS.Transform.toString(transform), transition, - zIndex: isDragging ? 20 : undefined, + zIndex: isDragging + ? TASK_BOARD_LAYERS.draggedItem + : menuLayerOpen + ? TASK_BOARD_LAYERS.columnMenu + : undefined, }} className={`tasks-board-column-group flex min-w-max flex-col ${isDragging ? "opacity-70" : ""}`} > @@ -3750,6 +3780,9 @@ function TasksPageContent() { const [boardAvatarCropperImage, setBoardAvatarCropperImage] = useState(null); const [boardAvatarFileName, setBoardAvatarFileName] = useState("board-avatar.jpg"); const [boardGroupModalOpen, setBoardGroupModalOpen] = useState(false); + const [managedBoardGroup, setManagedBoardGroup] = useState(null); + const [managedBoardGroupIds, setManagedBoardGroupIds] = useState([]); + const [managedBoardGroupSearch, setManagedBoardGroupSearch] = useState(""); const [boardActionsMenuOpen, setBoardActionsMenuOpen] = useState(false); const [automationModalOpen, setAutomationModalOpen] = useState(false); const [columnModalOpen, setColumnModalOpen] = useState(false); @@ -3841,10 +3874,14 @@ function TasksPageContent() { const [runningTaskAutomationId, setRunningTaskAutomationId] = useState(null); const boardActionsMenuRef = useRef(null); const taskMenuRef = useRef(null); + const taskMenuAnchorRef = useRef(null); + const taskMenuPopoverRef = useRef(null); const taskAddMenuRef = useRef(null); const taskAddMenuBottomRef = useRef(null); const taskAddMenuPopoverRef = useRef(null); const viewColumnMenuRef = useRef(null); + const viewColumnMenuAnchorRef = useRef(null); + const viewColumnMenuPopoverRef = useRef(null); const cardMenuAnchorRef = useRef(null); const cardMenuRef = useRef(null); const columnMenuRef = useRef(null); @@ -4480,7 +4517,8 @@ function TasksPageContent() { function handlePointerDown(event: MouseEvent) { if ( taskMenuRef.current && - !taskMenuRef.current.contains(event.target as Node) + !taskMenuRef.current.contains(event.target as Node) && + !taskMenuPopoverRef.current?.contains(event.target as Node) ) { setTaskMenuOpen(false); } @@ -4516,7 +4554,8 @@ function TasksPageContent() { function handlePointerDown(event: MouseEvent) { if ( viewColumnMenuRef.current && - !viewColumnMenuRef.current.contains(event.target as Node) + !viewColumnMenuRef.current.contains(event.target as Node) && + !viewColumnMenuPopoverRef.current?.contains(event.target as Node) ) { setViewColumnMenuOpen(false); } @@ -5262,6 +5301,31 @@ function TasksPageContent() { setBoardGroupForm(emptyBoardGroupForm); }, [saving]); + const openManageBoardGroup = useCallback((group: TaskBoardGroup) => { + setManagedBoardGroup(group); + setManagedBoardGroupIds( + boards + .filter((item) => ( + item.group_ids || (item.group_id ? [item.group_id] : []) + ).includes(group.id)) + .map((item) => item.id), + ); + setManagedBoardGroupSearch(""); + }, [boards]); + + const closeManageBoardGroup = useCallback(() => { + if (saving) return; + setManagedBoardGroup(null); + setManagedBoardGroupIds([]); + setManagedBoardGroupSearch(""); + }, [saving]); + + const toggleManagedBoard = useCallback((boardId: number) => { + setManagedBoardGroupIds((current) => current.includes(boardId) + ? current.filter((id) => id !== boardId) + : [...current, boardId]); + }, []); + const openEditBoard = useCallback((targetBoard?: TaskBoard | TaskBoardSummary) => { const editableBoard = targetBoard || board; if (!editableBoard) return; @@ -5658,6 +5722,8 @@ function TasksPageContent() { const previousGroupIds = [ ...(currentBoard?.group_ids || (currentBoard?.group_id ? [currentBoard.group_id] : [])), ].sort((left, right) => left - right); + const previousGroupPositions = { ...(currentBoard?.group_positions || {}) }; + const previousGroupSortKeys = { ...(currentBoard?.group_sort_keys || {}) }; const nextGroupIds = [...new Set(groupIds)].sort((left, right) => left - right); if ( saving @@ -5678,18 +5744,50 @@ function TasksPageContent() { setSaving(true); setError(null); try { - await apiClient.setTaskBoardGroups(boardId, nextGroupIds); + const result = await apiClient.setTaskBoardGroups(boardId, nextGroupIds); + setBoards((current) => current.map((item) => ( + item.id === boardId + ? { + ...item, + group_ids: result.group_ids, + group_id: result.group_id, + group_positions: result.group_positions, + group_sort_keys: result.group_sort_keys, + } + : item + ))); + setBoard((current) => current?.id === boardId + ? { + ...current, + group_ids: result.group_ids, + group_id: result.group_id, + group_positions: result.group_positions, + group_sort_keys: result.group_sort_keys, + } + : current); await loadBoardGroups(); return true; } catch (groupError) { const previousGroupId = previousGroupIds[0] || null; setBoards((current) => current.map((item) => ( item.id === boardId - ? { ...item, group_ids: previousGroupIds, group_id: previousGroupId } + ? { + ...item, + group_ids: previousGroupIds, + group_id: previousGroupId, + group_positions: previousGroupPositions, + group_sort_keys: previousGroupSortKeys, + } : item ))); setBoard((current) => current?.id === boardId - ? { ...current, group_ids: previousGroupIds, group_id: previousGroupId } + ? { + ...current, + group_ids: previousGroupIds, + group_id: previousGroupId, + group_positions: previousGroupPositions, + group_sort_keys: previousGroupSortKeys, + } : current); setError(getTaskError(groupError, "Не удалось изменить группы доски")); return false; @@ -5698,6 +5796,87 @@ function TasksPageContent() { } }, [boards, loadBoardGroups, saving]); + const reorderBoardInGroup = useCallback(async ( + groupId: number, + boardId: number, + previousBoardId: number | null, + nextBoardId: number | null, + ) => { + if (saving) return false; + setSaving(true); + setError(null); + try { + const result = await apiClient.reorderTaskBoardInGroup(groupId, { + board: boardId, + previous_board: previousBoardId, + next_board: nextBoardId, + }); + const mergeGroupOrder = ; + group_sort_keys?: Record; + }>(item: T): T => { + const position = result.positions[String(item.id)]; + const rankKey = result.rank_keys[String(item.id)]; + if (position === undefined || rankKey === undefined) return item; + return { + ...item, + group_positions: { + ...(item.group_positions || {}), + [String(groupId)]: position, + }, + group_sort_keys: { + ...(item.group_sort_keys || {}), + [String(groupId)]: rankKey, + }, + } as T; + }; + setBoards((current) => current.map(mergeGroupOrder)); + setBoard((current) => current ? mergeGroupOrder(current) : current); + return true; + } catch (reorderError) { + setError(getTaskError(reorderError, "Не удалось изменить приоритет доски")); + await loadBoards(); + return false; + } finally { + setSaving(false); + } + }, [loadBoards, saving]); + + const saveManagedBoardGroup = useCallback(async () => { + if (!managedBoardGroup || saving) return; + + setSaving(true); + setError(null); + try { + await apiClient.setTaskBoardGroupBoards( + managedBoardGroup.id, + managedBoardGroupIds, + ); + await Promise.all([ + loadBoards(), + loadBoardGroups(), + selectedBoardId ? loadBoard(selectedBoardId) : Promise.resolve(null), + ]); + setManagedBoardGroup(null); + setManagedBoardGroupIds([]); + setManagedBoardGroupSearch(""); + } catch (groupError) { + await Promise.all([loadBoards(), loadBoardGroups()]); + setError(getTaskError(groupError, "Не удалось сохранить доски группы")); + } finally { + setSaving(false); + } + }, [ + loadBoard, + loadBoardGroups, + loadBoards, + managedBoardGroup, + managedBoardGroupIds, + saving, + selectedBoardId, + ]); + const saveColumn = useCallback(async () => { if (!board || !columnForm.name.trim()) return; setSaving(true); @@ -7486,6 +7665,7 @@ function TasksPageContent() { onCreate={openCreateBoard} onCreateGroup={openCreateBoardGroup} onEditGroup={openEditBoardGroup} + onManageGroupBoards={openManageBoardGroup} onDeleteGroup={deleteBoardGroup} onToggleDefaultGroup={(group) => void toggleDefaultBoardGroup(group)} onToggleHiddenGroup={(group) => void toggleBoardGroupHidden(group)} @@ -7494,6 +7674,7 @@ function TasksPageContent() { onToggleBoardPin={(targetBoard) => void toggleBoardPin(targetBoard)} onToggleBoardNotifications={(targetBoard) => void toggleBoardNotifications(targetBoard)} onMoveBoard={setBoardGroupMemberships} + onReorderBoard={reorderBoardInGroup} wideMode={desktopWideMode} onWideModeChange={changeDesktopWideMode} userId={user?.id ?? null} @@ -7506,6 +7687,7 @@ function TasksPageContent() {
@@ -7591,7 +7773,7 @@ function TasksPageContent() { {boardActionsMenuOpen ? ( -
+
- + {activeDragTask ? : null} {cardMenuTask ? ( - @@ -8074,7 +8257,7 @@ function TasksPageContent() { Удалить - + ) : null} ) : requestedBoardId > 0 && !error ? ( @@ -8498,6 +8681,117 @@ function TasksPageContent() {
+ + + +
+ )} + > +
+
+ + setManagedBoardGroupSearch(event.target.value)} + className="app-input w-full rounded-xl py-2.5 pl-9 pr-3 text-sm" + placeholder="Поиск досок" + aria-label="Поиск досок группы" + /> +
+ +
+ + Выбрано: {managedBoardGroupIds.length} из {boards.length} + +
+ + +
+
+ +
+ {boards.filter((item) => { + const query = managedBoardGroupSearch.trim().toLowerCase(); + if (!query) return true; + return `${item.name} ${item.description || ""}`.toLowerCase().includes(query); + }).map((item) => ( + + ))} + {boards.length === 0 ? ( +
+ +

Доступных досок пока нет

+
+ ) : boards.every((item) => { + const query = managedBoardGroupSearch.trim().toLowerCase(); + return query && !`${item.name} ${item.description || ""}`.toLowerCase().includes(query); + }) ? ( +

Доски не найдены

+ ) : null} +
+
+ + {viewColumnMenuOpen ? ( -
+ {leafColumns.map((column) => { const selected = column.id === viewTask.column; const parent = column.parent @@ -8832,7 +9133,7 @@ function TasksPageContent() { ); })} -
+ ) : null}
) : null} @@ -8912,6 +9213,7 @@ function TasksPageContent() {
{taskMenuOpen ? ( -
+ -
+ ) : null}
diff --git a/frontend/src/components/AvatarCropper.tsx b/frontend/src/components/AvatarCropper.tsx index decfc676..78da793b 100644 --- a/frontend/src/components/AvatarCropper.tsx +++ b/frontend/src/components/AvatarCropper.tsx @@ -69,7 +69,7 @@ export default function AvatarCropper({ }, [croppedAreaPixels, initialImage, onCropComplete]); return ( - + {/* Заголовок */}

diff --git a/frontend/src/components/BigCalendar.tsx b/frontend/src/components/BigCalendar.tsx index 9bf6a94a..a026daf0 100644 --- a/frontend/src/components/BigCalendar.tsx +++ b/frontend/src/components/BigCalendar.tsx @@ -8,9 +8,10 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { apiClient } from "@/lib/api"; import { useCalendar } from "@/contexts/CalendarContext"; import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents"; -import { Plus, CalendarDays, ChevronLeft, ChevronRight, Users, Pencil, Trash2 } from "lucide-react"; +import { Bell, Plus, CalendarDays, ChevronLeft, ChevronRight, Users, Pencil, ShieldCheck, Trash2 } from "lucide-react"; import { CalendarModal } from "@/components/CalendarModal"; import CalendarParticipantsModal from "@/components/CalendarParticipantsModal"; +import CalendarNotificationSettingsModal from "@/components/CalendarNotificationSettingsModal"; import { EventModal } from "@/components/EventModal"; import { ViewDayEventsModal } from "@/components/ViewDayEventsModal"; import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal"; @@ -164,6 +165,8 @@ export function BigCalendar() { const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null); const [showCalendarActions, setShowCalendarActions] = useState(false); const [showParticipantsModal, setShowParticipantsModal] = useState(false); + const [participantsInitialTab, setParticipantsInitialTab] = useState<"participants" | "roles">("participants"); + const [showNotificationSettings, setShowNotificationSettings] = useState(false); const fileInputRef = useRef(null); // Новые модалы для просмотра @@ -272,15 +275,6 @@ export function BigCalendar() { // Создание события из модала просмотра дня const handleCreateEventFromDay = useCallback(() => { - if (selectedCalendarId === null) { - alert("Сначала выберите календарь"); - return; - } - if (selectedCalendar?.can_create_events === false) { - alert("В выбранный календарь нельзя добавлять события"); - return; - } - if (!selectedDate) return; // Устанавливаем время по умолчанию 10:00 - 11:00 @@ -295,7 +289,7 @@ export function BigCalendar() { description: "", start: startDate, end: endDate, - calendar: selectedCalendarId, + calendar: selectedCalendar?.can_create_events === false ? null : selectedCalendarId, color_event: DEFAULT_EVENT_COLOR, }; @@ -526,6 +520,7 @@ export function BigCalendar() { type="button" onClick={() => { if (participantsModalAvailable && selectedCalendar) { + setParticipantsInitialTab("participants"); setShowParticipantsModal(true); } setShowCalendarActions(false); @@ -536,6 +531,34 @@ export function BigCalendar() { Участники + + {selectedCalendar.can_manage_notification_settings ? ( + + ) : null} + +

+ } + > +
+
+
+ +
+
+

{calendarName}

+

Общие правила для всех участников календаря

+
+
+ + {error ?
{error}
: null} + +
+ {rules.map((rule) => { + const Icon = rule.icon; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/CalendarParticipantsModal.tsx b/frontend/src/components/CalendarParticipantsModal.tsx index 05af502c..07e622e1 100644 --- a/frontend/src/components/CalendarParticipantsModal.tsx +++ b/frontend/src/components/CalendarParticipantsModal.tsx @@ -1,23 +1,17 @@ -'use client'; +"use client"; -import { useState, useEffect, useCallback } from 'react'; -import { - Building2, - CalendarDays, - Check, - Search, - Trash2, - UserPlus, - UserRoundCheck, - Users, -} from 'lucide-react'; -import api from '@/lib/api'; -import { Modal } from '@/components/ui'; -import { ProjectSelectMenu } from '@/components/ui/ProjectSelectMenu'; -import { loadAllPages } from '@/lib/shared'; -import { resolveMediaUrl } from '@/lib/url'; -import type { CalendarParticipant, Department, User } from '@/types/api'; -import Image from 'next/image'; +import Image from "next/image"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Building2, CalendarDays, Check, Search, ShieldCheck, Trash2, UserPlus, UserRoundCheck, Users } from "lucide-react"; + +import { Modal } from "@/components/ui"; +import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu"; +import api from "@/lib/api"; +import { loadAllPages } from "@/lib/shared"; +import { resolveMediaUrl } from "@/lib/url"; +import type { CalendarParticipant, CalendarParticipantRole, Department, User } from "@/types/api"; + +type ModalTab = "participants" | "roles"; interface CalendarParticipantsModalProps { isOpen: boolean; @@ -27,7 +21,29 @@ interface CalendarParticipantsModalProps { canManageParticipants?: boolean; calendarType?: string | null; contextType?: string | null; - initialAccessScope?: 'all' | 'restricted'; + initialAccessScope?: "all" | "restricted"; + initialTab?: ModalTab; +} + +const roleOptions = [ + { value: "viewer", label: "Просмотр" }, + { value: "editor", label: "Редактор" }, + { value: "owner", label: "Владелец" }, +] as const; + +const roleLabels: Record = { + owner: "Владелец", + editor: "Редактор", + viewer: "Просмотр", +}; + +function ParticipantAvatar({ participant }: { participant: CalendarParticipant }) { + const user = participant.user; + const fullName = `${user?.first_name || ""} ${user?.last_name || ""}`.trim() || user?.username || "Сотрудник"; + if (user?.avatar) { + return {fullName}; + } + return
{user?.first_name?.[0] || user?.last_name?.[0] || "?"}
; } export default function CalendarParticipantsModal({ @@ -38,317 +54,224 @@ export default function CalendarParticipantsModal({ canManageParticipants = false, calendarType, contextType, - initialAccessScope = 'restricted', + initialAccessScope = "restricted", + initialTab = "participants", }: CalendarParticipantsModalProps) { + const [activeTab, setActiveTab] = useState(initialTab); const [participants, setParticipants] = useState([]); const [employees, setEmployees] = useState([]); const [departments, setDepartments] = useState([]); const [selectedUserIds, setSelectedUserIds] = useState([]); - const [selectedRole, setSelectedRole] = useState('viewer'); - const [selectedDepartment, setSelectedDepartment] = useState(''); + const [selectedRole, setSelectedRole] = useState("viewer"); + const [selectedDepartment, setSelectedDepartment] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [accessScope, setAccessScope] = useState<"all" | "restricted">(initialAccessScope); const [loading, setLoading] = useState(false); const [accessUpdating, setAccessUpdating] = useState(false); - const [accessScope, setAccessScope] = useState<'all' | 'restricted'>(initialAccessScope); - const [notice, setNotice] = useState<{ tone: 'success' | 'danger'; text: string } | null>(null); - const [searchQuery, setSearchQuery] = useState(''); + const [roleUpdatingId, setRoleUpdatingId] = useState(null); + const [notice, setNotice] = useState<{ tone: "success" | "danger"; text: string } | null>(null); - const isDepartmentCalendar = - calendarType === 'department' || contextType === 'department'; + const isDepartmentCalendar = calendarType === "department" || contextType === "department"; const canManage = canManageParticipants && !isDepartmentCalendar; const loadParticipants = useCallback(async () => { try { - const data = await api.getCalendarParticipants(calendarId); - setParticipants(data); + setParticipants(await api.getCalendarParticipants(calendarId)); } catch (error) { - console.error('Failed to load participants:', error); + console.error("Failed to load participants:", error); + setNotice({ tone: "danger", text: "Не удалось загрузить участников календаря." }); } }, [calendarId]); - const loadEmployees = useCallback(async () => { - try { - const allEmployees = await loadAllPages((params) => api.getEmployees(params)); - setEmployees(allEmployees); - } catch (error) { - console.error('Failed to load employees:', error); - } - }, []); - - const loadDepartments = useCallback(async () => { - try { - const data = await api.getDepartments({ limit: 100 }); - setDepartments(data.results || []); - } catch (error) { - console.error('Failed to load departments:', error); - } - }, []); - useEffect(() => { - if (!isOpen) { - return; - } - + if (!isOpen) return; const loadTimer = window.setTimeout(() => { + setActiveTab(initialTab); + setSelectedRole(initialTab === "roles" ? "editor" : "viewer"); + setSelectedDepartment(""); + setAccessScope(initialAccessScope); + setNotice(null); void loadParticipants(); - void api.getCalendar(calendarId).then((calendar) => { - setAccessScope(calendar.access_scope === 'all' ? 'all' : 'restricted'); - }).catch((error) => { - console.error('Failed to load calendar access:', error); - }); - - if (canManage) { - void loadEmployees(); - void loadDepartments(); - } + void api.getCalendar(calendarId) + .then((calendar) => setAccessScope(calendar.access_scope === "all" ? "all" : "restricted")) + .catch((error) => console.error("Failed to load calendar access:", error)); }, 0); return () => window.clearTimeout(loadTimer); - }, [calendarId, canManage, isOpen, loadDepartments, loadEmployees, loadParticipants]); + }, [calendarId, initialAccessScope, initialTab, isOpen, loadParticipants]); - const handleAccessScopeChange = async (nextScope: 'all' | 'restricted') => { + useEffect(() => { + if (!isOpen || !canManage) return; + if (employees.length === 0) { + void loadAllPages((params) => api.getEmployees(params)).then(setEmployees).catch((error) => console.error("Failed to load employees:", error)); + } + if (activeTab === "participants" && departments.length === 0) { + void api.getDepartments({ limit: 100 }).then((data) => setDepartments(data.results || [])).catch((error) => console.error("Failed to load departments:", error)); + } + }, [activeTab, canManage, departments.length, employees.length, isOpen]); + + const availableEmployees = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + return employees.filter((employee) => { + if (participants.some((participant) => participant.user?.id === employee.id)) return false; + if (selectedDepartment && !employee.departments?.some((department) => department.id === Number(selectedDepartment))) return false; + const haystack = `${employee.first_name || ""} ${employee.last_name || ""} ${employee.email || ""}`.toLowerCase(); + return !query || haystack.includes(query); + }); + }, [employees, participants, searchQuery, selectedDepartment]); + + const visibleParticipants = accessScope === "all" ? participants.filter((participant) => participant.distinction !== "viewer") : participants; + + const handleClose = () => { + setNotice(null); + setSelectedUserIds([]); + setSearchQuery(""); + onClose(); + }; + + const handleAccessScopeChange = async (nextScope: "all" | "restricted") => { if (nextScope === accessScope || accessUpdating) return; setAccessUpdating(true); setNotice(null); try { const calendar = await api.updateCalendarAccessScope(calendarId, nextScope); - setAccessScope(calendar.access_scope === 'all' ? 'all' : 'restricted'); + setAccessScope(calendar.access_scope === "all" ? "all" : "restricted"); setSelectedUserIds([]); - setNotice({ - tone: 'success', - text: nextScope === 'all' - ? 'Календарь доступен всей компании.' - : 'Календарь доступен только выбранным участникам.', - }); + setNotice({ tone: "success", text: nextScope === "all" ? "Календарь доступен всей компании." : "Календарь доступен только выбранным участникам." }); } catch (error) { - console.error('Failed to update calendar access:', error); - setNotice({ tone: 'danger', text: 'Не удалось изменить доступ к календарю.' }); + console.error("Failed to update calendar access:", error); + setNotice({ tone: "danger", text: "Не удалось изменить доступ к календарю." }); } finally { setAccessUpdating(false); } }; - const handleClose = () => { - setNotice(null); - setSelectedUserIds([]); - setSearchQuery(''); - onClose(); - }; - const handleAddParticipants = async () => { - if (selectedUserIds.length === 0) { - alert('Выберите хотя бы одного сотрудника'); - return; - } - + if (selectedUserIds.length === 0) return; setLoading(true); + setNotice(null); try { - await Promise.all(selectedUserIds.map((userId) => - api.addCalendarParticipant(calendarId, userId, selectedRole) - )); - + await Promise.all(selectedUserIds.map((userId) => api.addCalendarParticipant(calendarId, userId, selectedRole))); + const addedCount = selectedUserIds.length; await loadParticipants(); setSelectedUserIds([]); - setSearchQuery(''); - setSelectedRole('viewer'); - setNotice({ tone: 'success', text: `Добавлено участников: ${selectedUserIds.length}.` }); + setSearchQuery(""); + setSelectedRole("viewer"); + setNotice({ tone: "success", text: `Добавлено участников: ${addedCount}.` }); } catch (error) { - console.error('Failed to add participants:', error); - setNotice({ tone: 'danger', text: 'Не удалось добавить участников.' }); + console.error("Failed to add participants:", error); + setNotice({ tone: "danger", text: "Не удалось добавить участников." }); } finally { setLoading(false); } }; - const toggleUserSelection = (userId: number) => { - setSelectedUserIds(prev => - prev.includes(userId) - ? prev.filter(id => id !== userId) - : [...prev, userId] - ); - }; - - const selectAll = () => { - const availableUserIds = filteredAndDepartmentEmployees.map(emp => emp.id); - setSelectedUserIds(availableUserIds); - }; - - const selectAllFromDepartment = () => { - if (!selectedDepartment) return; - const departmentEmployees = filteredAndDepartmentEmployees.filter(emp => - emp.departments?.some(d => d.id === parseInt(selectedDepartment)) - ); - const departmentUserIds = departmentEmployees.map(emp => emp.id); - setSelectedUserIds(prev => [...new Set([...prev, ...departmentUserIds])]); - }; - - const deselectAll = () => { - setSelectedUserIds([]); - }; - const handleRemoveParticipant = async (userId: number) => { - if (!confirm('Удалить участника из календаря?')) return; - + if (!confirm("Удалить участника из календаря?")) return; setLoading(true); try { await api.removeCalendarParticipant(calendarId, userId); await loadParticipants(); - setNotice({ tone: 'success', text: 'Участник удалён из календаря.' }); + setNotice({ tone: "success", text: "Участник удалён из календаря." }); } catch (error) { - console.error('Failed to remove participant:', error); - setNotice({ tone: 'danger', text: 'Не удалось удалить участника.' }); + console.error("Failed to remove participant:", error); + setNotice({ tone: "danger", text: "Не удалось удалить участника." }); } finally { setLoading(false); } }; - const filteredEmployees = employees.filter((emp) => { - const fullName = `${emp.first_name} ${emp.last_name} ${emp.email || ''}`.toLowerCase(); - const matchesSearch = fullName.includes(searchQuery.toLowerCase()); - - return matchesSearch; - }); - - const filteredAndDepartmentEmployees = filteredEmployees.filter(emp => { - // Если выбран отдел, фильтруем по нему - if (selectedDepartment) { - return emp.departments?.some(d => d.id === parseInt(selectedDepartment)); + const handleRoleChange = async (participant: CalendarParticipant, role: string) => { + if (!participant.user || participant.distinction === role) return; + setRoleUpdatingId(participant.id); + setNotice(null); + try { + const updated = await api.updateCalendarParticipantRole(calendarId, participant.user.id, role as CalendarParticipantRole); + setParticipants((current) => current.map((item) => item.id === participant.id ? updated : item)); + setNotice({ tone: "success", text: "Роль участника обновлена." }); + } catch (error) { + console.error("Failed to update participant role:", error); + setNotice({ tone: "danger", text: "Не удалось изменить роль. У календаря должен оставаться хотя бы один владелец." }); + } finally { + setRoleUpdatingId(null); } - return true; - }); - - const availableEmployees = filteredAndDepartmentEmployees.filter(emp => - !participants.find(p => p.user?.id === emp.id) - ); - - const visibleParticipants = accessScope === 'all' - ? participants.filter((participant) => participant.distinction !== 'viewer') - : participants; + }; if (!isOpen) return null; return ( - } - > + }>
-
- -
-
-

{calendarName}

-

- {isDepartmentCalendar ? 'Состав определяется отделом' : accessScope === 'all' ? 'Доступ для всей компании' : 'Доступ выбранным участникам'} -

-
+
+

{calendarName}

{isDepartmentCalendar ? "Состав определяется отделом" : accessScope === "all" ? "Доступ для всей компании" : "Доступ выбранным участникам"}

- {notice ?
{notice.text}
: null} +
+ + +
- {isDepartmentCalendar ? ( -
- -

Участники синхронизируются автоматически из состава отдела и назначенных ролей.

+ {notice ?
{notice.text}
: null} + + {activeTab === "participants" ? ( +
+ {isDepartmentCalendar ?

Участники синхронизируются автоматически из состава отдела и назначенных ролей.

: canManage ? ( +

Доступ

+ + +
+ ) : null} + + {canManage && accessScope === "restricted" ? ( +
+

Добавить участников

Выбрано: {selectedUserIds.length}

+
{ setSelectedDepartment(value); setSelectedUserIds([]); }} ariaLabel="Отдел участников" options={[{ value: "", label: "Все отделы" }, ...departments.map((department) => ({ value: String(department.id), label: department.name }))]} />
+
+ {availableEmployees.length ? availableEmployees.map((employee) => { + const selected = selectedUserIds.includes(employee.id); + const fullName = `${employee.first_name || ""} ${employee.last_name || ""}`.trim() || employee.username || "Сотрудник"; + return ; + }) :

{searchQuery ? "Сотрудники не найдены" : "Все сотрудники уже добавлены"}

} +
+
setSelectedRole(value as CalendarParticipantRole)} ariaLabel="Роль участника" options={[...roleOptions]} />
+
+ ) : null} + + {accessScope === "all" && !isDepartmentCalendar ?

Вся компания

Все сотрудники получают просмотр календаря автоматически. Ниже показаны только индивидуальные роли.

: null} +

{accessScope === "all" ? "Индивидуальные роли" : "Текущие участники"}

{visibleParticipants.length}
+ {visibleParticipants.length ? visibleParticipants.map((participant) => { + const user = participant.user; + const fullName = `${user?.first_name || ""} ${user?.last_name || ""}`.trim() || user?.username || "Сотрудник"; + return

{fullName}

{user?.email ?

{user.email}

: null}
{roleLabels[participant.distinction] || participant.distinction}{canManage && participant.distinction !== "owner" ? : null}
; + }) :

Индивидуальные участники не добавлены

} +
- ) : canManage ? ( -
-

Доступ

-
- - -
-
- ) : null} - - {canManage && accessScope === 'restricted' ? ( -
-
-
-

Добавить участников

-

Выбрано: {selectedUserIds.length}

-
-
- - {selectedDepartment ? : null} - -
-
- -
- - { setSelectedDepartment(value); setSelectedUserIds([]); }} - ariaLabel="Отдел участников" - options={[{ value: '', label: 'Все отделы' }, ...departments.map((department) => ({ value: String(department.id), label: department.name }))]} - /> -
- -
- {availableEmployees.length ? availableEmployees.map((employee) => { - const selected = selectedUserIds.includes(employee.id); - const fullName = `${employee.first_name || ''} ${employee.last_name || ''}`.trim() || employee.username || 'Сотрудник'; - return ; - }) :

{searchQuery ? 'Сотрудники не найдены' : 'Все сотрудники уже добавлены'}

} -
- -
- - -
-
- ) : null} - - {accessScope === 'all' && !isDepartmentCalendar ? ( -
- -

Вся компания

Все действующие и новые сотрудники получают просмотр календаря автоматически.

+ ) : ( +
+

Владелец

Полное управление календарём, участниками и ролями.

Редактор

Создаёт события и настраивает уведомления.

Просмотр

Видит календарь без права изменений.

+ {isDepartmentCalendar ?

Роли календаря отдела назначаются автоматически: руководитель — владелец, участники отдела — редакторы.

: null} + {accessScope === "all" ?
Вся компанияимеет роль просмотра; ниже настраиваются индивидуальные расширенные роли.
: null} + {canManage ? ( +
+

Назначить индивидуальную роль

Сотрудник сохранит доступ компании, а выбранная роль даст дополнительные права.

+ + {searchQuery.trim() ?
{availableEmployees.length ? availableEmployees.map((employee) => { + const selected = selectedUserIds.includes(employee.id); + const fullName = `${employee.first_name || ""} ${employee.last_name || ""}`.trim() || employee.username || "Сотрудник"; + return ; + }) :

Сотрудники не найдены или уже имеют индивидуальную роль

}
: null} +
setSelectedRole(value as CalendarParticipantRole)} ariaLabel="Новая роль участника" options={[{ value: "editor", label: "Редактор" }, { value: "owner", label: "Владелец" }]} />
+
+ ) : null} +

Индивидуальные роли

{participants.length}
+ {participants.length ? participants.map((participant) => { + const user = participant.user; + const fullName = `${user?.first_name || ""} ${user?.last_name || ""}`.trim() || user?.username || "Сотрудник"; + return

{fullName}

{user?.email ?

{user.email}

: null}
{canManage ?
void handleRoleChange(participant, value)} ariaLabel={`Роль: ${fullName}`} options={[...roleOptions]} />
: {roleLabels[participant.distinction] || participant.distinction}}
; + }) :

Индивидуальные роли не назначены

} +
- ) : null} - -
-
-

{accessScope === 'all' ? 'Индивидуальные роли' : 'Текущие участники'}

- {visibleParticipants.length} -
-
- {visibleParticipants.length ? visibleParticipants.map((participant) => { - const user = participant.user; - const fullName = `${user?.first_name || ''} ${user?.last_name || ''}`.trim() || user?.username || 'Сотрудник'; - const secondaryLabel = user?.email || (user?.username ? `@${user.username}` : ''); - return
- {user?.avatar ? {fullName} :
{user?.first_name?.[0] || user?.last_name?.[0] || '?'}
} -

{fullName}

{secondaryLabel ?

{secondaryLabel}

: null}
- {participant.distinction === 'owner' ? 'Владелец' : participant.distinction === 'editor' ? 'Редактор' : 'Просмотр'} - {canManage && participant.distinction !== 'owner' ? : null} -
; - }) :

Индивидуальные участники не добавлены

} -
-
+ )}
); diff --git a/frontend/src/components/EventModal.tsx b/frontend/src/components/EventModal.tsx index 6e156587..230bafa4 100644 --- a/frontend/src/components/EventModal.tsx +++ b/frontend/src/components/EventModal.tsx @@ -2,9 +2,11 @@ import { useState, useEffect, useRef } from "react"; import Image from "next/image"; -import { Check, X, Trash2 } from "lucide-react"; +import { CalendarDays, Check, X, Trash2 } from "lucide-react"; import { apiClient } from "@/lib/api"; import { Modal } from "@/components/ui"; +import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu"; +import { useCalendar } from "@/contexts/CalendarContext"; import { EVENT_COLOR_OPTIONS, resolveEventColor } from "@/lib/calendar-event-colors"; import { loadAllPages } from "@/lib/shared"; import { resolveMediaUrl } from "@/lib/url"; @@ -61,6 +63,7 @@ export function EventModal({ onSave, showParticipants = false, }: EventModalProps) { + const { calendars, loading: calendarsLoading } = useCalendar(); const [editingEvent, setEditingEvent] = useState(null); const [eventParticipants, setEventParticipants] = useState([]); const [eventParticipantsLoading, setEventParticipantsLoading] = useState(false); @@ -429,11 +432,26 @@ export function EventModal({ if (!editingEvent) return null; + const selectedCalendarId = typeof editingEvent.calendar === "number" + ? editingEvent.calendar + : null; + const writableCalendars = calendars.filter((calendar) => calendar.can_create_events !== false); + const currentCalendar = selectedCalendarId === null + ? null + : calendars.find((calendar) => calendar.id === selectedCalendarId) ?? null; + const selectableCalendars = currentCalendar && !writableCalendars.some((calendar) => calendar.id === currentCalendar.id) + ? [currentCalendar, ...writableCalendars] + : writableCalendars; + const canSaveToCalendar = selectedCalendarId !== null && ( + Boolean(editingEvent.id) + || writableCalendars.some((calendar) => calendar.id === selectedCalendarId) + ); + const footerContent = (
+ + {selectedCalendar.can_manage_notification_settings ? ( + + ) : null} + {reorderEnabled ? ( + + ) : null} +
setGroupFilter(key)} + onClick={() => selectGroupFilter(key)} className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${ active ? "app-pill-active" : "app-pill" }`} @@ -686,7 +960,7 @@ export default function TaskBoardNavigator({ > +