Implement application updates and supporting changes

This commit is contained in:
lizerk 2026-08-24 12:51:58 +03:00
parent f6904ca2dd
commit 87a9a6fd08
36 changed files with 2920 additions and 624 deletions

View File

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

View File

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

View File

@ -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<user_id>[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):
"""Получить список всех участников календаря."""

View File

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

View File

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

View File

@ -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"])

View File

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

View File

@ -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="Уведомлять о создании события",
),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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="Добавлено"),
),
]

View File

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

View File

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

View File

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

View File

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

View File

@ -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<GuestVisit[]>([]);
const [guests, setGuests] = useState<Guest[]>([]);
const [documents, setDocuments] = useState<Document[]>([]);
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
const [comments, setComments] = useState<Record<number, GuestVisitComment[]>>({});
const [guestComments, setGuestComments] = useState<Record<number, GuestVisitComment[]>>({});
const [scopeCounts, setScopeCounts] = useState<Record<VisitScope, number>>({
@ -442,6 +444,33 @@ function GuestsPageContent() {
const openedVisitParamRef = useRef<string | null>(null);
const openedGuestParamRef = useRef<string | null>(null);
const visitMenuRef = useRef<HTMLDivElement | null>(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}
/>
<GuestPhotoModal
@ -1584,11 +1617,25 @@ function GuestsPageContent() {
}}
onReloadDocuments={loadDocuments}
onRemoveDocument={removeGuestProfileDocument}
onOpenDocument={openDocumentDetails}
onSubmit={saveGuestEdit}
onUploadDocument={uploadGuestProfileDocument}
setForm={setGuestEditForm}
/>
<DocumentDetailModal
document={selectedDocument}
isOpen={Boolean(selectedDocument)}
onClose={() => {
documentRequestRef.current += 1;
setSelectedDocument(null);
}}
onUpdate={() => void refreshSelectedDocument()}
onNavigateToRelated={(documentId) => {
void openDocumentDetails(documentId);
}}
/>
<FeedbackModal
message={actionError}
title="Проверьте данные"
@ -1792,6 +1839,32 @@ function LinkedTaskPills({
);
}
function GuestDocumentButton({
document,
onOpen,
className = "app-badge max-w-[150px] rounded-full px-2 py-1 text-[11px] lg:max-w-[180px]",
}: {
document: Document;
onOpen: (document: Document) => void | Promise<void>;
className?: string;
}) {
const label = document.title || document.file_name || `Документ #${document.id}`;
return (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void onOpen(document);
}}
className={`inline-flex min-w-0 items-center gap-1 text-left font-medium transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] ${className}`}
title={`Открыть документ: ${label}`}
>
<FileText size={12} className="shrink-0" />
<span className="min-w-0 truncate">{label}</span>
</button>
);
}
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<void>;
onOpenDocument: (document: Document) => void | Promise<void>;
onOpenGuestPhoto: (guest: Guest) => void;
onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise<void>;
onToggleComments: (visit: GuestVisit) => void | Promise<void>;
@ -1904,36 +1979,13 @@ function GuestVisitRow({
<LinkedTaskPills tasks={visit.linked_tasks} className="mt-2" />
{visit.documents?.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{visit.documents.map((document) => {
const label = document.title || document.file_name || `Документ #${document.id}`;
const content = (
<>
<FileText size={12} className="shrink-0" />
<span className="min-w-0 truncate">{label}</span>
</>
);
return document.file_url ? (
<a
key={document.id}
href={resolveMediaUrl(document.file_url)}
target="_blank"
rel="noreferrer"
className="app-badge inline-flex max-w-[150px] items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium no-underline transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] hover:no-underline lg:max-w-[180px]"
title={label}
>
{content}
</a>
) : (
<span
key={document.id}
className="app-badge inline-flex max-w-[150px] items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium lg:max-w-[180px]"
title={label}
>
{content}
</span>
);
})}
{visit.documents.map((document) => (
<GuestDocumentButton
key={document.id}
document={document}
onOpen={onOpenDocument}
/>
))}
</div>
) : null}
<VisitQuickActionButtons
@ -3053,6 +3105,7 @@ function GuestVisitDetailModal({
onDeleteComment,
onEdit,
onLinkTask,
onOpenDocument,
onRunAction,
}: {
busyKey: string | null;
@ -3064,6 +3117,7 @@ function GuestVisitDetailModal({
onDeleteComment: (visit: GuestVisit, commentId: number) => void | Promise<void>;
onEdit: (visit: GuestVisit) => void;
onLinkTask: (visit: GuestVisit) => void;
onOpenDocument: (document: Document) => void | Promise<void>;
onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise<void>;
}) {
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
@ -3214,36 +3268,14 @@ function GuestVisitDetailModal({
</div>
{visit.documents.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{visit.documents.map((doc) => {
const label = doc.title || doc.file_name || `Документ #${doc.id}`;
const content = (
<>
<FileText size={12} className="shrink-0" />
<span className="min-w-0 truncate">{label}</span>
</>
);
return doc.file_url ? (
<a
key={doc.id}
href={resolveMediaUrl(doc.file_url)}
target="_blank"
rel="noreferrer"
className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium no-underline transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] hover:no-underline"
title={label}
>
{content}
</a>
) : (
<span
key={doc.id}
className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium"
title={label}
>
{content}
</span>
);
})}
{visit.documents.map((document) => (
<GuestDocumentButton
key={document.id}
document={document}
onOpen={onOpenDocument}
className="app-badge max-w-full rounded-full px-2 py-1 text-[11px]"
/>
))}
</div>
) : (
<p className="app-text-muted text-xs">Документы не прикреплены.</p>
@ -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<void>;
onEditGuest: (guest: Guest) => void;
onLinkTask: (guest: Guest) => void;
onOpenDocument: (document: Document) => void | Promise<void>;
onOpenGuest: (guest: Guest) => void;
onOpenGuestPhoto: (guest: Guest) => void;
onOpenGuestVisits: (guest: Guest) => void;
@ -3784,35 +3818,13 @@ function GuestRegistryPanel({
</div>
{guest.documents?.length ? (
<div className="mt-1 flex flex-wrap gap-1.5 pl-[60px]">
{(guest.documents || []).map((document) => {
const label = document.title || document.file_name || `Документ #${document.id}`;
const content = (
<>
<FileText size={12} className="shrink-0" />
<span className="min-w-0 truncate">{label}</span>
</>
);
return document.file_url ? (
<a
key={`doc-${document.id}`}
href={resolveMediaUrl(document.file_url)}
target="_blank"
rel="noreferrer"
className="app-badge inline-flex max-w-[150px] items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium no-underline transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] hover:no-underline lg:max-w-[180px]"
title={label}
>
{content}
</a>
) : (
<span
key={`doc-${document.id}`}
className="app-badge inline-flex max-w-[150px] items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium lg:max-w-[180px]"
title={label}
>
{content}
</span>
);
})}
{(guest.documents || []).map((document) => (
<GuestDocumentButton
key={`doc-${document.id}`}
document={document}
onOpen={onOpenDocument}
/>
))}
</div>
) : 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<void>;
}) {
if (!guest) return null;
@ -4071,35 +4085,13 @@ function GuestDetailModal({
</div>
{guest.documents?.length ? (
<div className="flex flex-wrap gap-1.5">
{guest.documents.map((document) => {
const label = document.title || document.file_name || `Документ #${document.id}`;
const content = (
<>
<FileText size={12} className="shrink-0" />
<span className="min-w-0 truncate">{label}</span>
</>
);
return document.file_url ? (
<a
key={document.id}
href={resolveMediaUrl(document.file_url)}
target="_blank"
rel="noreferrer"
className="app-badge inline-flex max-w-[180px] items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium no-underline transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] hover:no-underline"
title={label}
>
{content}
</a>
) : (
<span
key={document.id}
className="app-badge inline-flex max-w-[180px] items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium"
title={label}
>
{content}
</span>
);
})}
{guest.documents.map((document) => (
<GuestDocumentButton
key={document.id}
document={document}
onOpen={onOpenDocument}
/>
))}
</div>
) : (
<p className="app-text-muted text-sm">Документы не прикреплены.</p>
@ -4121,6 +4113,7 @@ function GuestEditModal({
onClose,
onReloadDocuments,
onRemoveDocument,
onOpenDocument,
onSubmit,
onUploadDocument,
setForm,
@ -4135,6 +4128,7 @@ function GuestEditModal({
onClose: () => void;
onReloadDocuments: () => void | Promise<void>;
onRemoveDocument: (guest: Guest, documentId: number) => void | Promise<void>;
onOpenDocument: (document: Document) => void | Promise<void>;
onSubmit: () => void | Promise<void>;
onUploadDocument: (guest: Guest, file: File) => void | Promise<void>;
setForm: (updater: GuestEditState | null | ((current: GuestEditState | null) => GuestEditState | null)) => void;
@ -4239,23 +4233,11 @@ function GuestEditModal({
<div className="space-y-2">
{(guest.documents || []).map((document) => (
<div key={document.id} className="flex items-center gap-2">
{document.file_url ? (
<a
href={resolveMediaUrl(document.file_url)}
target="_blank"
rel="noreferrer"
className="app-input min-w-0 flex-1 truncate rounded-lg px-3 py-2 text-sm transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]"
title={document.title || document.file_name || `Документ #${document.id}`}
>
{document.title || document.file_name || `Документ #${document.id}`}
</a>
) : (
<input
readOnly
value={document.title || document.file_name || `Документ #${document.id}`}
className="app-input min-w-0 flex-1 rounded-lg px-3 py-2 text-sm"
/>
)}
<GuestDocumentButton
document={document}
onOpen={onOpenDocument}
className="app-input min-w-0 flex-1 rounded-lg px-3 py-2 text-sm"
/>
<button
type="button"
onClick={() => void onRemoveDocument(guest, document.id)}

View File

@ -932,44 +932,88 @@ export default function ProcurementPage() {
<section className={`app-surface rounded-2xl p-4 ${desktopWideMode ? "lg:min-h-0 lg:flex-1 lg:flex lg:flex-col lg:overflow-hidden" : ""}`}>
{/* ── header ── */}
<AutoRevealStickyHeader
className="rounded-b-xl"
className="shrink-0 rounded-b-xl"
disableOnDesktop={desktopWideMode}
>
<div className="mb-4 flex min-w-0 flex-nowrap items-center justify-between gap-2">
<div className="shrink-0">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Закупки</p>
{/* <div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => setActiveSection("requests")} className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${activeSection === "requests" ? "app-pill-active" : "app-pill"}`}>
Заявки
<div className="mb-4 flex min-w-0 flex-nowrap items-center justify-between gap-2">
<div className="shrink-0">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Закупки</p>
</div>
<div className="ml-auto flex min-w-0 shrink items-center gap-2">
<button
type="button"
onClick={() => changeDesktopWideMode(!desktopWideMode)}
className="app-action-ghost hidden h-9 w-9 items-center justify-center rounded-lg lg:inline-flex"
title={desktopWideMode ? "Вернуть обычный вид" : "Развернуть закупки"}
aria-label={desktopWideMode ? "Вернуть обычный вид" : "Развернуть закупки"}
aria-pressed={desktopWideMode}
>
{desktopWideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
<button type="button" onClick={() => setActiveSection("stats")} className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${activeSection === "stats" ? "app-pill-active" : "app-pill"}`}>
Статистика
</button>
<button type="button" onClick={() => setActiveSection("suppliers")} className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${activeSection === "suppliers" ? "app-pill-active" : "app-pill"}`}>
Поставщики
</button>
</div> */}
{activeSection === "requests" && (
<ModulePrimaryAction
onClick={openCreate}
icon={<Plus size={14} />}
label="Создать заявку"
/>
)}
</div>
</div>
<div className="ml-auto flex min-w-0 shrink items-center gap-2">
<button
type="button"
onClick={() => changeDesktopWideMode(!desktopWideMode)}
className="app-action-ghost hidden h-9 w-9 items-center justify-center rounded-lg lg:inline-flex"
title={desktopWideMode ? "Вернуть обычный вид" : "Развернуть закупки"}
aria-label={desktopWideMode ? "Вернуть обычный вид" : "Развернуть закупки"}
aria-pressed={desktopWideMode}
>
{desktopWideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
{activeSection === "requests" && (
<ModulePrimaryAction
onClick={openCreate}
icon={<Plus size={14} />}
label="Создать заявку"
/>
)}
</div>
</div>
{activeSection === "requests" ? (
<>
<div className="mb-4 flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
value={searchQuery}
onChange={(event) => 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"
/>
</div>
<button
id="procurement-filter-trigger"
type="button"
title="Фильтры"
onClick={() => setFiltersOpen((current) => !current)}
className={`relative inline-flex items-center justify-center rounded-lg p-2.5 transition ${filtersOpen ? "app-selected app-accent-text" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"}`}
>
<Filter size={16} />
{activeFilterCount > 0 ? (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-bold text-white">{activeFilterCount}</span>
) : null}
</button>
<CompactSortMenu
value={ordering}
options={orderingOptions}
onChange={setOrdering}
ariaLabel="Сортировка списка закупок"
/>
</div>
<HorizontalChipScroller className="mb-4" ariaLabel="Разделы закупок">
{scopeTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setScope(tab.value)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
scope === tab.value ? "app-pill-active" : "app-pill"
}`}
>
<span>{tab.label}</span>
<span className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
scope === tab.value ? "app-pill-count-active" : "app-pill-count"
}`}>
{scopeCounts[tab.value] ?? 0}
</span>
</button>
))}
</HorizontalChipScroller>
</>
) : null}
</AutoRevealStickyHeader>
{/* ── alerts ── */}
@ -983,61 +1027,6 @@ export default function ProcurementPage() {
{activeSection === "requests" && (
<>
{/* ── search + filter ── */}
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
value={searchQuery}
onChange={(e) => 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"
/>
</div>
<button
id="procurement-filter-trigger"
type="button"
title="Фильтры"
onClick={() => setFiltersOpen((v) => !v)}
className={`relative inline-flex items-center justify-center rounded-lg p-2.5 transition ${filtersOpen ? "app-selected app-accent-text" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"}`}
>
<Filter size={16} />
{activeFilterCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-bold text-white">{activeFilterCount}</span>
)}
</button>
<CompactSortMenu
value={ordering}
options={orderingOptions}
onChange={setOrdering}
ariaLabel="Сортировка списка закупок"
/>
</div>
{/* ── scope tabs ── */}
<HorizontalChipScroller className="mb-4" ariaLabel="Разделы закупок">
{scopeTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setScope(tab.value)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
scope === tab.value
? "app-pill-active"
: "app-pill"
}`}
>
<span>{tab.label}</span>
<span className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
scope === tab.value ? "app-pill-count-active" : "app-pill-count"
}`}>
{scopeCounts[tab.value] ?? 0}
</span>
</button>
))}
</HorizontalChipScroller>
{/* ── filters panel ── */}
<CompactFilterPanel open={filtersOpen} anchorId="procurement-filter-trigger" onClose={() => setFiltersOpen(false)} ariaLabel="Фильтры закупок">
<div className="flex flex-col gap-2">

View File

@ -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(
<div
ref={menuRef}
className="app-menu fixed z-[220] w-60 overflow-y-auto overscroll-contain rounded-xl p-1.5 shadow-xl"
className="app-menu fixed w-60 overflow-y-auto overscroll-contain rounded-xl p-1.5 shadow-xl"
style={{
left: position.left,
top: position.top,
bottom: position.bottom,
maxHeight: position.maxHeight,
zIndex: TASK_BOARD_LAYERS.modalPopover,
}}
>
{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<HTMLButtonElement | null>;
menuRef: RefObject<HTMLDivElement | null>;
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({
<div
ref={menuRef}
role="menu"
className="app-menu fixed z-[240] w-44 overflow-y-auto overscroll-contain rounded-lg p-1.5 shadow-xl"
className={`app-menu fixed ${widthClassName} overflow-y-auto overscroll-contain rounded-lg p-1.5 shadow-xl`}
style={{
left: position.left,
top: position.top,
bottom: position.bottom,
maxHeight: position.maxHeight,
zIndex,
}}
>
{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<HTMLDivElement | null>;
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<string | null>(null);
const [boardAvatarFileName, setBoardAvatarFileName] = useState("board-avatar.jpg");
const [boardGroupModalOpen, setBoardGroupModalOpen] = useState(false);
const [managedBoardGroup, setManagedBoardGroup] = useState<TaskBoardGroup | null>(null);
const [managedBoardGroupIds, setManagedBoardGroupIds] = useState<number[]>([]);
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<number | null>(null);
const boardActionsMenuRef = useRef<HTMLDivElement | null>(null);
const taskMenuRef = useRef<HTMLDivElement | null>(null);
const taskMenuAnchorRef = useRef<HTMLButtonElement | null>(null);
const taskMenuPopoverRef = useRef<HTMLDivElement | null>(null);
const taskAddMenuRef = useRef<HTMLDivElement | null>(null);
const taskAddMenuBottomRef = useRef<HTMLDivElement | null>(null);
const taskAddMenuPopoverRef = useRef<HTMLDivElement | null>(null);
const viewColumnMenuRef = useRef<HTMLDivElement | null>(null);
const viewColumnMenuAnchorRef = useRef<HTMLButtonElement | null>(null);
const viewColumnMenuPopoverRef = useRef<HTMLDivElement | null>(null);
const cardMenuAnchorRef = useRef<HTMLButtonElement | null>(null);
const cardMenuRef = useRef<HTMLDivElement | null>(null);
const columnMenuRef = useRef<HTMLDivElement | null>(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 = <T extends {
id: number;
group_positions?: Record<string, number>;
group_sort_keys?: Record<string, string>;
}>(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() {
<AutoRevealStickyHeader
className="rounded-b-2xl"
disableOnDesktop={desktopWideMode}
style={boardActionsMenuOpen ? { zIndex: TASK_BOARD_LAYERS.boardHeaderMenu } : undefined}
>
<section className="app-surface relative z-[35] shrink-0 overflow-visible rounded-2xl p-4">
<div className="flex items-center justify-between gap-3">
@ -7591,7 +7773,7 @@ function TasksPageContent() {
</button>
{boardActionsMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-[100] mt-2 w-56 rounded-xl py-1.5">
<div className="app-menu absolute right-0 top-full z-[60] mt-2 w-56 rounded-xl py-1.5">
<button
type="button"
onClick={() => openEditBoard()}
@ -7949,6 +8131,7 @@ function TasksPageContent() {
onEditColumn={openEditColumn}
onDeleteColumn={deleteColumn}
columnMenuOpen={columnMenuId === column.id}
menuLayerOpen={columnMenuId === column.id || cellColumns.some((item) => item.id === columnMenuId)}
columnMenuRef={columnMenuRef}
onToggleColumnMenu={() => toggleColumnMenu(column.id)}
onColumnMount={registerColumnNode}
@ -8046,13 +8229,13 @@ function TasksPageContent() {
</div>
</div>
</SortableContext>
<DragOverlay zIndex={1000} dropAnimation={null}>
<DragOverlay zIndex={TASK_BOARD_LAYERS.pagePopover} dropAnimation={null}>
{activeDragTask ? <TaskDragOverlayCard task={activeDragTask} /> : null}
</DragOverlay>
</DndContext>
{cardMenuTask ? (
<TaskCardMenuPopover
<TaskAnchoredMenuPopover
anchorRef={cardMenuAnchorRef}
menuRef={cardMenuRef}
>
@ -8074,7 +8257,7 @@ function TasksPageContent() {
<Trash2 size={14} />
Удалить
</button>
</TaskCardMenuPopover>
</TaskAnchoredMenuPopover>
) : null}
</>
) : requestedBoardId > 0 && !error ? (
@ -8498,6 +8681,117 @@ function TasksPageContent() {
</div>
</Modal>
<Modal
isOpen={Boolean(managedBoardGroup)}
onClose={closeManageBoardGroup}
title={managedBoardGroup ? `Доски группы «${managedBoardGroup.name}»` : "Управление досками"}
size="sm"
closeOnClickOutside
footer={(
<div className="flex justify-end gap-2">
<button
type="button"
onClick={closeManageBoardGroup}
className="app-action-secondary rounded-xl px-4 py-2 text-sm font-medium"
disabled={saving}
>
Отмена
</button>
<button
type="button"
onClick={() => void saveManagedBoardGroup()}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium disabled:opacity-60"
disabled={saving}
>
{saving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
Сохранить
</button>
</div>
)}
>
<div className="space-y-3">
<div className="relative">
<Search
size={15}
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
/>
<input
type="search"
value={managedBoardGroupSearch}
onChange={(event) => setManagedBoardGroupSearch(event.target.value)}
className="app-input w-full rounded-xl py-2.5 pl-9 pr-3 text-sm"
placeholder="Поиск досок"
aria-label="Поиск досок группы"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="app-text-muted text-xs">
Выбрано: {managedBoardGroupIds.length} из {boards.length}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setManagedBoardGroupIds(boards.map((item) => item.id))}
className="app-link-accent text-xs font-medium"
disabled={saving || boards.length === 0}
>
Выбрать все
</button>
<button
type="button"
onClick={() => setManagedBoardGroupIds([])}
className="app-text-muted text-xs font-medium hover:text-[var(--foreground)]"
disabled={saving || managedBoardGroupIds.length === 0}
>
Очистить
</button>
</div>
</div>
<div className="tasks-column-scroll app-surface-muted max-h-[min(24rem,55vh)] space-y-1 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-2">
{boards.filter((item) => {
const query = managedBoardGroupSearch.trim().toLowerCase();
if (!query) return true;
return `${item.name} ${item.description || ""}`.toLowerCase().includes(query);
}).map((item) => (
<label
key={item.id}
className="flex cursor-pointer items-center gap-3 rounded-xl px-2.5 py-2 transition hover:bg-[var(--surface-elevated)]"
>
<input
type="checkbox"
checked={managedBoardGroupIds.includes(item.id)}
onChange={() => toggleManagedBoard(item.id)}
className="app-checkbox shrink-0"
disabled={saving}
/>
<TaskBoardAvatar name={item.name} src={item.avatar} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--foreground)]">
{item.name}
</span>
<span className="app-text-muted block truncate text-xs">
{item.open_tasks_count} открыто · {item.tasks_count} всего
</span>
</span>
</label>
))}
{boards.length === 0 ? (
<div className="px-3 py-8 text-center">
<Kanban size={22} className="app-text-muted mx-auto mb-2" />
<p className="app-text-muted text-sm">Доступных досок пока нет</p>
</div>
) : boards.every((item) => {
const query = managedBoardGroupSearch.trim().toLowerCase();
return query && !`${item.name} ${item.description || ""}`.toLowerCase().includes(query);
}) ? (
<p className="app-text-muted px-3 py-8 text-center text-sm">Доски не найдены</p>
) : null}
</div>
</div>
</Modal>
<Modal
isOpen={columnModalOpen}
onClose={closeColumnModal}
@ -8780,6 +9074,7 @@ function TasksPageContent() {
{viewTaskColumn ? (
<div ref={viewColumnMenuRef} className="relative">
<button
ref={viewColumnMenuAnchorRef}
type="button"
onClick={() => setViewColumnMenuOpen((current) => !current)}
className="app-pill inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition hover:border-[var(--border-strong)] disabled:cursor-not-allowed disabled:opacity-60"
@ -8801,7 +9096,13 @@ function TasksPageContent() {
</button>
{viewColumnMenuOpen ? (
<div className="app-menu absolute left-0 top-full z-30 mt-2 w-56 rounded-xl p-1.5">
<TaskAnchoredMenuPopover
anchorRef={viewColumnMenuAnchorRef}
menuRef={viewColumnMenuPopoverRef}
menuWidth={224}
widthClassName="w-56"
zIndex={TASK_BOARD_LAYERS.modalPopover}
>
{leafColumns.map((column) => {
const selected = column.id === viewTask.column;
const parent = column.parent
@ -8832,7 +9133,7 @@ function TasksPageContent() {
</button>
);
})}
</div>
</TaskAnchoredMenuPopover>
) : null}
</div>
) : null}
@ -8912,6 +9213,7 @@ function TasksPageContent() {
<div ref={taskMenuRef} className="relative shrink-0">
<button
ref={taskMenuAnchorRef}
type="button"
onClick={() => setTaskMenuOpen((current) => !current)}
className="app-icon-button flex h-9 w-9 items-center justify-center rounded-lg"
@ -8926,7 +9228,13 @@ function TasksPageContent() {
/>
</button>
{taskMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-48 rounded-lg p-1.5">
<TaskAnchoredMenuPopover
anchorRef={taskMenuAnchorRef}
menuRef={taskMenuPopoverRef}
menuWidth={192}
widthClassName="w-48"
zIndex={TASK_BOARD_LAYERS.modalPopover}
>
<button
type="button"
onClick={() => openEditTask(viewTask)}
@ -8944,7 +9252,7 @@ function TasksPageContent() {
<Trash2 size={14} />
Удалить
</button>
</div>
</TaskAnchoredMenuPopover>
) : null}
</div>
</div>

View File

@ -69,7 +69,7 @@ export default function AvatarCropper({
}, [croppedAreaPixels, initialImage, onCropComplete]);
return (
<Modal isOpen onClose={onCancel} noHeader noPadding size="lg">
<Modal isOpen onClose={onCancel} noHeader noPadding size="lg" stackLevel={1}>
{/* Заголовок */}
<div className="app-divider border-b px-6 py-4">
<h3 className="text-lg font-semibold text-[var(--foreground)]">

View File

@ -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<HTMLInputElement>(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() {
<Users size={16} />
Участники
</button>
<button
type="button"
onClick={() => {
if (participantsModalAvailable) {
setParticipantsInitialTab("roles");
setShowParticipantsModal(true);
}
setShowCalendarActions(false);
}}
disabled={!participantsModalAvailable}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
>
<ShieldCheck size={16} />
Роли
</button>
{selectedCalendar.can_manage_notification_settings ? (
<button
type="button"
onClick={() => {
setShowNotificationSettings(true);
setShowCalendarActions(false);
}}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Bell size={16} />
Уведомления
</button>
) : null}
<button
type="button"
onClick={handleExportCalendar}
@ -772,8 +795,18 @@ export function BigCalendar() {
calendarType={selectedCalendar.type}
contextType={selectedCalendar.context_type}
initialAccessScope={selectedCalendar.access_scope}
initialTab={participantsInitialTab}
/>
)}
{selectedCalendar ? (
<CalendarNotificationSettingsModal
isOpen={showNotificationSettings}
onClose={() => setShowNotificationSettings(false)}
calendarId={selectedCalendar.id}
calendarName={selectedCalendar.name}
/>
) : null}
</>
);
}

View File

@ -0,0 +1,165 @@
"use client";
import { useEffect, useState } from "react";
import { Bell, CalendarPlus2, CalendarSync, CalendarX2 } from "lucide-react";
import { Modal } from "@/components/ui";
import api from "@/lib/api";
import type { CalendarNotificationSettings } from "@/types/api";
type EditableSettings = Omit<CalendarNotificationSettings, "can_manage">;
const defaultSettings: EditableSettings = {
notify_event_created: true,
notify_event_changed: true,
notify_event_cancelled: true,
};
const rules = [
{
key: "notify_event_created" as const,
title: "Создание события",
description: "Сообщать участникам, когда в календаре появляется новое событие.",
icon: CalendarPlus2,
},
{
key: "notify_event_changed" as const,
title: "Изменение события",
description: "Сообщать об изменении названия, времени, описания и других важных полей.",
icon: CalendarSync,
},
{
key: "notify_event_cancelled" as const,
title: "Удаление события",
description: "Сообщать участникам об отмене или удалении события.",
icon: CalendarX2,
},
];
interface CalendarNotificationSettingsModalProps {
isOpen: boolean;
onClose: () => void;
calendarId: number;
calendarName: string;
}
export default function CalendarNotificationSettingsModal({
isOpen,
onClose,
calendarId,
calendarName,
}: CalendarNotificationSettingsModalProps) {
const [settings, setSettings] = useState<EditableSettings>(defaultSettings);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
const loadTimer = window.setTimeout(() => {
setLoading(true);
setError(null);
void api.getCalendarNotificationSettings(calendarId)
.then((data) => {
if (cancelled) return;
setSettings({
notify_event_created: data.notify_event_created,
notify_event_changed: data.notify_event_changed,
notify_event_cancelled: data.notify_event_cancelled,
});
})
.catch((loadError) => {
console.error("Failed to load calendar notification settings:", loadError);
if (!cancelled) setError("Не удалось загрузить правила уведомлений.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
}, 0);
return () => {
cancelled = true;
window.clearTimeout(loadTimer);
};
}, [calendarId, isOpen]);
const handleSave = async () => {
setSaving(true);
setError(null);
try {
const result = await api.updateCalendarNotificationSettings(calendarId, settings);
setSettings({
notify_event_created: result.notify_event_created,
notify_event_changed: result.notify_event_changed,
notify_event_cancelled: result.notify_event_cancelled,
});
onClose();
} catch (saveError) {
console.error("Failed to update calendar notification settings:", saveError);
setError("Не удалось сохранить правила уведомлений.");
} finally {
setSaving(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Уведомления календаря"
size="sm"
closeOnClickOutside={!saving}
footer={
<div className="flex justify-end gap-2 pb-4">
<button type="button" onClick={onClose} disabled={saving} className="app-action-secondary rounded-xl px-4 py-2 text-sm font-medium disabled:opacity-50">
Отмена
</button>
<button type="button" onClick={() => void handleSave()} disabled={loading || saving} className="app-action-primary rounded-xl px-4 py-2 text-sm font-medium disabled:opacity-50">
{saving ? "Сохранение…" : "Сохранить"}
</button>
</div>
}
>
<div className="space-y-4 pb-2">
<div className="app-surface-muted flex items-center gap-3 rounded-xl p-3">
<div className="app-avatar-fallback flex h-11 w-11 shrink-0 items-center justify-center rounded-xl">
<Bell size={20} />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{calendarName}</p>
<p className="app-text-muted mt-0.5 text-xs">Общие правила для всех участников календаря</p>
</div>
</div>
{error ? <div className="app-feedback-danger rounded-xl p-3 text-sm">{error}</div> : null}
<div className="space-y-2" aria-busy={loading}>
{rules.map((rule) => {
const Icon = rule.icon;
return (
<label key={rule.key} className={`${loading ? "opacity-60" : "cursor-pointer"} flex items-center justify-between gap-4 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-primary)] p-3 transition hover:border-[var(--border-strong)]`}>
<span className="flex min-w-0 items-start gap-3">
<Icon size={18} className="app-accent-text mt-0.5 shrink-0" />
<span className="min-w-0">
<span className="block text-sm font-medium">{rule.title}</span>
<span className="app-text-muted mt-0.5 block text-xs leading-4">{rule.description}</span>
</span>
</span>
<span className="relative inline-flex shrink-0 items-center">
<input
type="checkbox"
checked={settings[rule.key]}
disabled={loading || saving}
onChange={(event) => setSettings((current) => ({ ...current, [rule.key]: event.target.checked }))}
className="peer sr-only"
/>
<span className="h-6 w-11 rounded-full bg-[var(--surface-tertiary)] transition after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:shadow-sm after:transition-all after:content-[''] peer-checked:bg-[var(--accent-primary)] peer-checked:after:translate-x-full peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--accent-primary)] peer-disabled:opacity-50" />
</span>
</label>
);
})}
</div>
</div>
</Modal>
);
}

View File

@ -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<string, string> = {
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 <Image src={resolveMediaUrl(user.avatar)} alt={fullName} width={36} height={36} unoptimized className="app-avatar-frame h-9 w-9 shrink-0 rounded-full object-cover" />;
}
return <div className="app-avatar-fallback flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-medium">{user?.first_name?.[0] || user?.last_name?.[0] || "?"}</div>;
}
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<ModalTab>(initialTab);
const [participants, setParticipants] = useState<CalendarParticipant[]>([]);
const [employees, setEmployees] = useState<User[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const [selectedRole, setSelectedRole] = useState<string>('viewer');
const [selectedDepartment, setSelectedDepartment] = useState<string>('');
const [selectedRole, setSelectedRole] = useState<CalendarParticipantRole>("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<number | null>(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<User>((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<User>((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 (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Участники календаря"
size="md"
closeOnClickOutside
footer={<div className="flex justify-end pb-4"><button type="button" onClick={handleClose} className="app-action-secondary rounded-xl px-4 py-2 text-sm font-medium">Закрыть</button></div>}
>
<Modal isOpen={isOpen} onClose={handleClose} title="Управление календарём" size="md" closeOnClickOutside footer={<div className="flex justify-end pb-4"><button type="button" onClick={handleClose} className="app-action-secondary rounded-xl px-4 py-2 text-sm font-medium">Закрыть</button></div>}>
<div className="space-y-4 pb-2">
<div className="app-surface-muted flex items-center gap-3 rounded-xl p-3">
<div className="app-avatar-fallback flex h-11 w-11 shrink-0 items-center justify-center rounded-xl">
<CalendarDays size={20} />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--foreground)]">{calendarName}</p>
<p className="app-text-muted mt-0.5 text-xs">
{isDepartmentCalendar ? 'Состав определяется отделом' : accessScope === 'all' ? 'Доступ для всей компании' : 'Доступ выбранным участникам'}
</p>
</div>
<div className="app-avatar-fallback flex h-11 w-11 shrink-0 items-center justify-center rounded-xl"><CalendarDays size={20} /></div>
<div className="min-w-0"><p className="truncate text-sm font-semibold">{calendarName}</p><p className="app-text-muted mt-0.5 text-xs">{isDepartmentCalendar ? "Состав определяется отделом" : accessScope === "all" ? "Доступ для всей компании" : "Доступ выбранным участникам"}</p></div>
</div>
{notice ? <div className={`${notice.tone === 'success' ? 'app-feedback-success' : 'app-feedback-danger'} rounded-xl p-3 text-sm`}>{notice.text}</div> : null}
<div className="grid grid-cols-2 gap-2" role="tablist" aria-label="Управление участниками календаря">
<button type="button" role="tab" aria-selected={activeTab === "participants"} onClick={() => setActiveTab("participants")} className={`${activeTab === "participants" ? "app-selected border-[var(--accent-primary)]" : "app-action-secondary"} flex items-center justify-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium`}><Users size={16} />Участники</button>
<button type="button" role="tab" aria-selected={activeTab === "roles"} onClick={() => { setActiveTab("roles"); setSelectedRole("editor"); setSelectedUserIds([]); setSelectedDepartment(""); setSearchQuery(""); }} className={`${activeTab === "roles" ? "app-selected border-[var(--accent-primary)]" : "app-action-secondary"} flex items-center justify-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium`}><ShieldCheck size={16} />Роли</button>
</div>
{isDepartmentCalendar ? (
<div className="app-surface-muted flex items-start gap-3 rounded-xl p-3">
<Building2 size={18} className="app-accent-text mt-0.5 shrink-0" />
<p className="app-text-muted text-sm">Участники синхронизируются автоматически из состава отдела и назначенных ролей.</p>
{notice ? <div className={`${notice.tone === "success" ? "app-feedback-success" : "app-feedback-danger"} rounded-xl p-3 text-sm`}>{notice.text}</div> : null}
{activeTab === "participants" ? (
<div className="space-y-4" role="tabpanel">
{isDepartmentCalendar ? <div className="app-surface-muted flex items-start gap-3 rounded-xl p-3"><Building2 size={18} className="app-accent-text mt-0.5 shrink-0" /><p className="app-text-muted text-sm">Участники синхронизируются автоматически из состава отдела и назначенных ролей.</p></div> : canManage ? (
<section><p className="app-card-caption mb-2">Доступ</p><div className="grid gap-2 sm:grid-cols-2">
<button type="button" onClick={() => void handleAccessScopeChange("all")} disabled={accessUpdating} className={`${accessScope === "all" ? "app-selected border-[var(--accent-primary)]" : "border-[var(--border-subtle)] hover:border-[var(--border-strong)]"} flex items-start gap-3 rounded-xl border p-3 text-left transition disabled:opacity-60`}><Users size={18} className="app-accent-text mt-0.5 shrink-0" /><span><span className="block text-sm font-semibold">Вся компания</span><span className="app-text-muted mt-1 block text-xs">Просмотр доступен всем сотрудникам</span></span>{accessScope === "all" ? <Check size={16} className="app-accent-text ml-auto shrink-0" /> : null}</button>
<button type="button" onClick={() => void handleAccessScopeChange("restricted")} disabled={accessUpdating} className={`${accessScope === "restricted" ? "app-selected border-[var(--accent-primary)]" : "border-[var(--border-subtle)] hover:border-[var(--border-strong)]"} flex items-start gap-3 rounded-xl border p-3 text-left transition disabled:opacity-60`}><UserRoundCheck size={18} className="app-accent-text mt-0.5 shrink-0" /><span><span className="block text-sm font-semibold">Выборочно</span><span className="app-text-muted mt-1 block text-xs">Только указанные участники</span></span>{accessScope === "restricted" ? <Check size={16} className="app-accent-text ml-auto shrink-0" /> : null}</button>
</div></section>
) : null}
{canManage && accessScope === "restricted" ? (
<section className="app-surface-muted space-y-3 rounded-xl p-3 sm:p-4">
<div className="flex flex-wrap items-center justify-between gap-2"><div><p className="text-sm font-semibold">Добавить участников</p><p className="app-text-muted mt-0.5 text-xs">Выбрано: {selectedUserIds.length}</p></div><div className="flex gap-2"><button type="button" onClick={() => setSelectedUserIds(availableEmployees.map((employee) => employee.id))} disabled={!availableEmployees.length} className="app-badge rounded-full px-2.5 py-1 text-xs disabled:opacity-50">Выбрать всех</button><button type="button" onClick={() => setSelectedUserIds([])} disabled={!selectedUserIds.length} className="app-badge rounded-full px-2.5 py-1 text-xs disabled:opacity-50">Снять выбор</button></div></div>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(11rem,0.45fr)]"><label className="relative min-w-0"><Search size={15} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" /><input type="search" placeholder="Поиск сотрудников" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} className="app-input w-full rounded-xl py-2 pl-9 pr-3 text-sm" /></label><ProjectSelectMenu value={selectedDepartment} onChange={(value) => { setSelectedDepartment(value); setSelectedUserIds([]); }} ariaLabel="Отдел участников" options={[{ value: "", label: "Все отделы" }, ...departments.map((department) => ({ value: String(department.id), label: department.name }))]} /></div>
<div className="tasks-column-scroll app-surface max-h-60 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-1">
{availableEmployees.length ? availableEmployees.map((employee) => {
const selected = selectedUserIds.includes(employee.id);
const fullName = `${employee.first_name || ""} ${employee.last_name || ""}`.trim() || employee.username || "Сотрудник";
return <label key={employee.id} className={`${selected ? "app-selected" : "hover:bg-[var(--surface-secondary)]"} flex cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 transition`}><input type="checkbox" checked={selected} onChange={() => setSelectedUserIds((current) => current.includes(employee.id) ? current.filter((id) => id !== employee.id) : [...current, employee.id])} className="h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)]" />{employee.avatar ? <Image src={resolveMediaUrl(employee.avatar)} alt={fullName} width={36} height={36} unoptimized className="app-avatar-frame h-9 w-9 shrink-0 rounded-full object-cover" /> : <div className="app-avatar-fallback flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-medium">{employee.first_name?.[0] || employee.last_name?.[0] || "?"}</div>}<span className="min-w-0"><span className="block truncate text-sm font-medium">{fullName}</span>{employee.email ? <span className="app-text-muted block truncate text-xs">{employee.email}</span> : null}</span></label>;
}) : <p className="app-text-muted px-3 py-6 text-center text-sm">{searchQuery ? "Сотрудники не найдены" : "Все сотрудники уже добавлены"}</p>}
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto]"><ProjectSelectMenu value={selectedRole} onChange={(value) => setSelectedRole(value as CalendarParticipantRole)} ariaLabel="Роль участника" options={[...roleOptions]} /><button type="button" onClick={() => void handleAddParticipants()} disabled={!selectedUserIds.length || loading} className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2 text-sm font-medium disabled:opacity-50"><UserPlus size={16} />{loading ? "Добавление…" : `Добавить (${selectedUserIds.length})`}</button></div>
</section>
) : null}
{accessScope === "all" && !isDepartmentCalendar ? <div className="app-selected flex items-start gap-3 rounded-xl border p-3"><Users size={18} className="app-accent-text mt-0.5 shrink-0" /><div><p className="text-sm font-semibold">Вся компания</p><p className="app-text-muted mt-1 text-xs">Все сотрудники получают просмотр календаря автоматически. Ниже показаны только индивидуальные роли.</p></div></div> : null}
<section><div className="mb-2 flex items-center justify-between gap-2"><p className="app-card-caption">{accessScope === "all" ? "Индивидуальные роли" : "Текущие участники"}</p><span className="app-badge rounded-full px-2 py-0.5 text-[11px]">{visibleParticipants.length}</span></div><div className="tasks-column-scroll max-h-72 space-y-1 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-1">
{visibleParticipants.length ? visibleParticipants.map((participant) => {
const user = participant.user;
const fullName = `${user?.first_name || ""} ${user?.last_name || ""}`.trim() || user?.username || "Сотрудник";
return <div key={participant.id} className="flex min-w-0 items-center gap-3 rounded-lg px-2.5 py-2 hover:bg-[var(--surface-secondary)]"><ParticipantAvatar participant={participant} /><div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{fullName}</p>{user?.email ? <p className="app-text-muted truncate text-xs">{user.email}</p> : null}</div><span className={`${participant.distinction === "editor" ? "app-selected" : "app-badge"} shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium`}>{roleLabels[participant.distinction] || participant.distinction}</span>{canManage && participant.distinction !== "owner" ? <button type="button" onClick={() => user && void handleRemoveParticipant(user.id)} disabled={loading} className="app-icon-button shrink-0 rounded-lg p-2 text-rose-500 disabled:opacity-50" title="Удалить участника"><Trash2 size={15} /></button> : null}</div>;
}) : <p className="app-text-muted px-3 py-6 text-center text-sm">Индивидуальные участники не добавлены</p>}
</div></section>
</div>
) : canManage ? (
<section>
<p className="app-card-caption mb-2">Доступ</p>
<div className="grid gap-2 sm:grid-cols-2">
<button type="button" onClick={() => void handleAccessScopeChange('all')} disabled={accessUpdating} className={`${accessScope === 'all' ? 'app-selected border-[var(--accent-primary)]' : 'border-[var(--border-subtle)] hover:border-[var(--border-strong)]'} flex items-start gap-3 rounded-xl border p-3 text-left transition disabled:opacity-60`}>
<Users size={18} className="app-accent-text mt-0.5 shrink-0" />
<span><span className="block text-sm font-semibold">Вся компания</span><span className="app-text-muted mt-1 block text-xs">Просмотр доступен всем сотрудникам</span></span>
{accessScope === 'all' ? <Check size={16} className="app-accent-text ml-auto shrink-0" /> : null}
</button>
<button type="button" onClick={() => void handleAccessScopeChange('restricted')} disabled={accessUpdating} className={`${accessScope === 'restricted' ? 'app-selected border-[var(--accent-primary)]' : 'border-[var(--border-subtle)] hover:border-[var(--border-strong)]'} flex items-start gap-3 rounded-xl border p-3 text-left transition disabled:opacity-60`}>
<UserRoundCheck size={18} className="app-accent-text mt-0.5 shrink-0" />
<span><span className="block text-sm font-semibold">Выборочно</span><span className="app-text-muted mt-1 block text-xs">Только указанные участники</span></span>
{accessScope === 'restricted' ? <Check size={16} className="app-accent-text ml-auto shrink-0" /> : null}
</button>
</div>
</section>
) : null}
{canManage && accessScope === 'restricted' ? (
<section className="app-surface-muted space-y-3 rounded-xl p-3 sm:p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold">Добавить участников</p>
<p className="app-text-muted mt-0.5 text-xs">Выбрано: {selectedUserIds.length}</p>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={selectAll} disabled={availableEmployees.length === 0} className="app-badge rounded-full px-2.5 py-1 text-xs disabled:opacity-50">Выбрать всех</button>
{selectedDepartment ? <button type="button" onClick={selectAllFromDepartment} className="app-badge rounded-full px-2.5 py-1 text-xs">Весь отдел</button> : null}
<button type="button" onClick={deselectAll} disabled={selectedUserIds.length === 0} className="app-badge rounded-full px-2.5 py-1 text-xs disabled:opacity-50">Снять выбор</button>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(11rem,0.45fr)]">
<label className="relative min-w-0">
<Search size={15} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input type="search" placeholder="Поиск сотрудников" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} className="app-input w-full rounded-xl py-2 pl-9 pr-3 text-sm" />
</label>
<ProjectSelectMenu
value={selectedDepartment}
onChange={(value) => { setSelectedDepartment(value); setSelectedUserIds([]); }}
ariaLabel="Отдел участников"
options={[{ value: '', label: 'Все отделы' }, ...departments.map((department) => ({ value: String(department.id), label: department.name }))]}
/>
</div>
<div className="tasks-column-scroll app-surface max-h-60 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-1">
{availableEmployees.length ? availableEmployees.map((employee) => {
const selected = selectedUserIds.includes(employee.id);
const fullName = `${employee.first_name || ''} ${employee.last_name || ''}`.trim() || employee.username || 'Сотрудник';
return <label key={employee.id} className={`${selected ? 'app-selected' : 'hover:bg-[var(--surface-secondary)]'} flex cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 transition`}>
<input type="checkbox" checked={selected} onChange={() => toggleUserSelection(employee.id)} className="h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)]" />
{employee.avatar ? <Image src={resolveMediaUrl(employee.avatar)} alt={fullName} width={36} height={36} unoptimized className="app-avatar-frame h-9 w-9 shrink-0 rounded-full object-cover" /> : <div className="app-avatar-fallback flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-medium">{employee.first_name?.[0] || employee.last_name?.[0] || '?'}</div>}
<span className="min-w-0"><span className="block truncate text-sm font-medium">{fullName}</span>{employee.email ? <span className="app-text-muted block truncate text-xs">{employee.email}</span> : null}</span>
</label>;
}) : <p className="app-text-muted px-3 py-6 text-center text-sm">{searchQuery ? 'Сотрудники не найдены' : 'Все сотрудники уже добавлены'}</p>}
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
<ProjectSelectMenu
value={selectedRole}
onChange={setSelectedRole}
ariaLabel="Роль участника"
options={[{ value: 'viewer', label: 'Просмотр' }, { value: 'editor', label: 'Редактор' }, { value: 'owner', label: 'Владелец' }]}
/>
<button type="button" onClick={() => void handleAddParticipants()} disabled={selectedUserIds.length === 0 || loading} className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2 text-sm font-medium disabled:opacity-50"><UserPlus size={16} />{loading ? 'Добавление…' : `Добавить (${selectedUserIds.length})`}</button>
</div>
</section>
) : null}
{accessScope === 'all' && !isDepartmentCalendar ? (
<div className="app-selected flex items-start gap-3 rounded-xl border p-3">
<Users size={18} className="app-accent-text mt-0.5 shrink-0" />
<div><p className="text-sm font-semibold">Вся компания</p><p className="app-text-muted mt-1 text-xs">Все действующие и новые сотрудники получают просмотр календаря автоматически.</p></div>
) : (
<div className="space-y-4" role="tabpanel">
<div className="grid gap-2 sm:grid-cols-3"><div className="app-surface-muted rounded-xl p-3"><p className="text-sm font-semibold">Владелец</p><p className="app-text-muted mt-1 text-xs">Полное управление календарём, участниками и ролями.</p></div><div className="app-surface-muted rounded-xl p-3"><p className="text-sm font-semibold">Редактор</p><p className="app-text-muted mt-1 text-xs">Создаёт события и настраивает уведомления.</p></div><div className="app-surface-muted rounded-xl p-3"><p className="text-sm font-semibold">Просмотр</p><p className="app-text-muted mt-1 text-xs">Видит календарь без права изменений.</p></div></div>
{isDepartmentCalendar ? <div className="app-surface-muted flex items-start gap-3 rounded-xl p-3"><Building2 size={18} className="app-accent-text mt-0.5 shrink-0" /><p className="app-text-muted text-sm">Роли календаря отдела назначаются автоматически: руководитель — владелец, участники отдела — редакторы.</p></div> : null}
{accessScope === "all" ? <div className="app-selected rounded-xl border p-3 text-sm"><span className="font-semibold">Вся компания</span><span className="app-text-muted ml-1">имеет роль просмотра; ниже настраиваются индивидуальные расширенные роли.</span></div> : null}
{canManage ? (
<section className="app-surface-muted space-y-3 rounded-xl p-3 sm:p-4">
<div><p className="text-sm font-semibold">Назначить индивидуальную роль</p><p className="app-text-muted mt-0.5 text-xs">Сотрудник сохранит доступ компании, а выбранная роль даст дополнительные права.</p></div>
<label className="relative block min-w-0"><Search size={15} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" /><input type="search" placeholder="Найти сотрудника" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} className="app-input w-full rounded-xl py-2 pl-9 pr-3 text-sm" /></label>
{searchQuery.trim() ? <div className="tasks-column-scroll app-surface max-h-48 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-1">{availableEmployees.length ? availableEmployees.map((employee) => {
const selected = selectedUserIds.includes(employee.id);
const fullName = `${employee.first_name || ""} ${employee.last_name || ""}`.trim() || employee.username || "Сотрудник";
return <label key={employee.id} className={`${selected ? "app-selected" : "hover:bg-[var(--surface-secondary)]"} flex cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 transition`}><input type="checkbox" checked={selected} onChange={() => setSelectedUserIds((current) => current.includes(employee.id) ? current.filter((id) => id !== employee.id) : [...current, employee.id])} className="h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)]" /><span className="min-w-0"><span className="block truncate text-sm font-medium">{fullName}</span>{employee.email ? <span className="app-text-muted block truncate text-xs">{employee.email}</span> : null}</span></label>;
}) : <p className="app-text-muted px-3 py-5 text-center text-sm">Сотрудники не найдены или уже имеют индивидуальную роль</p>}</div> : null}
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto]"><ProjectSelectMenu value={selectedRole} onChange={(value) => setSelectedRole(value as CalendarParticipantRole)} ariaLabel="Новая роль участника" options={[{ value: "editor", label: "Редактор" }, { value: "owner", label: "Владелец" }]} /><button type="button" onClick={() => void handleAddParticipants()} disabled={!selectedUserIds.length || loading} className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2 text-sm font-medium disabled:opacity-50"><UserPlus size={16} />{loading ? "Назначение…" : `Назначить (${selectedUserIds.length})`}</button></div>
</section>
) : null}
<section><div className="mb-2 flex items-center justify-between gap-2"><p className="app-card-caption">Индивидуальные роли</p><span className="app-badge rounded-full px-2 py-0.5 text-[11px]">{participants.length}</span></div><div className="tasks-column-scroll max-h-96 space-y-1 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-1">
{participants.length ? participants.map((participant) => {
const user = participant.user;
const fullName = `${user?.first_name || ""} ${user?.last_name || ""}`.trim() || user?.username || "Сотрудник";
return <div key={participant.id} className="grid min-w-0 gap-2 rounded-lg px-2.5 py-2 hover:bg-[var(--surface-secondary)] sm:grid-cols-[auto_minmax(0,1fr)_10rem] sm:items-center"><ParticipantAvatar participant={participant} /><div className="min-w-0"><p className="truncate text-sm font-medium">{fullName}</p>{user?.email ? <p className="app-text-muted truncate text-xs">{user.email}</p> : null}</div>{canManage ? <div className={roleUpdatingId === participant.id ? "pointer-events-none opacity-60" : ""}><ProjectSelectMenu value={participant.distinction} onChange={(value) => void handleRoleChange(participant, value)} ariaLabel={`Роль: ${fullName}`} options={[...roleOptions]} /></div> : <span className="app-badge justify-self-start rounded-full px-2.5 py-1 text-xs sm:justify-self-end">{roleLabels[participant.distinction] || participant.distinction}</span>}</div>;
}) : <p className="app-text-muted px-3 py-6 text-center text-sm">Индивидуальные роли не назначены</p>}
</div></section>
</div>
) : null}
<section>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="app-card-caption">{accessScope === 'all' ? 'Индивидуальные роли' : 'Текущие участники'}</p>
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">{visibleParticipants.length}</span>
</div>
<div className="tasks-column-scroll max-h-72 space-y-1 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-1">
{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 <div key={participant.id} className="flex min-w-0 items-center gap-3 rounded-lg px-2.5 py-2 hover:bg-[var(--surface-secondary)]">
{user?.avatar ? <Image src={resolveMediaUrl(user.avatar)} alt={fullName} width={36} height={36} unoptimized className="app-avatar-frame h-9 w-9 shrink-0 rounded-full object-cover" /> : <div className="app-avatar-fallback flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-medium">{user?.first_name?.[0] || user?.last_name?.[0] || '?'}</div>}
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{fullName}</p>{secondaryLabel ? <p className="app-text-muted truncate text-xs">{secondaryLabel}</p> : null}</div>
<span className={`${participant.distinction === 'editor' ? 'app-selected' : 'app-badge'} shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium`}>{participant.distinction === 'owner' ? 'Владелец' : participant.distinction === 'editor' ? 'Редактор' : 'Просмотр'}</span>
{canManage && participant.distinction !== 'owner' ? <button type="button" onClick={() => participant.user && void handleRemoveParticipant(participant.user.id)} disabled={loading} className="app-icon-button shrink-0 rounded-lg p-2 text-rose-500 disabled:opacity-50" title="Удалить участника"><Trash2 size={15} /></button> : null}
</div>;
}) : <p className="app-text-muted px-3 py-6 text-center text-sm">Индивидуальные участники не добавлены</p>}
</div>
</section>
)}
</div>
</Modal>
);

View File

@ -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<EditableEvent | null>(null);
const [eventParticipants, setEventParticipants] = useState<EventParticipant[]>([]);
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 = (
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={!editingEvent.title?.trim() || saving}
disabled={!editingEvent.title?.trim() || !canSaveToCalendar || saving}
className="app-action-primary flex-1 rounded-xl px-4 py-3 text-sm font-semibold shadow-sm"
>
{saving ? 'Сохранение...' : (editingEvent.id ? 'Сохранить' : 'Создать')}
@ -473,6 +491,39 @@ export function EventModal({
footer={footerContent}
>
<div className="space-y-5">
<div>
<label className="app-field-label">Календарь</label>
<ProjectSelectMenu
value={selectedCalendarId === null ? "" : String(selectedCalendarId)}
onChange={(value) => setEditingEvent({
...editingEvent,
calendar: value ? Number(value) : null,
})}
ariaLabel="Календарь события"
triggerIcon={<CalendarDays className="h-4 w-4" aria-hidden="true" />}
disabled={calendarsLoading || selectableCalendars.length === 0}
options={[
{
value: "",
label: calendarsLoading
? "Загрузка календарей…"
: selectableCalendars.length > 0
? "Выберите календарь"
: "Нет доступных календарей",
disabled: true,
},
...selectableCalendars.map((calendar) => ({
value: String(calendar.id),
label: calendar.name,
disabled: calendar.can_create_events === false,
})),
]}
/>
{selectedCalendarId === null && !calendarsLoading && selectableCalendars.length > 0 ? (
<p className="app-text-muted mt-1.5 text-xs">Выберите календарь, в котором появится событие.</p>
) : null}
</div>
<div>
<label className="app-field-label">Название</label>
<input

View File

@ -2,7 +2,7 @@
import { memo, useMemo, useState, useRef, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { ChevronDown, ChevronLeft, ChevronRight, Pencil, Trash2, Users } from "lucide-react";
import { Bell, ChevronDown, ChevronLeft, ChevronRight, Pencil, ShieldCheck, Trash2, Users } from "lucide-react";
import { apiClient } from "@/lib/api";
import { useCalendar } from "@/contexts/CalendarContext";
import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents";
@ -14,6 +14,7 @@ import {
toCalendarEventDraft,
} from "@/services/calendarService";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import CalendarNotificationSettingsModal from "@/components/CalendarNotificationSettingsModal";
import { ViewportAwareMenu } from "@/components/shared/ViewportAwareMenu";
import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-colors";
import {
@ -84,6 +85,7 @@ export const CalendarCard = memo(function CalendarCard({
});
const [showCalendarMenu, setShowCalendarMenu] = useState(false);
const [notificationCalendar, setNotificationCalendar] = useState<{ id: number; name: string } | null>(null);
const [eventsViewMode, setEventsViewMode] = useState<"week" | "month">("week");
const [linkedTasksByEventId, setLinkedTasksByEventId] = useState<Record<number, TaskCard[]>>({});
const fileInputRef = useRef<HTMLInputElement>(null);
@ -432,6 +434,31 @@ export const CalendarCard = memo(function CalendarCard({
<Users size={16} />
Участники
</button>
<button
onClick={() => {
if (participantsModalAvailable) {
onOpenParticipantsModal(toParticipantsTarget(selectedCalendar, "roles"));
}
setShowCalendarMenu(false);
}}
disabled={!participantsModalAvailable}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
>
<ShieldCheck size={16} />
Роли
</button>
{selectedCalendar.can_manage_notification_settings ? (
<button
onClick={() => {
setNotificationCalendar({ id: selectedCalendar.id, name: selectedCalendar.name });
setShowCalendarMenu(false);
}}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Bell size={16} />
Уведомления
</button>
) : null}
<button
onClick={handleExportCalendar}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
@ -732,6 +759,14 @@ export const CalendarCard = memo(function CalendarCard({
);
})()}
</div>
{notificationCalendar ? (
<CalendarNotificationSettingsModal
isOpen
onClose={() => setNotificationCalendar(null)}
calendarId={notificationCalendar.id}
calendarName={notificationCalendar.name}
/>
) : null}
</>
);
});

View File

@ -28,6 +28,7 @@ export function CalendarModals(props: CalendarModalsProps) {
calendarType={props.participantsCalendar.type}
contextType={props.participantsCalendar.context_type}
initialAccessScope={props.participantsCalendar.access_scope}
initialTab={props.participantsCalendar.initial_tab}
/>
)}

View File

@ -1,5 +1,21 @@
"use client";
import {
DndContext,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Bell,
BellOff,
@ -10,6 +26,7 @@ import {
Filter,
Folder,
FolderInput,
GripVertical,
Eye,
EyeOff,
Kanban,
@ -32,6 +49,10 @@ import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { Modal } from "@/components/ui";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import { CompactFilterPanel } from "@/components/ui/CompactFilterMenu";
import {
CompactSortMenu,
type CompactSortOption,
} from "@/components/ui/CompactSortMenu";
import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu";
@ -41,12 +62,57 @@ import type { TaskBoardGroup, TaskBoardSummary } from "@/types/api";
type BoardUnreadFilter = "all" | "unread" | "read";
type BoardAccessFilter = "all" | "private" | "shared";
type BoardGroupFilter = "all" | "ungrouped" | number;
type BoardSortValue =
| "priority"
| "priority-desc"
| "name"
| "name-desc"
| "newest"
| "oldest"
| "updated";
const BOARD_PRIORITY_SORT_OPTIONS = [
{ value: "priority", label: "По приоритету ↑", icon: "priority-asc" },
{ value: "priority-desc", label: "По приоритету ↓", icon: "priority-desc" },
] as const satisfies readonly CompactSortOption<BoardSortValue>[];
const BOARD_SORT_OPTIONS = [
{ value: "name", label: "По названию ↑", icon: "alphabetical" },
{ value: "name-desc", label: "По названию ↓", icon: "alphabetical-reverse" },
{ value: "newest", label: "Сначала новые", icon: "created-desc" },
{ value: "oldest", label: "Сначала старые", icon: "created-asc" },
{ value: "updated", label: "Недавно обновлённые", icon: "updated" },
] as const satisfies readonly CompactSortOption<BoardSortValue>[];
const NAVIGATOR_COLLAPSED_STORAGE_PREFIX = "tasks.board-navigator-collapsed";
function boardGroupIds(board: TaskBoardSummary): number[] {
return board.group_ids || (board.group_id ? [board.group_id] : []);
}
function boardGroupPosition(
board: TaskBoardSummary,
groupId: number,
): number | undefined {
return board.group_positions?.[String(groupId)];
}
function boardGroupSortKey(
board: TaskBoardSummary,
groupId: number,
): string | undefined {
return board.group_sort_keys?.[String(groupId)];
}
function compareBoardNames(first: TaskBoardSummary, second: TaskBoardSummary): number {
return first.name.localeCompare(second.name, "ru") || first.id - second.id;
}
function boardTimestamp(value: string): number {
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : 0;
}
function sameGroupIds(left: number[], right: number[]): boolean {
const normalizedLeft = [...left].sort((first, second) => first - second);
const normalizedRight = [...right].sort((first, second) => first - second);
@ -63,6 +129,7 @@ type TaskBoardNavigatorProps = {
onCreate: () => void;
onCreateGroup: () => void;
onEditGroup: (group: TaskBoardGroup) => void;
onManageGroupBoards: (group: TaskBoardGroup) => void;
onDeleteGroup: (group: TaskBoardGroup) => void;
onToggleDefaultGroup: (group: TaskBoardGroup) => void;
onToggleHiddenGroup: (group: TaskBoardGroup) => void;
@ -74,6 +141,12 @@ type TaskBoardNavigatorProps = {
boardId: number,
groupIds: number[],
) => boolean | void | Promise<boolean | void>;
onReorderBoard: (
groupId: number,
boardId: number,
previousBoardId: number | null,
nextBoardId: number | null,
) => boolean | Promise<boolean>;
wideMode: boolean;
onWideModeChange: (enabled: boolean) => void;
userId: number | null;
@ -112,6 +185,8 @@ function BoardRow({
onMove,
onViewParticipants,
groupName,
groupPosition,
reorderEnabled = false,
compact = false,
}: {
board: TaskBoardSummary;
@ -127,11 +202,35 @@ function BoardRow({
onMove: () => void;
onViewParticipants: () => void;
groupName?: string;
groupPosition?: number;
reorderEnabled?: boolean;
compact?: boolean;
}) {
const {
attributes,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: board.id,
disabled: !reorderEnabled,
});
const sortableStyle = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 20 : undefined,
};
if (compact) {
return (
<div className="relative flex justify-center">
<div
ref={setNodeRef}
style={sortableStyle}
className={`relative flex justify-center ${isDragging ? "opacity-60" : ""}`}
>
<button
type="button"
onClick={onSelect}
@ -143,6 +242,14 @@ function BoardRow({
aria-current={active ? "page" : undefined}
>
<TaskBoardAvatar name={board.name} src={board.avatar} />
{groupPosition ? (
<span
className="absolute bottom-0 left-0 flex h-4 min-w-4 items-center justify-center rounded-full border-2 border-[var(--surface-primary)] bg-[var(--accent-primary)] px-0.5 text-[8px] font-bold leading-none text-white"
title={`Приоритет в группе: ${groupPosition}`}
>
{groupPosition}
</span>
) : null}
{unread > 0 ? (
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-sky-500 px-1 text-[9px] font-bold text-white">
{unread > 99 ? "99+" : unread}
@ -154,7 +261,11 @@ function BoardRow({
}
return (
<div className="relative">
<div
ref={setNodeRef}
style={sortableStyle}
className={`relative ${isDragging ? "opacity-60" : ""}`}
>
<button
type="button"
onClick={onSelect}
@ -163,13 +274,20 @@ function BoardRow({
}`}
aria-current={active ? "page" : undefined}
>
<TaskBoardAvatar
name={board.name}
src={board.avatar}
className="mt-0.5"
/>
<span className="relative mt-0.5 shrink-0">
<TaskBoardAvatar name={board.name} src={board.avatar} />
{groupPosition ? (
<span
className="absolute -bottom-1 -left-1 flex h-4 min-w-4 items-center justify-center rounded-full border-2 border-[var(--surface-primary)] bg-[var(--accent-primary)] px-0.5 text-[8px] font-bold leading-none text-white"
title={`Приоритет в группе: ${groupPosition}`}
>
{groupPosition}
</span>
) : null}
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
{reorderEnabled ? <span className="h-5 w-5 shrink-0" aria-hidden="true" /> : null}
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--foreground)]">
{board.name}
</span>
@ -205,6 +323,19 @@ function BoardRow({
</span>
</span>
</button>
{reorderEnabled ? (
<button
ref={setActivatorNodeRef}
type="button"
className="app-text-muted absolute left-16 top-2.5 z-10 inline-flex h-5 w-5 shrink-0 cursor-grab touch-none items-center justify-center rounded transition hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)] active:cursor-grabbing focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
{...attributes}
{...listeners}
aria-label={`Изменить приоритет доски «${board.name}»`}
title="Перетащите, чтобы изменить приоритет"
>
<GripVertical size={15} strokeWidth={2} />
</button>
) : null}
<button
type="button"
onClick={onToggleMenu}
@ -292,6 +423,7 @@ export default function TaskBoardNavigator({
onCreate,
onCreateGroup,
onEditGroup,
onManageGroupBoards,
onDeleteGroup,
onToggleDefaultGroup,
onToggleHiddenGroup,
@ -300,6 +432,7 @@ export default function TaskBoardNavigator({
onToggleBoardPin,
onToggleBoardNotifications,
onMoveBoard,
onReorderBoard,
wideMode,
onWideModeChange,
userId,
@ -310,6 +443,7 @@ export default function TaskBoardNavigator({
const [unreadFilter, setUnreadFilter] = useState<BoardUnreadFilter>("all");
const [accessFilter, setAccessFilter] = useState<BoardAccessFilter>("all");
const [groupFilter, setGroupFilter] = useState<BoardGroupFilter>("all");
const [boardSort, setBoardSort] = useState<BoardSortValue>("name");
const [boardMenuId, setBoardMenuId] = useState<number | null>(null);
const [groupMenuId, setGroupMenuId] = useState<number | null>(null);
const [groupMenuPosition, setGroupMenuPosition] = useState<{
@ -324,6 +458,11 @@ export default function TaskBoardNavigator({
const [moveSubmitting, setMoveSubmitting] = useState(false);
const [participantsBoard, setParticipantsBoard] = useState<TaskBoardSummary | null>(null);
const [hiddenGroupsOpen, setHiddenGroupsOpen] = useState(false);
const [optimisticBoardOrder, setOptimisticBoardOrder] = useState<{
groupId: number;
boardIds: number[];
} | null>(null);
const [reorderingBoardId, setReorderingBoardId] = useState<number | null>(null);
const menusRef = useRef<HTMLDivElement>(null);
const groupMenuRef = useRef<HTMLDivElement>(null);
const groupMenuButtonRefs = useRef(new Map<number, HTMLButtonElement>());
@ -343,6 +482,23 @@ export default function TaskBoardNavigator({
const effectiveGroupFilter = (
typeof groupFilter !== "number" || visibleGroups.some((group) => group.id === groupFilter)
) ? groupFilter : "all";
const prioritySortAvailable = typeof effectiveGroupFilter === "number";
const effectiveBoardSort: BoardSortValue = (
!prioritySortAvailable && boardSort.startsWith("priority")
) ? "name" : boardSort;
const boardSortOptions = useMemo<readonly CompactSortOption<BoardSortValue>[]>(
() => prioritySortAvailable
? [...BOARD_PRIORITY_SORT_OPTIONS, ...BOARD_SORT_OPTIONS]
: BOARD_SORT_OPTIONS,
[prioritySortAvailable],
);
const boardReorderEnabled = (
typeof effectiveGroupFilter === "number"
&& effectiveBoardSort === "priority"
&& normalizedQuery === ""
&& unreadFilter === "all"
&& accessFilter === "all"
);
const filteredBoards = useMemo(() => boards.filter((board) => {
const unread = unreadCounts[board.id] || 0;
if (unreadFilter === "unread" && unread === 0) return false;
@ -351,12 +507,66 @@ export default function TaskBoardNavigator({
if (accessFilter === "shared" && board.access_scope === "private") return false;
return true;
}), [accessFilter, boards, unreadCounts, unreadFilter]);
const visibleBoards = useMemo(() => filteredBoards.filter((board) => {
const groupIds = boardGroupIds(board);
if (effectiveGroupFilter === "ungrouped" && groupIds.length > 0) return false;
if (typeof effectiveGroupFilter === "number" && !groupIds.includes(effectiveGroupFilter)) return false;
return !normalizedQuery || matchesBoard(board, normalizedQuery);
}), [effectiveGroupFilter, filteredBoards, normalizedQuery]);
const visibleBoards = useMemo(() => {
const matchingBoards = filteredBoards.filter((board) => {
const groupIds = boardGroupIds(board);
if (effectiveGroupFilter === "ungrouped" && groupIds.length > 0) return false;
if (typeof effectiveGroupFilter === "number" && !groupIds.includes(effectiveGroupFilter)) return false;
return !normalizedQuery || matchesBoard(board, normalizedQuery);
});
const sortedBoards = matchingBoards.sort((first, second) => {
if (
(effectiveBoardSort === "priority" || effectiveBoardSort === "priority-desc")
&& typeof effectiveGroupFilter === "number"
) {
const firstRank = boardGroupSortKey(first, effectiveGroupFilter);
const secondRank = boardGroupSortKey(second, effectiveGroupFilter);
if (firstRank && secondRank && firstRank !== secondRank) {
const direction = effectiveBoardSort === "priority-desc" ? -1 : 1;
return (firstRank < secondRank ? -1 : 1) * direction;
}
if (firstRank && !secondRank) return -1;
if (!firstRank && secondRank) return 1;
}
if (effectiveBoardSort === "name-desc") {
return second.name.localeCompare(first.name, "ru") || second.id - first.id;
}
if (effectiveBoardSort === "newest") {
return boardTimestamp(second.created_at) - boardTimestamp(first.created_at)
|| compareBoardNames(first, second);
}
if (effectiveBoardSort === "oldest") {
return boardTimestamp(first.created_at) - boardTimestamp(second.created_at)
|| compareBoardNames(first, second);
}
if (effectiveBoardSort === "updated") {
return boardTimestamp(second.updated_at) - boardTimestamp(first.updated_at)
|| compareBoardNames(first, second);
}
return compareBoardNames(first, second);
});
if (
optimisticBoardOrder
&& typeof effectiveGroupFilter === "number"
&& optimisticBoardOrder.groupId === effectiveGroupFilter
) {
const optimisticIndexes = new Map(
optimisticBoardOrder.boardIds.map((boardId, index) => [boardId, index]),
);
return sortedBoards.sort((first, second) => (
(optimisticIndexes.get(first.id) ?? Number.MAX_SAFE_INTEGER)
- (optimisticIndexes.get(second.id) ?? Number.MAX_SAFE_INTEGER)
));
}
return sortedBoards;
}, [
effectiveBoardSort,
effectiveGroupFilter,
filteredBoards,
normalizedQuery,
optimisticBoardOrder,
]);
const groupStats = useMemo(() => {
const buildStats = (matchingBoards: TaskBoardSummary[]) => ({
total: matchingBoards.length,
@ -380,6 +590,15 @@ export default function TaskBoardNavigator({
);
const activeFiltersCount = [unreadFilter !== "all", accessFilter !== "all"].filter(Boolean).length;
const compactNavigator = desktopNavigatorLayout && wideMode && navigatorCollapsed;
const boardReorderActive = (
boardReorderEnabled && !compactNavigator && visibleBoards.length > 1
);
const boardReorderSensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 180, tolerance: 8 },
}),
);
const filteredMoveGroups = useMemo(() => {
const moveQuery = moveGroupQuery.trim().toLowerCase();
return moveQuery
@ -387,10 +606,59 @@ export default function TaskBoardNavigator({
: visibleGroups;
}, [moveGroupQuery, visibleGroups]);
const selectGroupFilter = (nextGroupFilter: BoardGroupFilter) => {
setOptimisticBoardOrder(null);
setGroupFilter(nextGroupFilter);
setBoardSort(typeof nextGroupFilter === "number" ? "priority" : "name");
};
const selectBoardSort = (nextSort: BoardSortValue) => {
setOptimisticBoardOrder(null);
setBoardSort(nextSort);
};
const handleBoardDragEnd = async (event: DragEndEvent) => {
if (
!boardReorderActive
|| reorderingBoardId !== null
|| typeof effectiveGroupFilter !== "number"
|| !event.over
) return;
const activeBoardId = Number(event.active.id);
const overBoardId = Number(event.over.id);
const oldIndex = visibleBoards.findIndex((item) => item.id === activeBoardId);
const newIndex = visibleBoards.findIndex((item) => item.id === overBoardId);
if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return;
const reorderedBoards = arrayMove(visibleBoards, oldIndex, newIndex);
const movedIndex = reorderedBoards.findIndex((item) => item.id === activeBoardId);
const previousBoardId = reorderedBoards[movedIndex - 1]?.id ?? null;
const nextBoardId = reorderedBoards[movedIndex + 1]?.id ?? null;
setOptimisticBoardOrder({
groupId: effectiveGroupFilter,
boardIds: reorderedBoards.map((item) => item.id),
});
setReorderingBoardId(activeBoardId);
try {
await onReorderBoard(
effectiveGroupFilter,
activeBoardId,
previousBoardId,
nextBoardId,
);
} finally {
setOptimisticBoardOrder(null);
setReorderingBoardId(null);
}
};
useEffect(() => {
if (defaultGroupInitializedRef.current || visibleGroups.length === 0) return;
const defaultGroup = visibleGroups.find((group) => group.is_default_for_current_user);
setGroupFilter(defaultGroup?.id ?? "all");
const nextGroupFilter = defaultGroup?.id ?? "all";
setGroupFilter(nextGroupFilter);
setBoardSort(typeof nextGroupFilter === "number" ? "priority" : "name");
defaultGroupInitializedRef.current = true;
}, [visibleGroups]);
@ -599,6 +867,12 @@ export default function TaskBoardNavigator({
</span>
) : null}
</button>
<CompactSortMenu
value={effectiveBoardSort}
options={boardSortOptions}
onChange={selectBoardSort}
ariaLabel="Сортировка списка досок"
/>
</div>
<CompactFilterPanel
@ -654,7 +928,7 @@ export default function TaskBoardNavigator({
<button
key={key}
type="button"
onClick={() => 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({
>
<button
type="button"
onClick={() => setGroupFilter(group.id)}
onClick={() => selectGroupFilter(group.id)}
className="inline-flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-7 text-xs font-medium"
>
<span className="max-w-28 truncate">{group.name}</span>
@ -755,50 +1029,65 @@ export default function TaskBoardNavigator({
wideMode ? "lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:overscroll-y-contain" : ""
} ${compactNavigator ? "px-2 pt-2" : "px-4"}`}>
{visibleBoards.length > 0 ? (
<div className="space-y-1">
{visibleBoards.map((board) => (
<BoardRow
key={board.id}
board={board}
groupName={boardGroupIds(board)
.map((groupId) => groupNames.get(groupId))
.filter((groupName): groupName is string => Boolean(groupName))
.join(", ") || undefined}
active={selectedBoardId === board.id}
unread={unreadCounts[board.id] || 0}
compact={compactNavigator}
onSelect={() => onSelect(board.id)}
menuOpen={boardMenuId === board.id}
onToggleMenu={() => setBoardMenuId((current) => current === board.id ? null : board.id)}
onEdit={() => {
onEditBoard(board);
setBoardMenuId(null);
}}
onDelete={() => {
onDeleteBoard(board);
setBoardMenuId(null);
}}
onTogglePin={() => {
onToggleBoardPin(board);
setBoardMenuId(null);
}}
onToggleNotifications={() => {
onToggleBoardNotifications(board);
setBoardMenuId(null);
}}
onMove={() => {
setMoveBoardTarget(board);
setMoveGroupIds(boardGroupIds(board));
setMoveGroupQuery("");
setBoardMenuId(null);
}}
onViewParticipants={() => {
setParticipantsBoard(board);
setBoardMenuId(null);
}}
/>
))}
</div>
<DndContext
sensors={boardReorderSensors}
collisionDetection={closestCenter}
onDragEnd={(event) => void handleBoardDragEnd(event)}
>
<SortableContext
items={boardReorderActive ? visibleBoards.map((item) => item.id) : []}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{visibleBoards.map((board) => (
<BoardRow
key={board.id}
board={board}
groupName={boardGroupIds(board)
.map((groupId) => groupNames.get(groupId))
.filter((groupName): groupName is string => Boolean(groupName))
.join(", ") || undefined}
groupPosition={typeof effectiveGroupFilter === "number"
? boardGroupPosition(board, effectiveGroupFilter)
: undefined}
active={selectedBoardId === board.id}
unread={unreadCounts[board.id] || 0}
compact={compactNavigator}
reorderEnabled={boardReorderActive && reorderingBoardId === null}
onSelect={() => onSelect(board.id)}
menuOpen={boardMenuId === board.id}
onToggleMenu={() => setBoardMenuId((current) => current === board.id ? null : board.id)}
onEdit={() => {
onEditBoard(board);
setBoardMenuId(null);
}}
onDelete={() => {
onDeleteBoard(board);
setBoardMenuId(null);
}}
onTogglePin={() => {
onToggleBoardPin(board);
setBoardMenuId(null);
}}
onToggleNotifications={() => {
onToggleBoardNotifications(board);
setBoardMenuId(null);
}}
onMove={() => {
setMoveBoardTarget(board);
setMoveGroupIds(boardGroupIds(board));
setMoveGroupQuery("");
setBoardMenuId(null);
}}
onViewParticipants={() => {
setParticipantsBoard(board);
setBoardMenuId(null);
}}
/>
))}
</div>
</SortableContext>
</DndContext>
) : compactNavigator ? (
<div className="app-text-muted flex justify-center py-5" title="Доски не найдены">
<Kanban size={20} />
@ -818,7 +1107,7 @@ export default function TaskBoardNavigator({
? createPortal(
<div
ref={groupMenuRef}
className="app-surface-elevated fixed z-[100] w-44 max-w-[calc(100dvw-1rem)] overflow-hidden rounded-lg border border-[var(--border-subtle)] py-1 shadow-xl"
className="app-surface-elevated fixed z-[90] w-44 max-w-[calc(100dvw-1rem)] overflow-hidden rounded-lg border border-[var(--border-subtle)] py-1 shadow-xl"
style={{
left: groupMenuPosition.left,
top: groupMenuPosition.top,
@ -854,7 +1143,18 @@ export default function TaskBoardNavigator({
<button
type="button"
onClick={() => {
if (groupFilter === activeGroupMenu.id) setGroupFilter("all");
onManageGroupBoards(activeGroupMenu);
setGroupMenuId(null);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Kanban size={14} />
Управление досками
</button>
<button
type="button"
onClick={() => {
if (groupFilter === activeGroupMenu.id) selectGroupFilter("all");
onToggleHiddenGroup(activeGroupMenu);
setGroupMenuId(null);
}}
@ -866,7 +1166,7 @@ export default function TaskBoardNavigator({
<button
type="button"
onClick={() => {
if (groupFilter === activeGroupMenu.id) setGroupFilter("all");
if (groupFilter === activeGroupMenu.id) selectGroupFilter("all");
onDeleteGroup(activeGroupMenu);
setGroupMenuId(null);
}}

View File

@ -28,7 +28,7 @@ type TaskLinkPillProps = {
const fallbackColumnColor = "#38bdf8";
const priorityColors: Record<TaskPriority, string> = {
low: "#22c55e",
low: "#64748b",
medium: "#38bdf8",
high: "#f59e0b",
critical: "#ef4444",

View File

@ -1,6 +1,6 @@
"use client";
import { type ReactNode } from "react";
import { type CSSProperties, type ReactNode } from "react";
import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext";
import { useAutoRevealOnScroll } from "@/hooks/useAutoRevealOnScroll";
@ -10,6 +10,7 @@ type AutoRevealStickyHeaderProps = {
className?: string;
disableOnDesktop?: boolean;
surface?: "solid" | "transparent";
style?: CSSProperties;
};
export function AutoRevealStickyHeader({
@ -17,6 +18,7 @@ export function AutoRevealStickyHeader({
className = "",
disableOnDesktop = false,
surface = "solid",
style,
}: AutoRevealStickyHeaderProps) {
const { mobileNavPlacement } = useMobileNavPlacement();
const { elevated, show, visible } = useAutoRevealOnScroll();
@ -25,6 +27,7 @@ export function AutoRevealStickyHeader({
<div
data-auto-reveal-header="true"
onFocusCapture={show}
style={style}
className={`sticky z-30 transition-[transform,box-shadow] duration-200 ease-out will-change-transform ${
disableOnDesktop
? "lg:static lg:translate-y-0 lg:bg-transparent lg:shadow-none"

View File

@ -73,10 +73,6 @@ export function useCalendarModals() {
}, []);
const handleCreateEventFromDay = useCallback(() => {
if (!currentSelectedCalendarId) {
alert("Сначала выберите календарь");
return;
}
if (!selectedDateForModal) return;
const startDate = new Date(selectedDateForModal);

View File

@ -4,6 +4,8 @@ import type {
CalendarEvent,
CalendarOccurrence,
CalendarParticipant,
CalendarNotificationSettings,
CalendarParticipantRole,
PaginatedResponse,
} from '@/types/api';
@ -57,6 +59,12 @@ export function createCalendarApi(request: RequestFn, requestRaw: RawRequestFn)
request(`/api/v1/schedule/calendars/${calendarId}/add-participant/`, { method: 'POST', body: JSON.stringify({ user_id: userId, role: distinction }) }),
removeCalendarParticipant: (calendarId: number, userId: number): Promise<void> =>
request(`/api/v1/schedule/calendars/${calendarId}/remove-participant/${userId}/`, { method: 'DELETE' }),
updateCalendarParticipantRole: (calendarId: number, userId: number, role: CalendarParticipantRole): Promise<CalendarParticipant> =>
request(`/api/v1/schedule/calendars/${calendarId}/participant-role/${userId}/`, { method: 'PATCH', body: JSON.stringify({ role }) }),
getCalendarNotificationSettings: (calendarId: number): Promise<CalendarNotificationSettings> =>
request(`/api/v1/schedule/calendars/${calendarId}/notification-settings/`),
updateCalendarNotificationSettings: (calendarId: number, settings: Partial<Omit<CalendarNotificationSettings, 'can_manage'>>): Promise<CalendarNotificationSettings> =>
request(`/api/v1/schedule/calendars/${calendarId}/notification-settings/`, { method: 'PATCH', body: JSON.stringify(settings) }),
getEventParticipants: (eventId: number) => request(`/api/v1/schedule/relations/?event=${eventId}`),
addEventParticipant: (eventId: number, userId: number, distinction: string = 'attendee') =>
request('/api/v1/schedule/relations/', { method: 'POST', body: JSON.stringify({ event: eventId, object_id: userId, distinction }) }),

View File

@ -71,7 +71,7 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
moveTaskBoardToGroup: (
id: number,
group: number | null,
): Promise<{ board: number; group_ids: number[]; group_id: number | null }> =>
): Promise<{ board: number; group_ids: number[]; group_id: number | null; group_positions: Record<string, number>; group_sort_keys: Record<string, string> }> =>
request(`/api/v1/tasks/boards/${id}/move-to-group/`, {
method: "POST",
body: JSON.stringify({ group }),
@ -79,7 +79,7 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
setTaskBoardGroups: (
id: number,
groups: number[],
): Promise<{ board: number; group_ids: number[]; group_id: number | null }> =>
): Promise<{ board: number; group_ids: number[]; group_id: number | null; group_positions: Record<string, number>; group_sort_keys: Record<string, string> }> =>
request(`/api/v1/tasks/boards/${id}/groups/`, {
method: "POST",
body: JSON.stringify({ groups }),
@ -136,6 +136,26 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
request(`/api/v1/tasks/board-groups/${id}/toggle-hidden/`, {
method: "POST",
}),
setTaskBoardGroupBoards: (
id: number,
boards: number[],
): Promise<{ group: number; board_ids: number[]; positions: Record<string, number>; rank_keys: Record<string, string> }> =>
request(`/api/v1/tasks/board-groups/${id}/boards/`, {
method: "POST",
body: JSON.stringify({ boards }),
}),
reorderTaskBoardInGroup: (
groupId: number,
data: {
board: number;
previous_board: number | null;
next_board: number | null;
},
): Promise<{ group: number; board: number; positions: Record<string, number>; rank_keys: Record<string, string> }> =>
request(`/api/v1/tasks/board-groups/${groupId}/reorder-board/`, {
method: "POST",
body: JSON.stringify(data),
}),
markTaskBoardNotificationsRead: (
id: number,
): Promise<{ status: string; count: number; notification_ids: number[] }> =>

View File

@ -18,6 +18,7 @@ export interface CalendarParticipantsTarget {
type?: string | null;
context_type?: string | null;
access_scope?: "all" | "restricted";
initial_tab?: "participants" | "roles";
}
function parsePositiveInt(value: string | null): number | null {
@ -61,6 +62,7 @@ export function resolveSelectedCalendar(
can_create_events: true,
can_edit_calendar: false,
can_manage_participants: false,
can_manage_notification_settings: false,
};
}
@ -104,6 +106,7 @@ export function canOpenParticipantsModal(
export function toParticipantsTarget(
calendar: Calendar,
initialTab: "participants" | "roles" = "participants",
): CalendarParticipantsTarget {
return {
id: calendar.id,
@ -112,5 +115,6 @@ export function toParticipantsTarget(
type: calendar.type,
context_type: calendar.context_type,
access_scope: calendar.access_scope,
initial_tab: initialTab,
};
}

View File

@ -376,6 +376,8 @@ export interface TaskBoard {
is_default_for_current_user?: boolean;
group_ids?: number[];
group_id?: number | null;
group_positions?: Record<string, number>;
group_sort_keys?: Record<string, string>;
is_pinned?: boolean;
notifications_enabled?: boolean;
view_state: TaskBoardViewState;
@ -398,6 +400,8 @@ export interface TaskBoardSummary {
is_default_for_current_user?: boolean;
group_ids?: number[];
group_id?: number | null;
group_positions?: Record<string, number>;
group_sort_keys?: Record<string, string>;
is_pinned?: boolean;
notifications_enabled?: boolean;
created_at: string;
@ -1772,6 +1776,15 @@ export interface CalendarParticipant {
distinction: string;
}
export type CalendarParticipantRole = "owner" | "editor" | "viewer";
export interface CalendarNotificationSettings {
notify_event_created: boolean;
notify_event_changed: boolean;
notify_event_cancelled: boolean;
can_manage: boolean;
}
export interface Calendar {
id: number;
name: string; // django-scheduler использует name (не title)
@ -1786,6 +1799,7 @@ export interface Calendar {
can_create_events?: boolean;
can_edit_calendar?: boolean;
can_manage_participants?: boolean;
can_manage_notification_settings?: boolean;
}
// Notifications types (v2 API)