Implement application updates and supporting changes
This commit is contained in:
parent
f6904ca2dd
commit
87a9a6fd08
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
"""Получить список всех участников календаря."""
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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="Уведомлять о создании события",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
151
backend/tasks/group_ranking.py
Normal file
151
backend/tasks/group_ranking.py
Normal 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
|
||||
139
backend/tasks/migrations/0038_taskboardgroupmembership.py
Normal file
139
backend/tasks/migrations/0038_taskboardgroupmembership.py
Normal 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="Добавлено"),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)]">
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
165
frontend/src/components/CalendarNotificationSettingsModal.tsx
Normal file
165
frontend/src/components/CalendarNotificationSettingsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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);
|
||||
}}
|
||||
|
||||
@ -28,7 +28,7 @@ type TaskLinkPillProps = {
|
||||
const fallbackColumnColor = "#38bdf8";
|
||||
|
||||
const priorityColors: Record<TaskPriority, string> = {
|
||||
low: "#22c55e",
|
||||
low: "#64748b",
|
||||
medium: "#38bdf8",
|
||||
high: "#f59e0b",
|
||||
critical: "#ef4444",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -73,10 +73,6 @@ export function useCalendarModals() {
|
||||
}, []);
|
||||
|
||||
const handleCreateEventFromDay = useCallback(() => {
|
||||
if (!currentSelectedCalendarId) {
|
||||
alert("Сначала выберите календарь");
|
||||
return;
|
||||
}
|
||||
if (!selectedDateForModal) return;
|
||||
|
||||
const startDate = new Date(selectedDateForModal);
|
||||
|
||||
@ -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 }) }),
|
||||
|
||||
@ -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[] }> =>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user