feat: improve task boards and chat controls

This commit is contained in:
lizerk 2026-08-06 00:41:43 +03:00
parent 0e012c3069
commit a889086661
19 changed files with 2167 additions and 117 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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="Дорожка"),
),
]

View File

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

View File

@ -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="Состояние представления",
),
),
]

View File

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

View File

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

View File

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

View 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()}
/>
</>
);
}

View File

@ -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}
/>
</>
);
}

View File

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

View File

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

View File

@ -163,5 +163,8 @@ export function useMessagesChatList(userId?: number | null) {
return retainChatList(userId);
}, [userId]);
return state;
return {
...state,
refreshChat,
};
}

View File

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

View File

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

View File

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