feat: improve task boards and chat controls
This commit is contained in:
parent
0e012c3069
commit
a889086661
@ -1,6 +1,6 @@
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from django.db.models import Max
|
||||
from django.db.models import Count, Max
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
@ -44,8 +44,10 @@ from tasks.models import (
|
||||
TaskBoard,
|
||||
TaskBoardAccessScope,
|
||||
TaskBoardGroup,
|
||||
TaskBoardPlacement,
|
||||
TaskChecklistItem,
|
||||
TaskColumn,
|
||||
TaskColumnRow,
|
||||
TaskExternalLink,
|
||||
TaskLabel,
|
||||
TaskLinkedObject,
|
||||
@ -205,6 +207,54 @@ class TaskColumnSerializer(serializers.ModelSerializer):
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class TaskColumnRowSerializer(serializers.ModelSerializer):
|
||||
tasks_count = serializers.IntegerField(read_only=True, default=0)
|
||||
|
||||
class Meta:
|
||||
model = TaskColumnRow
|
||||
fields = [
|
||||
"id",
|
||||
"column",
|
||||
"name",
|
||||
"position",
|
||||
"color",
|
||||
"tasks_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "tasks_count", "created_at", "updated_at"]
|
||||
|
||||
def validate_column(self, column):
|
||||
validate_board_access(self, column.board)
|
||||
if column.parent_id:
|
||||
raise serializers.ValidationError(
|
||||
"Дорожки можно создавать только в основной колонке."
|
||||
)
|
||||
if column.is_archived:
|
||||
raise serializers.ValidationError(
|
||||
"Нельзя создать дорожку в архивной колонке."
|
||||
)
|
||||
return column
|
||||
|
||||
def validate(self, attrs):
|
||||
column = attrs.get("column") or getattr(self.instance, "column", None)
|
||||
if self.instance and column and self.instance.column_id != column.id:
|
||||
raise serializers.ValidationError(
|
||||
{"column": "Основную колонку дорожки изменить нельзя."}
|
||||
)
|
||||
name = attrs.get("name", getattr(self.instance, "name", "")).strip()
|
||||
if not name:
|
||||
raise serializers.ValidationError({"name": "Укажите название дорожки."})
|
||||
duplicate = TaskColumnRow.objects.filter(column=column, name__iexact=name)
|
||||
if self.instance:
|
||||
duplicate = duplicate.exclude(pk=self.instance.pk)
|
||||
if duplicate.exists():
|
||||
raise serializers.ValidationError(
|
||||
{"name": "Дорожка с таким названием уже существует в колонке."}
|
||||
)
|
||||
attrs["name"] = name
|
||||
return attrs
|
||||
|
||||
class TaskSerializer(serializers.ModelSerializer):
|
||||
created_by = EmployeeBriefSerializer(read_only=True)
|
||||
assignee = EmployeeBriefSerializer(read_only=True)
|
||||
@ -238,6 +288,8 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
board_name = serializers.CharField(source="board.name", read_only=True)
|
||||
column_name = serializers.CharField(source="column.name", read_only=True)
|
||||
column_color = serializers.CharField(source="column.color", read_only=True)
|
||||
row_name = serializers.CharField(source="row.name", read_only=True)
|
||||
row_color = serializers.CharField(source="row.color", read_only=True)
|
||||
linked_posts_count = serializers.SerializerMethodField()
|
||||
linked_messages_count = serializers.SerializerMethodField()
|
||||
linked_events_count = serializers.SerializerMethodField()
|
||||
@ -263,6 +315,9 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
"column",
|
||||
"column_name",
|
||||
"column_color",
|
||||
"row",
|
||||
"row_name",
|
||||
"row_color",
|
||||
"title",
|
||||
"description",
|
||||
"created_by",
|
||||
@ -302,6 +357,8 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
"board_name",
|
||||
"column_name",
|
||||
"column_color",
|
||||
"row_name",
|
||||
"row_color",
|
||||
"priority_display",
|
||||
"completed_at",
|
||||
"linked_posts_count",
|
||||
@ -443,6 +500,11 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
user = getattr(request, "user", None)
|
||||
board = attrs.get("board") or getattr(self.instance, "board", None)
|
||||
column = attrs.get("column") or getattr(self.instance, "column", None)
|
||||
row = (
|
||||
attrs.get("row")
|
||||
if "row" in attrs
|
||||
else getattr(self.instance, "row", None)
|
||||
)
|
||||
labels = attrs.get("labels")
|
||||
participants = attrs.get("participants")
|
||||
|
||||
@ -461,6 +523,13 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
{"column": "Выберите подколонку для размещения задачи."}
|
||||
)
|
||||
|
||||
if row and column:
|
||||
top_level_column_id = column.parent_id or column.id
|
||||
if row.column_id != top_level_column_id:
|
||||
raise serializers.ValidationError(
|
||||
{"row": "Дорожка должна принадлежать основной колонке задачи."}
|
||||
)
|
||||
|
||||
if labels is not None and board:
|
||||
invalid_labels = [
|
||||
label.id for label in labels if label.board_id != board.id
|
||||
@ -493,8 +562,9 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
participants = list(validated_data.pop("participants", []))
|
||||
if "position" not in validated_data:
|
||||
column = validated_data["column"]
|
||||
row = validated_data.get("row")
|
||||
max_position = (
|
||||
Task.objects.filter(column=column).aggregate(Max("position"))[
|
||||
Task.objects.filter(column=column, row=row).aggregate(Max("position"))[
|
||||
"position__max"
|
||||
]
|
||||
or 0
|
||||
@ -560,6 +630,7 @@ class TaskBoardSerializer(serializers.ModelSerializer):
|
||||
read_only=True,
|
||||
)
|
||||
columns = TaskColumnSerializer(many=True, read_only=True)
|
||||
column_rows = serializers.SerializerMethodField()
|
||||
labels = TaskLabelSerializer(many=True, read_only=True)
|
||||
tasks = TaskSerializer(many=True, read_only=True)
|
||||
tasks_count = serializers.IntegerField(read_only=True, default=0)
|
||||
@ -580,6 +651,7 @@ class TaskBoardSerializer(serializers.ModelSerializer):
|
||||
read_only=True,
|
||||
default=True,
|
||||
)
|
||||
view_state = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TaskBoard
|
||||
@ -596,6 +668,7 @@ class TaskBoardSerializer(serializers.ModelSerializer):
|
||||
"department_details",
|
||||
"is_archived",
|
||||
"columns",
|
||||
"column_rows",
|
||||
"labels",
|
||||
"tasks",
|
||||
"tasks_count",
|
||||
@ -603,6 +676,7 @@ class TaskBoardSerializer(serializers.ModelSerializer):
|
||||
"group_id",
|
||||
"is_pinned",
|
||||
"notifications_enabled",
|
||||
"view_state",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@ -629,6 +703,63 @@ class TaskBoardSerializer(serializers.ModelSerializer):
|
||||
attrs["departments"] = []
|
||||
return attrs
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_view_state(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return {
|
||||
"hidden_column_ids": [],
|
||||
"collapsed_base_column_ids": [],
|
||||
"collapsed_row_ids": [],
|
||||
}
|
||||
raw_state = (
|
||||
TaskBoardPlacement.objects.filter(user=user, board=obj)
|
||||
.values_list("view_state", flat=True)
|
||||
.first()
|
||||
or {}
|
||||
)
|
||||
valid_column_ids = set(
|
||||
obj.columns.filter(parent__isnull=True, is_archived=False).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
valid_row_ids = set(
|
||||
TaskColumnRow.objects.filter(
|
||||
column__board=obj,
|
||||
column__is_archived=False,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
|
||||
def valid_ids(key, allowed_ids):
|
||||
values = raw_state.get(key, []) if isinstance(raw_state, dict) else []
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
return sorted(
|
||||
{
|
||||
value
|
||||
for value in values
|
||||
if isinstance(value, int) and value in allowed_ids
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"hidden_column_ids": valid_ids("hidden_column_ids", valid_column_ids),
|
||||
"collapsed_base_column_ids": valid_ids(
|
||||
"collapsed_base_column_ids", valid_column_ids
|
||||
),
|
||||
"collapsed_row_ids": valid_ids("collapsed_row_ids", valid_row_ids),
|
||||
}
|
||||
|
||||
@extend_schema_field(TaskColumnRowSerializer(many=True))
|
||||
def get_column_rows(self, obj):
|
||||
rows = TaskColumnRow.objects.filter(
|
||||
column__board=obj,
|
||||
).annotate(
|
||||
tasks_count=Count("tasks", distinct=True)
|
||||
).order_by("column_id", "position", "id")
|
||||
return TaskColumnRowSerializer(rows, many=True).data
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_is_default_for_current_user(self, obj):
|
||||
request = self.context.get("request")
|
||||
|
||||
@ -5,6 +5,7 @@ from .views import (
|
||||
TaskBoardGroupViewSet,
|
||||
TaskBoardViewSet,
|
||||
TaskColumnViewSet,
|
||||
TaskColumnRowViewSet,
|
||||
TaskLabelViewSet,
|
||||
TaskViewSet,
|
||||
)
|
||||
@ -15,6 +16,7 @@ router = DefaultRouter()
|
||||
router.register(r"board-groups", TaskBoardGroupViewSet, basename="task-board-group")
|
||||
router.register(r"boards", TaskBoardViewSet, basename="task-board")
|
||||
router.register(r"columns", TaskColumnViewSet, basename="task-column")
|
||||
router.register(r"column-rows", TaskColumnRowViewSet, basename="task-column-row")
|
||||
router.register(r"labels", TaskLabelViewSet, basename="task-label")
|
||||
router.register(r"", TaskViewSet, basename="task")
|
||||
|
||||
|
||||
@ -58,6 +58,7 @@ from tasks.models import (
|
||||
TaskBoardGroup,
|
||||
TaskBoardPlacement,
|
||||
TaskColumn,
|
||||
TaskColumnRow,
|
||||
TaskExternalLink,
|
||||
TaskLabel,
|
||||
TaskLinkedObject,
|
||||
@ -88,6 +89,7 @@ from .serializers import (
|
||||
TaskBoardSummarySerializer,
|
||||
TaskChecklistItemSerializer,
|
||||
TaskColumnSerializer,
|
||||
TaskColumnRowSerializer,
|
||||
TaskExternalLinkSerializer,
|
||||
TaskLabelSerializer,
|
||||
TaskLinkedCalendarEventSerializer,
|
||||
@ -145,9 +147,9 @@ def create_task_activity(
|
||||
)
|
||||
|
||||
|
||||
def normalize_column_positions(column):
|
||||
def normalize_column_positions(column, row=None):
|
||||
task_ids = list(
|
||||
column.tasks.order_by("position", "-created_at", "id").values_list(
|
||||
column.tasks.filter(row=row).order_by("position", "-created_at", "id").values_list(
|
||||
"id",
|
||||
flat=True,
|
||||
)
|
||||
@ -375,7 +377,11 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
).first()
|
||||
if placement:
|
||||
placement.group = None
|
||||
if not placement.is_pinned and placement.notifications_enabled:
|
||||
if (
|
||||
not placement.is_pinned
|
||||
and placement.notifications_enabled
|
||||
and not placement.view_state
|
||||
):
|
||||
placement.delete()
|
||||
else:
|
||||
placement.save(update_fields=["group", "updated_at"])
|
||||
@ -411,7 +417,12 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
placement.is_pinned = not placement.is_pinned
|
||||
placement.pinned_at = timezone.now() if placement.is_pinned else None
|
||||
is_pinned = placement.is_pinned
|
||||
if not placement.is_pinned and placement.group_id is None and placement.notifications_enabled:
|
||||
if (
|
||||
not placement.is_pinned
|
||||
and placement.group_id is None
|
||||
and placement.notifications_enabled
|
||||
and not placement.view_state
|
||||
):
|
||||
placement.delete()
|
||||
else:
|
||||
placement.save(update_fields=["is_pinned", "pinned_at", "updated_at"])
|
||||
@ -427,7 +438,12 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
placement.notifications_enabled = not placement.notifications_enabled
|
||||
notifications_enabled = placement.notifications_enabled
|
||||
if placement.notifications_enabled and placement.group_id is None and not placement.is_pinned:
|
||||
if (
|
||||
placement.notifications_enabled
|
||||
and placement.group_id is None
|
||||
and not placement.is_pinned
|
||||
and not placement.view_state
|
||||
):
|
||||
placement.delete()
|
||||
else:
|
||||
placement.save(update_fields=["notifications_enabled", "updated_at"])
|
||||
@ -435,6 +451,96 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
{"ok": True, "notifications_enabled": notifications_enabled}
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["patch"], url_path="view-state")
|
||||
def view_state(self, request, pk=None):
|
||||
board = self.get_object()
|
||||
scope = request.data.get("scope")
|
||||
collapsed = request.data.get("collapsed")
|
||||
try:
|
||||
object_id = int(request.data.get("object_id"))
|
||||
except (TypeError, ValueError):
|
||||
object_id = None
|
||||
|
||||
state_keys = {
|
||||
"column": "hidden_column_ids",
|
||||
"base": "collapsed_base_column_ids",
|
||||
"row": "collapsed_row_ids",
|
||||
}
|
||||
if scope not in state_keys:
|
||||
return Response(
|
||||
{"scope": ["Допустимые значения: column, base, row."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if object_id is None:
|
||||
return Response(
|
||||
{"object_id": ["Укажите корректный идентификатор."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if not isinstance(collapsed, bool):
|
||||
return Response(
|
||||
{"collapsed": ["Укажите логическое значение."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if scope == "row":
|
||||
object_exists = TaskColumnRow.objects.filter(
|
||||
id=object_id,
|
||||
column__board=board,
|
||||
column__is_archived=False,
|
||||
).exists()
|
||||
else:
|
||||
object_exists = TaskColumn.objects.filter(
|
||||
id=object_id,
|
||||
board=board,
|
||||
parent__isnull=True,
|
||||
is_archived=False,
|
||||
).exists()
|
||||
if not object_exists:
|
||||
return Response(
|
||||
{"object_id": ["Элемент не принадлежит этой доске."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
placement, _ = TaskBoardPlacement.objects.select_for_update().get_or_create(
|
||||
user=request.user,
|
||||
board=board,
|
||||
defaults={"group": None},
|
||||
)
|
||||
state = placement.view_state.copy() if isinstance(placement.view_state, dict) else {}
|
||||
key = state_keys[scope]
|
||||
values = {
|
||||
value
|
||||
for value in state.get(key, [])
|
||||
if isinstance(value, int)
|
||||
}
|
||||
if collapsed:
|
||||
values.add(object_id)
|
||||
else:
|
||||
values.discard(object_id)
|
||||
state[key] = sorted(values)
|
||||
state = {name: value for name, value in state.items() if value}
|
||||
placement.view_state = state
|
||||
if (
|
||||
not state
|
||||
and placement.group_id is None
|
||||
and not placement.is_pinned
|
||||
and placement.notifications_enabled
|
||||
):
|
||||
placement.delete()
|
||||
else:
|
||||
placement.save(update_fields=["view_state", "updated_at"])
|
||||
|
||||
return Response(
|
||||
{
|
||||
"hidden_column_ids": state.get("hidden_column_ids", []),
|
||||
"collapsed_base_column_ids": state.get(
|
||||
"collapsed_base_column_ids", []
|
||||
),
|
||||
"collapsed_row_ids": state.get("collapsed_row_ids", []),
|
||||
}
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="notifications/read")
|
||||
def mark_notifications_read(self, request, pk=None):
|
||||
board = self.get_object()
|
||||
@ -489,6 +595,7 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet):
|
||||
group__isnull=True,
|
||||
is_pinned=False,
|
||||
notifications_enabled=True,
|
||||
view_state={},
|
||||
).delete()
|
||||
|
||||
|
||||
@ -679,10 +786,59 @@ class TaskColumnViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"board": board.id, "parent": parent_id, "column_ids": column_ids}
|
||||
{
|
||||
"board": board.id,
|
||||
"parent": parent_id,
|
||||
"column_ids": column_ids,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TaskColumnRowViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = TaskColumnRowSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = (
|
||||
TaskColumnRow.objects.filter(
|
||||
column__board__is_archived=False,
|
||||
column__board__in=TaskBoard.objects.filter(
|
||||
task_board_access_q(self.request.user)
|
||||
),
|
||||
)
|
||||
.select_related("column", "column__board")
|
||||
.annotate(tasks_count=Count("tasks", distinct=True))
|
||||
.order_by("column_id", "position", "id")
|
||||
)
|
||||
if column := self.request.query_params.get("column"):
|
||||
queryset = queryset.filter(column_id=column)
|
||||
if board := self.request.query_params.get("board"):
|
||||
queryset = queryset.filter(column__board_id=board)
|
||||
return queryset
|
||||
|
||||
def perform_create(self, serializer):
|
||||
row = serializer.save()
|
||||
send_task_board_update(row.column.board, "created", "column_row", row.id)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
row = serializer.save()
|
||||
send_task_board_update(row.column.board, "updated", "column_row", row.id)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
board = instance.column.board
|
||||
row_id = instance.id
|
||||
leaf_columns = list(
|
||||
instance.column.subcolumns.filter(is_archived=False).order_by(
|
||||
"position",
|
||||
"id",
|
||||
)
|
||||
) or [instance.column]
|
||||
instance.delete()
|
||||
for column in leaf_columns:
|
||||
normalize_column_positions(column, None)
|
||||
send_task_board_update(board, "deleted", "column_row", row_id)
|
||||
|
||||
|
||||
class TaskLabelViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = TaskLabelSerializer
|
||||
@ -745,7 +901,7 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
task_board_access_q(self.request.user)
|
||||
)
|
||||
)
|
||||
.select_related("board", "column", "created_by", "assignee")
|
||||
.select_related("board", "column", "row", "created_by", "assignee")
|
||||
.prefetch_related("labels", "participants", "checklist_items")
|
||||
.annotate(
|
||||
linked_posts_count=Count(
|
||||
@ -840,6 +996,8 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
queryset = queryset.filter(board_id=board)
|
||||
if column := params.get("column"):
|
||||
queryset = queryset.filter(column_id=column)
|
||||
if row := params.get("row"):
|
||||
queryset = queryset.filter(row_id=row)
|
||||
if assignee := params.get("assignee"):
|
||||
queryset = queryset.filter(assignee_id=assignee)
|
||||
if priority := params.get("priority"):
|
||||
@ -867,6 +1025,7 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
"assignee_id": instance.assignee_id,
|
||||
"priority": instance.priority,
|
||||
"due_date": instance.due_date.isoformat() if instance.due_date else None,
|
||||
"row_id": instance.row_id,
|
||||
"label_ids": sorted(instance.labels.values_list("id", flat=True)),
|
||||
"participant_ids": sorted(
|
||||
instance.participants.values_list("id", flat=True)
|
||||
@ -879,6 +1038,7 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
"assignee_id": task.assignee_id,
|
||||
"priority": task.priority,
|
||||
"due_date": task.due_date.isoformat() if task.due_date else None,
|
||||
"row_id": task.row_id,
|
||||
"label_ids": sorted(task.labels.values_list("id", flat=True)),
|
||||
"participant_ids": sorted(
|
||||
task.participants.values_list("id", flat=True)
|
||||
@ -904,11 +1064,12 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
instance.delete()
|
||||
send_task_board_update(board, "deleted", "task", task_id)
|
||||
|
||||
def _move_task(self, task, column, position, actor):
|
||||
def _move_task(self, task, column, row, position, actor):
|
||||
old_column = task.column
|
||||
old_row = task.row
|
||||
if position in (None, ""):
|
||||
max_position = (
|
||||
Task.objects.filter(column=column).exclude(id=task.id).aggregate(
|
||||
Task.objects.filter(column=column, row=row).exclude(id=task.id).aggregate(
|
||||
Max("position")
|
||||
)["position__max"]
|
||||
or 0
|
||||
@ -916,26 +1077,38 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
position = max_position + 1000
|
||||
|
||||
task.column = column
|
||||
task.row = row
|
||||
task.position = position
|
||||
task.save(
|
||||
update_fields=["column", "position", "completed_at", "updated_at"]
|
||||
update_fields=["column", "row", "position", "completed_at", "updated_at"]
|
||||
)
|
||||
|
||||
normalize_column_positions(column)
|
||||
if old_column.id != column.id:
|
||||
normalize_column_positions(old_column)
|
||||
normalize_column_positions(column, row)
|
||||
if old_column.id != column.id or old_row != row:
|
||||
normalize_column_positions(old_column, old_row)
|
||||
task.refresh_from_db()
|
||||
|
||||
activity_metadata = {
|
||||
"from_column_id": old_column.id,
|
||||
"from_column": old_column.name,
|
||||
"to_column_id": column.id,
|
||||
"to_column": column.name,
|
||||
}
|
||||
if old_row or row:
|
||||
activity_metadata.update(
|
||||
{
|
||||
"from_row_id": old_row.id if old_row else None,
|
||||
"from_row": old_row.name if old_row else None,
|
||||
"to_row_id": row.id if row else None,
|
||||
"to_row": row.name if row else None,
|
||||
}
|
||||
)
|
||||
|
||||
create_task_activity(
|
||||
task,
|
||||
actor,
|
||||
TaskActivityAction.MOVED,
|
||||
metadata={
|
||||
"from_column_id": old_column.id,
|
||||
"from_column": old_column.name,
|
||||
"to_column_id": column.id,
|
||||
"to_column": column.name,
|
||||
},
|
||||
metadata=activity_metadata,
|
||||
)
|
||||
notify_task_moved(task, actor, old_column, column)
|
||||
send_task_board_update(
|
||||
@ -943,7 +1116,7 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
"moved",
|
||||
"task",
|
||||
task.id,
|
||||
extra={"column_id": task.column_id},
|
||||
extra={"column_id": task.column_id, "row_id": task.row_id},
|
||||
)
|
||||
return task
|
||||
|
||||
@ -951,6 +1124,7 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
def move(self, request, pk=None):
|
||||
task = self.get_object()
|
||||
column_id = request.data.get("column")
|
||||
raw_row_id = request.data.get("row") if "row" in request.data else None
|
||||
position = request.data.get("position")
|
||||
if not column_id:
|
||||
return Response(
|
||||
@ -976,6 +1150,27 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
row = None
|
||||
if raw_row_id not in (None, ""):
|
||||
try:
|
||||
row_id = int(raw_row_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{"row": ["Идентификатор дорожки должен быть числом."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
top_level_column_id = column.parent_id or column.id
|
||||
try:
|
||||
row = TaskColumnRow.objects.get(
|
||||
id=row_id,
|
||||
column_id=top_level_column_id,
|
||||
)
|
||||
except TaskColumnRow.DoesNotExist:
|
||||
return Response(
|
||||
{"row": ["Дорожка не принадлежит основной колонке задачи."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if position in (None, ""):
|
||||
position = None
|
||||
else:
|
||||
@ -987,7 +1182,7 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
task = self._move_task(task, column, position, request.user)
|
||||
task = self._move_task(task, column, row, position, request.user)
|
||||
serializer = self.get_serializer(task)
|
||||
return Response(serializer.data)
|
||||
|
||||
@ -1074,7 +1269,7 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
task = self._move_task(task, done_column, None, request.user)
|
||||
task = self._move_task(task, done_column, None, None, request.user)
|
||||
return Response(self.get_serializer(task).data)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="activity")
|
||||
|
||||
@ -9,6 +9,7 @@ from .models import (
|
||||
TaskBoardPlacement,
|
||||
TaskChecklistItem,
|
||||
TaskColumn,
|
||||
TaskColumnRow,
|
||||
TaskExternalLink,
|
||||
TaskLabel,
|
||||
TaskLinkedObject,
|
||||
@ -63,6 +64,7 @@ class TaskBoardPlacementAdmin(admin.ModelAdmin):
|
||||
list_filter = ["is_pinned", "notifications_enabled", "group"]
|
||||
search_fields = ["board__name", "group__name", "user__email"]
|
||||
autocomplete_fields = ["board", "group", "user"]
|
||||
readonly_fields = ["view_state"]
|
||||
|
||||
|
||||
@admin.register(TaskColumn)
|
||||
@ -79,6 +81,13 @@ class TaskColumnAdmin(admin.ModelAdmin):
|
||||
search_fields = ["name", "board__name"]
|
||||
|
||||
|
||||
@admin.register(TaskColumnRow)
|
||||
class TaskColumnRowAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "column", "position", "color"]
|
||||
list_filter = ["column__board", "column"]
|
||||
search_fields = ["name", "column__name", "column__board__name"]
|
||||
|
||||
|
||||
@admin.register(TaskLabel)
|
||||
class TaskLabelAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "board", "color"]
|
||||
@ -92,12 +101,13 @@ class TaskAdmin(admin.ModelAdmin):
|
||||
"title",
|
||||
"board",
|
||||
"column",
|
||||
"row",
|
||||
"assignee",
|
||||
"priority",
|
||||
"due_date",
|
||||
"completed_at",
|
||||
]
|
||||
list_filter = ["board", "column", "priority", "due_date"]
|
||||
list_filter = ["board", "column", "row", "priority", "due_date"]
|
||||
search_fields = ["title", "description", "assignee__email"]
|
||||
filter_horizontal = ["labels", "participants"]
|
||||
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tasks", "0024_taskcolumn_parent"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="TaskColumnRow",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("name", models.CharField(max_length=120, verbose_name="Название")),
|
||||
("position", models.PositiveIntegerField(default=0, verbose_name="Позиция")),
|
||||
("color", models.CharField(blank=True, max_length=32, verbose_name="Цвет")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Создано")),
|
||||
("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлено")),
|
||||
("column", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="rows", to="tasks.taskcolumn", verbose_name="Основная колонка")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Дорожка колонки",
|
||||
"verbose_name_plural": "Дорожки колонок",
|
||||
"ordering": ["position", "id"],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="taskcolumnrow",
|
||||
constraint=models.UniqueConstraint(fields=("column", "name"), name="uniq_task_column_row_name"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="taskcolumn",
|
||||
name="row",
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="subcolumns", to="tasks.taskcolumnrow", verbose_name="Дорожка"),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,50 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def move_column_rows_to_tasks(apps, schema_editor):
|
||||
Task = apps.get_model("tasks", "Task")
|
||||
TaskColumn = apps.get_model("tasks", "TaskColumn")
|
||||
|
||||
for column_id, row_id in TaskColumn.objects.exclude(row_id=None).values_list(
|
||||
"id",
|
||||
"row_id",
|
||||
):
|
||||
Task.objects.filter(column_id=column_id).update(row_id=row_id)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tasks", "0025_taskcolumnrow_taskcolumn_row"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="task",
|
||||
name="row",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="tasks",
|
||||
to="tasks.taskcolumnrow",
|
||||
verbose_name="Дорожка",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(move_column_rows_to_tasks, migrations.RunPython.noop),
|
||||
migrations.RemoveField(
|
||||
model_name="taskcolumn",
|
||||
name="row",
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name="task",
|
||||
name="tasks_task_board_i_6021b0_idx",
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="task",
|
||||
index=models.Index(
|
||||
fields=["board", "column", "row", "position"],
|
||||
name="tasks_task_board_i_528a7b_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,19 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tasks", "0026_move_task_column_row_to_task"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="taskboardplacement",
|
||||
name="view_state",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
verbose_name="Состояние представления",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -116,6 +116,11 @@ class TaskBoardPlacement(models.Model):
|
||||
is_pinned = models.BooleanField("Закреплена", default=False)
|
||||
pinned_at = models.DateTimeField("Закреплена в", null=True, blank=True)
|
||||
notifications_enabled = models.BooleanField("Уведомления", default=True)
|
||||
view_state = models.JSONField(
|
||||
"Состояние представления",
|
||||
default=dict,
|
||||
blank=True,
|
||||
)
|
||||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
|
||||
@ -219,6 +224,34 @@ class TaskColumn(models.Model):
|
||||
return self.parent_id is not None
|
||||
|
||||
|
||||
class TaskColumnRow(models.Model):
|
||||
column = models.ForeignKey(
|
||||
TaskColumn,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="rows",
|
||||
verbose_name="Основная колонка",
|
||||
)
|
||||
name = models.CharField("Название", max_length=120)
|
||||
position = models.PositiveIntegerField("Позиция", default=0)
|
||||
color = models.CharField("Цвет", max_length=32, blank=True)
|
||||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Дорожка колонки"
|
||||
verbose_name_plural = "Дорожки колонок"
|
||||
ordering = ["position", "id"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["column", "name"],
|
||||
name="uniq_task_column_row_name",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.column}: {self.name}"
|
||||
|
||||
|
||||
class TaskLabel(models.Model):
|
||||
board = models.ForeignKey(
|
||||
TaskBoard,
|
||||
@ -257,6 +290,14 @@ class Task(models.Model):
|
||||
related_name="tasks",
|
||||
verbose_name="Колонка",
|
||||
)
|
||||
row = models.ForeignKey(
|
||||
TaskColumnRow,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="tasks",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Дорожка",
|
||||
)
|
||||
title = models.CharField("Название", max_length=255)
|
||||
description = models.TextField("Описание", blank=True)
|
||||
created_by = models.ForeignKey(
|
||||
@ -302,7 +343,7 @@ class Task(models.Model):
|
||||
verbose_name_plural = "Задачи"
|
||||
ordering = ["position", "-created_at", "id"]
|
||||
indexes = [
|
||||
models.Index(fields=["board", "column", "position"]),
|
||||
models.Index(fields=["board", "column", "row", "position"]),
|
||||
models.Index(fields=["assignee", "due_date"]),
|
||||
]
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ from tasks.models import (
|
||||
TaskBoardPlacement,
|
||||
TaskChecklistItem,
|
||||
TaskColumn,
|
||||
TaskColumnRow,
|
||||
TaskExternalLink,
|
||||
TaskUserSettings,
|
||||
)
|
||||
@ -442,6 +443,76 @@ def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings(
|
||||
assert TaskBoard.objects.filter(id=first_board.id).exists()
|
||||
|
||||
|
||||
def test_board_view_state_is_persisted_per_user(api_client, user):
|
||||
other_user = make_user("task-view-state-other@example.com", "+79994440096")
|
||||
board = TaskBoard.objects.create(name="Состояние доски", created_by=user)
|
||||
column = TaskColumn.objects.create(
|
||||
board=board,
|
||||
name="Готово",
|
||||
position=1000,
|
||||
)
|
||||
row = TaskColumnRow.objects.create(
|
||||
column=column,
|
||||
name="Вторая дорожка",
|
||||
position=1000,
|
||||
)
|
||||
url = reverse(
|
||||
"api:v1:tasks:task-board-view-state",
|
||||
kwargs={"pk": board.id},
|
||||
)
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
for payload in [
|
||||
{"scope": "column", "object_id": column.id, "collapsed": True},
|
||||
{"scope": "base", "object_id": column.id, "collapsed": True},
|
||||
{"scope": "row", "object_id": row.id, "collapsed": True},
|
||||
]:
|
||||
response = api_client.patch(url, payload, format="json")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
detail_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
|
||||
)
|
||||
assert detail_response.data["view_state"] == {
|
||||
"hidden_column_ids": [column.id],
|
||||
"collapsed_base_column_ids": [column.id],
|
||||
"collapsed_row_ids": [row.id],
|
||||
}
|
||||
assert TaskBoardPlacement.objects.get(user=user, board=board).view_state
|
||||
|
||||
api_client.force_authenticate(user=other_user)
|
||||
other_detail_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
|
||||
)
|
||||
assert other_detail_response.data["view_state"] == {
|
||||
"hidden_column_ids": [],
|
||||
"collapsed_base_column_ids": [],
|
||||
"collapsed_row_ids": [],
|
||||
}
|
||||
|
||||
|
||||
def test_board_view_state_rejects_objects_from_another_board(api_client, user):
|
||||
board = TaskBoard.objects.create(name="Первая доска", created_by=user)
|
||||
other_board = TaskBoard.objects.create(name="Другая доска", created_by=user)
|
||||
other_column = TaskColumn.objects.create(
|
||||
board=other_board,
|
||||
name="Чужая колонка",
|
||||
position=1000,
|
||||
)
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
"api:v1:tasks:task-board-view-state",
|
||||
kwargs={"pk": board.id},
|
||||
),
|
||||
{"scope": "column", "object_id": other_column.id, "collapsed": True},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not TaskBoardPlacement.objects.filter(user=user, board=board).exists()
|
||||
|
||||
def test_user_cannot_set_inaccessible_board_as_default(api_client):
|
||||
owner = make_user("task-default-owner@example.com", "+79994440091")
|
||||
outsider = make_user("task-default-outsider@example.com", "+79994440092")
|
||||
@ -738,6 +809,128 @@ def test_task_cannot_be_created_in_parent_with_subcolumns(api_client, user):
|
||||
assert "column" in response.data
|
||||
|
||||
|
||||
def test_column_rows_are_optional_and_belong_to_top_level_column(api_client, user):
|
||||
board = TaskBoard.objects.create(name="Локальные дорожки", created_by=user)
|
||||
parent = TaskColumn.objects.create(board=board, name="Производство", position=1000)
|
||||
existing_subcolumn = TaskColumn.objects.create(
|
||||
board=board,
|
||||
parent=parent,
|
||||
name="Существующая подколонка",
|
||||
position=1000,
|
||||
)
|
||||
existing_task = Task.objects.create(
|
||||
board=board,
|
||||
column=existing_subcolumn,
|
||||
title="Существующая задача",
|
||||
created_by=user,
|
||||
)
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:v1:tasks:task-column-row-list"),
|
||||
{
|
||||
"column": parent.id,
|
||||
"name": "Первая линия",
|
||||
"position": 1000,
|
||||
"color": "#22c55e",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["column"] == parent.id
|
||||
row = TaskColumnRow.objects.get(pk=response.data["id"])
|
||||
assert TaskColumn.objects.filter(board=board).count() == 2
|
||||
existing_subcolumn.refresh_from_db()
|
||||
existing_task.refresh_from_db()
|
||||
assert existing_task.column_id == existing_subcolumn.id
|
||||
assert existing_task.row_id is None
|
||||
assert row.tasks.count() == 0
|
||||
|
||||
board_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
|
||||
)
|
||||
assert board_response.data["column_rows"][0]["name"] == "Первая линия"
|
||||
|
||||
|
||||
def test_task_row_must_belong_to_tasks_top_level_column(api_client, user):
|
||||
board = TaskBoard.objects.create(name="Проверка дорожек", created_by=user)
|
||||
first_parent = TaskColumn.objects.create(board=board, name="Первая", position=1000)
|
||||
second_parent = TaskColumn.objects.create(board=board, name="Вторая", position=2000)
|
||||
second_child = TaskColumn.objects.create(
|
||||
board=board,
|
||||
parent=second_parent,
|
||||
name="Подколонка второй",
|
||||
position=1000,
|
||||
)
|
||||
row = TaskColumnRow.objects.create(
|
||||
column=first_parent,
|
||||
name="Линия",
|
||||
position=1000,
|
||||
)
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:v1:tasks:task-list"),
|
||||
{
|
||||
"board": board.id,
|
||||
"column": second_child.id,
|
||||
"row": row.id,
|
||||
"title": "Задача в чужой дорожке",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "row" in response.data
|
||||
|
||||
|
||||
def test_task_moves_between_rows_without_changing_column(api_client, user):
|
||||
board = TaskBoard.objects.create(name="Строки ячеек", created_by=user)
|
||||
parent = TaskColumn.objects.create(board=board, name="Производство", position=1000)
|
||||
first_row = TaskColumnRow.objects.create(column=parent, name="Линия 1", position=1000)
|
||||
second_row = TaskColumnRow.objects.create(column=parent, name="Линия 2", position=2000)
|
||||
child = TaskColumn.objects.create(
|
||||
board=board,
|
||||
parent=parent,
|
||||
name="Сборка",
|
||||
position=1000,
|
||||
)
|
||||
task = Task.objects.create(
|
||||
board=board,
|
||||
column=child,
|
||||
row=first_row,
|
||||
title="Перемещаемая задача",
|
||||
created_by=user,
|
||||
position=1000,
|
||||
)
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:v1:tasks:task-move", kwargs={"pk": task.id}),
|
||||
{
|
||||
"column": child.id,
|
||||
"row": second_row.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["column"] == child.id
|
||||
assert response.data["row"] == second_row.id
|
||||
task.refresh_from_db()
|
||||
assert task.column_id == child.id
|
||||
assert task.row_id == second_row.id
|
||||
|
||||
delete_response = api_client.delete(
|
||||
reverse("api:v1:tasks:task-column-row-detail", kwargs={"pk": second_row.id})
|
||||
)
|
||||
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
|
||||
task.refresh_from_db()
|
||||
assert task.column_id == child.id
|
||||
assert task.row_id is None
|
||||
|
||||
|
||||
def test_reorder_subcolumns_does_not_change_top_level_order(api_client, user):
|
||||
board = TaskBoard.objects.create(name="Порядок подколонок", created_by=user)
|
||||
first_parent = TaskColumn.objects.create(
|
||||
@ -901,7 +1094,10 @@ def test_create_task_and_move_to_done_column(api_client, user):
|
||||
send_args, send_kwargs = send_update.call_args
|
||||
assert send_args[0].id == board_id
|
||||
assert send_args[1:4] == ("moved", "task", task_id)
|
||||
assert send_kwargs["extra"] == {"column_id": columns["Готово"]["id"]}
|
||||
assert send_kwargs["extra"] == {
|
||||
"column_id": columns["Готово"]["id"],
|
||||
"row_id": None,
|
||||
}
|
||||
|
||||
task = Task.objects.get(id=task_id)
|
||||
assert task.column_id == columns["Готово"]["id"]
|
||||
|
||||
@ -33,7 +33,7 @@ export default function MessagesPage() {
|
||||
|
||||
function StandardMessagesList() {
|
||||
const { setMode, openCreateChat } = useMessagesWorkspace();
|
||||
const { user, identity, visibleChats, chatTypeCounts, loading, error } = useMessagesDirectory();
|
||||
const { user, identity, visibleChats, chatTypeCounts, loading, error, refreshChat } = useMessagesDirectory();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -73,7 +73,13 @@ function StandardMessagesList() {
|
||||
<p className="app-text-muted text-sm">Чаты не найдены</p>
|
||||
</div>
|
||||
) : visibleChats.map((chat) => (
|
||||
<MessagesChatListItem key={chat.id} chat={chat} currentUserId={user?.id} identity={identity} />
|
||||
<MessagesChatListItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
currentUserId={user?.id}
|
||||
identity={identity}
|
||||
onChatUpdated={refreshChat}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
248
frontend/src/components/messages/ChatSettingsModal.tsx
Normal file
248
frontend/src/components/messages/ChatSettingsModal.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState, type ChangeEvent } from "react";
|
||||
import { Bell, BellOff, Pencil, Pin } from "lucide-react";
|
||||
|
||||
import EditChatModal from "@/components/messages/EditChatModal";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils";
|
||||
import { getChatTypeLabel } from "@/lib/messages/chatSettingsUtils";
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import type { Chat } from "@/types/api";
|
||||
|
||||
type ChatSettingsModalProps = {
|
||||
chatId: number;
|
||||
currentUserId?: number;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onChatUpdated?: (chatId: number) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export default function ChatSettingsModal({
|
||||
chatId,
|
||||
currentUserId,
|
||||
open,
|
||||
onClose,
|
||||
onChatUpdated,
|
||||
}: ChatSettingsModalProps) {
|
||||
const [chat, setChat] = useState<Chat | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editAvatarFile, setEditAvatarFile] = useState<File | null>(null);
|
||||
const [editAvatarPreview, setEditAvatarPreview] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void apiClient.getChat(chatId)
|
||||
.then((data: Chat) => {
|
||||
if (!cancelled) setChat(data);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
console.error("Не удалось загрузить настройки чата:", loadError);
|
||||
if (!cancelled) setError("Не удалось загрузить настройки чата");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chatId, open]);
|
||||
|
||||
const syncChat = async () => {
|
||||
const refreshed: Chat = await apiClient.getChat(chatId);
|
||||
setChat(refreshed);
|
||||
await onChatUpdated?.(chatId);
|
||||
};
|
||||
|
||||
const togglePin = async () => {
|
||||
if (!chat || actionLoading) return;
|
||||
setActionLoading("pin");
|
||||
try {
|
||||
await apiClient.togglePinChat(chatId);
|
||||
await syncChat();
|
||||
} catch (actionError) {
|
||||
console.error("Не удалось изменить закрепление чата:", actionError);
|
||||
setError("Не удалось изменить закрепление чата");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleNotifications = async () => {
|
||||
if (!chat || actionLoading) return;
|
||||
setActionLoading("notifications");
|
||||
try {
|
||||
await apiClient.toggleChatNotifications(chatId);
|
||||
await syncChat();
|
||||
} catch (actionError) {
|
||||
console.error("Не удалось изменить уведомления чата:", actionError);
|
||||
setError("Не удалось изменить уведомления чата");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
if (!chat) return;
|
||||
setEditName(chat.name || "");
|
||||
setEditDescription(chat.description || "");
|
||||
setEditAvatarFile(null);
|
||||
setEditAvatarPreview(null);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const closeEdit = () => {
|
||||
setEditOpen(false);
|
||||
setEditAvatarFile(null);
|
||||
setEditAvatarPreview(null);
|
||||
};
|
||||
|
||||
const handleAvatarChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
setEditAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => setEditAvatarPreview(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!chat || !editName.trim() || actionLoading) return;
|
||||
setActionLoading("edit");
|
||||
try {
|
||||
await apiClient.updateChat(chatId, {
|
||||
name: editName.trim(),
|
||||
description: editDescription.trim() || undefined,
|
||||
});
|
||||
if (editAvatarFile) await apiClient.uploadChatAvatar(chatId, editAvatarFile);
|
||||
await syncChat();
|
||||
closeEdit();
|
||||
} catch (actionError) {
|
||||
console.error("Не удалось обновить чат:", actionError);
|
||||
setError("Не удалось обновить чат");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isOwner = chat?.created_by === currentUserId;
|
||||
const isAdmin = chat?.memberships?.some(
|
||||
(membership) => membership.user === currentUserId && membership.role === "admin",
|
||||
) ?? false;
|
||||
const canEdit = Boolean(chat?.can_manage || isOwner || isAdmin);
|
||||
const avatar = chat ? getChatAvatar(chat, currentUserId) : null;
|
||||
const title = chat ? getChatTitle(chat, currentUserId) : "Настройки чата";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={onClose}
|
||||
title="Настройки чата"
|
||||
size="md"
|
||||
closeOnClickOutside
|
||||
>
|
||||
{loading ? (
|
||||
<div className="app-text-muted flex min-h-48 items-center justify-center text-sm">
|
||||
Загрузка настроек...
|
||||
</div>
|
||||
) : error && !chat ? (
|
||||
<div className="app-feedback-danger rounded-xl p-4 text-sm">{error}</div>
|
||||
) : chat ? (
|
||||
<div className="space-y-4">
|
||||
{error ? <div className="app-feedback-danger rounded-xl p-3 text-sm">{error}</div> : null}
|
||||
|
||||
<section className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="app-avatar-fallback flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-full text-base font-semibold">
|
||||
{avatar ? (
|
||||
<Image
|
||||
src={resolveMediaUrl(avatar)}
|
||||
alt={title}
|
||||
width={56}
|
||||
height={56}
|
||||
unoptimized
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : getChatInitials(chat, currentUserId)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-base font-semibold text-[var(--foreground)]">{title}</p>
|
||||
<p className="app-text-muted mt-0.5 text-xs">{getChatTypeLabel(chat)}</p>
|
||||
</div>
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openEdit}
|
||||
className="app-icon-button flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
title="Редактировать чат"
|
||||
aria-label="Редактировать чат"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{chat.description ? (
|
||||
<p className="app-text-muted mt-3 whitespace-pre-wrap text-sm">{chat.description}</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void togglePin()}
|
||||
disabled={actionLoading !== null}
|
||||
className="app-surface-muted flex w-full items-center gap-3 rounded-xl border border-[var(--border-subtle)] p-3 text-left transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
|
||||
>
|
||||
<Pin size={17} className={chat.is_pinned ? "app-accent-text fill-current" : "app-text-muted"} />
|
||||
<span className="text-sm font-medium">{chat.is_pinned ? "Открепить чат" : "Закрепить чат"}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleNotifications()}
|
||||
disabled={actionLoading !== null}
|
||||
className="app-surface-muted flex w-full items-center gap-3 rounded-xl border border-[var(--border-subtle)] p-3 text-left transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
|
||||
>
|
||||
{chat.notifications_enabled === false ? (
|
||||
<BellOff size={17} className="app-text-muted" />
|
||||
) : (
|
||||
<Bell size={17} className="app-text-muted" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{chat.notifications_enabled === false
|
||||
? "Включить уведомления"
|
||||
: "Отключить уведомления"}
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<EditChatModal
|
||||
chat={chat}
|
||||
open={editOpen}
|
||||
currentUserId={currentUserId}
|
||||
editName={editName}
|
||||
editDescription={editDescription}
|
||||
editAvatarPreview={editAvatarPreview}
|
||||
actionLoading={actionLoading}
|
||||
onClose={closeEdit}
|
||||
onNameChange={setEditName}
|
||||
onDescriptionChange={setEditDescription}
|
||||
onAvatarChange={handleAvatarChange}
|
||||
onSave={() => void saveEdit()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -2,18 +2,24 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
Building2,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Megaphone,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Pin,
|
||||
Radio,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
import ChatSettingsModal from "@/components/messages/ChatSettingsModal";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import {
|
||||
type ChatIdentity,
|
||||
getChatAvatar,
|
||||
@ -29,6 +35,7 @@ type MessagesChatListItemProps = {
|
||||
currentUserId?: number;
|
||||
identity: ChatIdentity;
|
||||
active?: boolean;
|
||||
onChatUpdated?: (chatId: number) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function getChatTypeIcon(chat: Chat) {
|
||||
@ -71,22 +78,72 @@ export default function MessagesChatListItem({
|
||||
currentUserId,
|
||||
identity,
|
||||
active = false,
|
||||
onChatUpdated,
|
||||
}: MessagesChatListItemProps) {
|
||||
const title = getChatTitle(chat, currentUserId, identity);
|
||||
const avatar = getChatAvatar(chat, currentUserId);
|
||||
const unread = chat.unread_count ?? 0;
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<"pin" | "notifications" | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const closeMenu = (event: MouseEvent) => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) setMenuOpen(false);
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", closeMenu);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", closeMenu);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
const togglePin = async () => {
|
||||
if (actionLoading) return;
|
||||
setActionLoading("pin");
|
||||
try {
|
||||
await apiClient.togglePinChat(chat.id);
|
||||
await onChatUpdated?.(chat.id);
|
||||
setMenuOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Не удалось изменить закрепление чата:", error);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleNotifications = async () => {
|
||||
if (actionLoading) return;
|
||||
setActionLoading("notifications");
|
||||
try {
|
||||
await apiClient.toggleChatNotifications(chat.id);
|
||||
await onChatUpdated?.(chat.id);
|
||||
setMenuOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Не удалось изменить уведомления чата:", error);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
<>
|
||||
<div
|
||||
data-testid="messages-chat-list-item"
|
||||
href={`/messages/${chat.id}`}
|
||||
className={`block w-full rounded-xl border p-3 text-left transition ${
|
||||
className={`relative w-full rounded-xl border text-left transition ${
|
||||
active
|
||||
? "app-selected"
|
||||
: "border-transparent hover:border-[var(--border-subtle)] hover:bg-[var(--surface-secondary)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Link href={`/messages/${chat.id}`} className="block w-full p-3 pr-10">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-visible">
|
||||
<div data-testid="messages-chat-avatar" className="app-avatar-fallback flex h-10 w-10 items-center justify-center overflow-hidden rounded-full text-xs font-semibold">
|
||||
{avatar ? (
|
||||
@ -127,6 +184,72 @@ export default function MessagesChatListItem({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Link>
|
||||
|
||||
<div ref={menuRef} className="absolute right-2 top-2 z-30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((current) => !current)}
|
||||
className="app-icon-button flex h-7 w-7 items-center justify-center rounded-lg"
|
||||
title="Действия с чатом"
|
||||
aria-label={`Действия с чатом ${title}`}
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`transition-transform ${menuOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{menuOpen ? (
|
||||
<div className="app-menu absolute right-0 top-full z-40 mt-1.5 w-56 rounded-lg p-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Pencil size={14} className="app-text-muted" />
|
||||
Настройки чата
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void togglePin()}
|
||||
disabled={actionLoading !== null}
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
|
||||
>
|
||||
<Pin size={14} className={chat.is_pinned ? "app-accent-text fill-current" : "app-text-muted"} />
|
||||
{chat.is_pinned ? "Открепить" : "Закрепить"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleNotifications()}
|
||||
disabled={actionLoading !== null}
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
|
||||
>
|
||||
{chat.notifications_enabled === false ? (
|
||||
<Bell size={14} className="app-text-muted" />
|
||||
) : (
|
||||
<BellOff size={14} className="app-text-muted" />
|
||||
)}
|
||||
{chat.notifications_enabled === false
|
||||
? "Включить уведомления"
|
||||
: "Отключить уведомления"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<ChatSettingsModal
|
||||
chatId={chat.id}
|
||||
currentUserId={currentUserId}
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onChatUpdated={onChatUpdated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ export default function MessagesFocusSidebar({
|
||||
onModeChange,
|
||||
onCreateChat,
|
||||
}: MessagesFocusSidebarProps) {
|
||||
const { user, identity, visibleChats, chatTypeCounts, loading } = useMessagesDirectory();
|
||||
const { user, identity, visibleChats, chatTypeCounts, loading, refreshChat } = useMessagesDirectory();
|
||||
|
||||
return (
|
||||
<aside data-testid="messages-focus-sidebar" className="app-surface hidden min-h-0 w-[24rem] shrink-0 flex-col overflow-hidden rounded-2xl lg:flex">
|
||||
@ -61,6 +61,7 @@ export default function MessagesFocusSidebar({
|
||||
currentUserId={user?.id}
|
||||
identity={identity}
|
||||
active={chat.id === activeChatId}
|
||||
onChatUpdated={refreshChat}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
Building2,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@ -23,12 +24,15 @@ import {
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import TaskBoardAvatar from "@/components/tasks/TaskBoardAvatar";
|
||||
import { RequestAvatar } from "@/components/requests/RequestAvatar";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { displayUserName } from "@/lib/shared";
|
||||
import type { TaskBoardGroup, TaskBoardSummary } from "@/types/api";
|
||||
|
||||
type BoardUnreadFilter = "all" | "unread" | "read";
|
||||
type BoardAccessFilter = "all" | "private" | "shared";
|
||||
type BoardGroupFilter = "all" | "ungrouped" | number;
|
||||
const NAVIGATOR_COLLAPSED_STORAGE_PREFIX = "tasks.board-navigator-collapsed";
|
||||
|
||||
type TaskBoardNavigatorProps = {
|
||||
boards: TaskBoardSummary[];
|
||||
@ -52,6 +56,7 @@ type TaskBoardNavigatorProps = {
|
||||
) => boolean | void | Promise<boolean | void>;
|
||||
wideMode: boolean;
|
||||
onWideModeChange: (enabled: boolean) => void;
|
||||
userId: number | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@ -69,6 +74,10 @@ function matchesBoard(board: TaskBoardSummary, query: string) {
|
||||
return haystack.includes(query);
|
||||
}
|
||||
|
||||
function getUserInitials(user: NonNullable<TaskBoardSummary["created_by"]>) {
|
||||
return `${user.first_name?.[0] || ""}${user.last_name?.[0] || ""}`.toUpperCase() || "С";
|
||||
}
|
||||
|
||||
function BoardRow({
|
||||
board,
|
||||
active,
|
||||
@ -82,6 +91,7 @@ function BoardRow({
|
||||
onTogglePin,
|
||||
onToggleNotifications,
|
||||
onMove,
|
||||
onViewParticipants,
|
||||
compact = false,
|
||||
}: {
|
||||
board: TaskBoardSummary;
|
||||
@ -96,6 +106,7 @@ function BoardRow({
|
||||
onTogglePin: () => void;
|
||||
onToggleNotifications: () => void;
|
||||
onMove: () => void;
|
||||
onViewParticipants: () => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (compact) {
|
||||
@ -191,6 +202,14 @@ function BoardRow({
|
||||
<Pencil size={14} className="app-text-muted" />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewParticipants}
|
||||
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)]"
|
||||
>
|
||||
<Users size={14} className="app-text-muted" />
|
||||
Участники
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePin}
|
||||
@ -273,6 +292,7 @@ export default function TaskBoardNavigator({
|
||||
onMoveBoard,
|
||||
wideMode,
|
||||
onWideModeChange,
|
||||
userId,
|
||||
className = "",
|
||||
}: TaskBoardNavigatorProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
@ -287,6 +307,7 @@ export default function TaskBoardNavigator({
|
||||
const [moveGroupId, setMoveGroupId] = useState<number | null>(null);
|
||||
const [moveGroupQuery, setMoveGroupQuery] = useState("");
|
||||
const [moveSubmitting, setMoveSubmitting] = useState(false);
|
||||
const [participantsBoard, setParticipantsBoard] = useState<TaskBoardSummary | null>(null);
|
||||
const menusRef = useRef<HTMLDivElement>(null);
|
||||
const defaultGroupInitializedRef = useRef(false);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
@ -339,6 +360,33 @@ export default function TaskBoardNavigator({
|
||||
defaultGroupInitializedRef.current = true;
|
||||
}, [groups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setNavigatorCollapsed(false);
|
||||
return;
|
||||
}
|
||||
setNavigatorCollapsed(
|
||||
window.localStorage.getItem(
|
||||
`${NAVIGATOR_COLLAPSED_STORAGE_PREFIX}.${userId}`,
|
||||
) === "1",
|
||||
);
|
||||
}, [userId]);
|
||||
|
||||
const toggleNavigatorCollapsed = () => {
|
||||
setNavigatorCollapsed((current) => {
|
||||
const next = !current;
|
||||
if (userId) {
|
||||
window.localStorage.setItem(
|
||||
`${NAVIGATOR_COLLAPSED_STORAGE_PREFIX}.${userId}`,
|
||||
next ? "1" : "0",
|
||||
);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setBoardMenuId(null);
|
||||
setGroupMenuId(null);
|
||||
};
|
||||
|
||||
const closeMoveModal = () => {
|
||||
if (moveSubmitting) return;
|
||||
setMoveBoardTarget(null);
|
||||
@ -380,7 +428,9 @@ export default function TaskBoardNavigator({
|
||||
<>
|
||||
<aside
|
||||
ref={menusRef}
|
||||
className={`app-surface flex min-h-0 flex-col overflow-hidden rounded-2xl transition-[width] duration-200 ${
|
||||
className={`flex min-h-0 flex-col overflow-hidden transition-[width] duration-200 ${
|
||||
compactNavigator ? "bg-transparent" : "app-surface rounded-2xl"
|
||||
} ${
|
||||
wideMode ? (compactNavigator ? "lg:w-[4.75rem]" : "lg:w-[24rem]") : ""
|
||||
} ${className}`}
|
||||
>
|
||||
@ -392,11 +442,7 @@ export default function TaskBoardNavigator({
|
||||
{wideMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNavigatorCollapsed((current) => !current);
|
||||
setBoardMenuId(null);
|
||||
setGroupMenuId(null);
|
||||
}}
|
||||
onClick={toggleNavigatorCollapsed}
|
||||
className={`app-action-ghost flex items-center justify-center rounded-lg ${
|
||||
compactNavigator ? "h-9 w-9" : "min-w-0 gap-1 px-1 py-1"
|
||||
}`}
|
||||
@ -677,6 +723,10 @@ export default function TaskBoardNavigator({
|
||||
setMoveGroupQuery("");
|
||||
setBoardMenuId(null);
|
||||
}}
|
||||
onViewParticipants={() => {
|
||||
setParticipantsBoard(board);
|
||||
setBoardMenuId(null);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -695,6 +745,147 @@ export default function TaskBoardNavigator({
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Modal
|
||||
isOpen={Boolean(participantsBoard)}
|
||||
onClose={() => setParticipantsBoard(null)}
|
||||
title="Участники доски"
|
||||
size="sm"
|
||||
closeOnClickOutside
|
||||
>
|
||||
{participantsBoard ? (
|
||||
<div className="space-y-4 pb-5">
|
||||
<div className="app-surface-muted flex items-center gap-3 rounded-xl p-3">
|
||||
<TaskBoardAvatar
|
||||
name={participantsBoard.name}
|
||||
src={participantsBoard.avatar}
|
||||
size="md"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-[var(--foreground)]">
|
||||
{participantsBoard.name}
|
||||
</p>
|
||||
<p className="app-text-muted mt-0.5 text-xs">
|
||||
{participantsBoard.access_scope === "private"
|
||||
? "Персональная доска"
|
||||
: participantsBoard.access_scope === "restricted"
|
||||
? "Выборочный доступ"
|
||||
: "Доступ для всей компании"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{participantsBoard.access_scope === "all" ? (
|
||||
<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 text-[var(--foreground)]">Вся компания</p>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Доска доступна всем сотрудникам компании.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{participantsBoard.access_scope === "private" ? (
|
||||
<div className="app-selected flex items-start gap-3 rounded-xl border p-3">
|
||||
<LockKeyhole size={18} className="app-accent-text mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Персональная доска</p>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Доступ есть только у владельца доски.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{participantsBoard.created_by ? (
|
||||
<section>
|
||||
<p className="app-card-caption mb-2">Владелец</p>
|
||||
<div className="app-surface-muted flex items-center gap-3 rounded-xl p-3">
|
||||
<RequestAvatar
|
||||
alt={displayUserName(participantsBoard.created_by)}
|
||||
fallback={getUserInitials(participantsBoard.created_by)}
|
||||
src={participantsBoard.created_by.avatar}
|
||||
size="lg"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{displayUserName(participantsBoard.created_by)}
|
||||
</p>
|
||||
<p className="app-text-muted truncate text-xs">
|
||||
{participantsBoard.created_by.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{participantsBoard.access_scope === "restricted" ? (
|
||||
<>
|
||||
<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-xs">
|
||||
{participantsBoard.member_details?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
{(participantsBoard.member_details || []).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{(participantsBoard.member_details || []).map((member) => (
|
||||
<div key={member.id} className="app-surface-muted flex items-center gap-3 rounded-xl p-3">
|
||||
<RequestAvatar
|
||||
alt={displayUserName(member)}
|
||||
fallback={getUserInitials(member)}
|
||||
src={member.avatar}
|
||||
size="lg"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{displayUserName(member)}
|
||||
</p>
|
||||
<p className="app-text-muted truncate text-xs">{member.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-text-muted text-xs">Отдельные сотрудники не выбраны.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<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-xs">
|
||||
{participantsBoard.department_details?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
{(participantsBoard.department_details || []).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{(participantsBoard.department_details || []).map((department) => (
|
||||
<div key={department.id} className="app-surface-muted flex items-center gap-3 rounded-xl p-3">
|
||||
<span className="app-avatar-fallback flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
|
||||
<Building2 size={15} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{department.name}
|
||||
</p>
|
||||
<p className="app-text-muted text-xs">Участники и назначенные роли отдела</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-text-muted text-xs">Отделы не выбраны.</p>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={Boolean(moveBoardTarget)}
|
||||
onClose={closeMoveModal}
|
||||
|
||||
@ -163,5 +163,8 @@ export function useMessagesChatList(userId?: number | null) {
|
||||
return retainChatList(userId);
|
||||
}, [userId]);
|
||||
|
||||
return state;
|
||||
return {
|
||||
...state,
|
||||
refreshChat,
|
||||
};
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ function chatTimestamp(chat: Chat): number {
|
||||
export function useMessagesDirectory() {
|
||||
const { user } = useUser();
|
||||
const { search, chatTypeFilter, filterUnread, filterPinned } = useMessagesListFilters(user?.id);
|
||||
const { chats: allChats, loading, error } = useMessagesChatList(user?.id);
|
||||
const { chats: allChats, loading, error, refreshChat } = useMessagesChatList(user?.id);
|
||||
|
||||
const identity = useMemo<ChatIdentity>(() => ({
|
||||
first_name: user?.first_name,
|
||||
@ -100,5 +100,6 @@ export function useMessagesDirectory() {
|
||||
chatTypeCounts,
|
||||
loading,
|
||||
error,
|
||||
refreshChat,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type {
|
||||
TaskBoard,
|
||||
TaskBoardViewState,
|
||||
TaskBoardGroup,
|
||||
TaskCard,
|
||||
TaskColumn,
|
||||
TaskColumnRow,
|
||||
TaskActivity,
|
||||
TaskAttachment,
|
||||
TaskChecklistItem,
|
||||
@ -79,6 +81,18 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
id: number,
|
||||
): Promise<{ ok: boolean; notifications_enabled: boolean }> =>
|
||||
request(`/api/v1/tasks/boards/${id}/notifications/`, { method: "POST" }),
|
||||
updateTaskBoardViewState: (
|
||||
id: number,
|
||||
data: {
|
||||
scope: "column" | "base" | "row";
|
||||
object_id: number;
|
||||
collapsed: boolean;
|
||||
},
|
||||
): Promise<TaskBoardViewState> =>
|
||||
request(`/api/v1/tasks/boards/${id}/view-state/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getTaskBoardGroups: (): Promise<
|
||||
TaskBoardGroup[] | { results: TaskBoardGroup[] }
|
||||
> =>
|
||||
@ -137,6 +151,21 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ board, parent, column_ids: columnIds }),
|
||||
}),
|
||||
createTaskColumnRow: (data: Record<string, any>): Promise<TaskColumnRow> =>
|
||||
request("/api/v1/tasks/column-rows/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
updateTaskColumnRow: (
|
||||
id: number,
|
||||
data: Record<string, any>,
|
||||
): Promise<TaskColumnRow> =>
|
||||
request(`/api/v1/tasks/column-rows/${id}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteTaskColumnRow: (id: number): Promise<void> =>
|
||||
request(`/api/v1/tasks/column-rows/${id}/`, { method: "DELETE" }),
|
||||
createTaskLabel: (data: Record<string, any>): Promise<TaskLabel> =>
|
||||
request("/api/v1/tasks/labels/", {
|
||||
method: "POST",
|
||||
@ -154,7 +183,7 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
}),
|
||||
moveTask: (
|
||||
id: number,
|
||||
data: { column: number; position?: number },
|
||||
data: { column: number; row?: number | null; position?: number },
|
||||
): Promise<TaskCard> =>
|
||||
request(`/api/v1/tasks/${id}/move/`, {
|
||||
method: "POST",
|
||||
|
||||
@ -188,6 +188,17 @@ export interface TaskColumn {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TaskColumnRow {
|
||||
id: number;
|
||||
column: number;
|
||||
name: string;
|
||||
position: number;
|
||||
color?: string;
|
||||
tasks_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TaskLabel {
|
||||
id: number;
|
||||
board: number;
|
||||
@ -202,6 +213,9 @@ export interface TaskCard {
|
||||
column: number;
|
||||
column_name?: string;
|
||||
column_color?: string;
|
||||
row: number | null;
|
||||
row_name?: string | null;
|
||||
row_color?: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
created_by?: User;
|
||||
@ -232,6 +246,12 @@ export interface TaskCard {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TaskBoardViewState {
|
||||
hidden_column_ids: number[];
|
||||
collapsed_base_column_ids: number[];
|
||||
collapsed_row_ids: number[];
|
||||
}
|
||||
|
||||
export interface TaskBoard {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -245,6 +265,7 @@ export interface TaskBoard {
|
||||
department_details?: Department[];
|
||||
is_archived: boolean;
|
||||
columns: TaskColumn[];
|
||||
column_rows: TaskColumnRow[];
|
||||
labels: TaskLabel[];
|
||||
tasks: TaskCard[];
|
||||
tasks_count?: number;
|
||||
@ -252,6 +273,7 @@ export interface TaskBoard {
|
||||
group_id?: number | null;
|
||||
is_pinned?: boolean;
|
||||
notifications_enabled?: boolean;
|
||||
view_state: TaskBoardViewState;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user