diff --git a/backend/api/v1/tasks/serializers/tasks.py b/backend/api/v1/tasks/serializers/tasks.py index d6363c6a..b6ef92df 100644 --- a/backend/api/v1/tasks/serializers/tasks.py +++ b/backend/api/v1/tasks/serializers/tasks.py @@ -438,6 +438,7 @@ class TaskSerializer(serializers.ModelSerializer): def create(self, validated_data): labels = validated_data.pop("labels", []) + skills = validated_data.pop("skills", []) participants = list(validated_data.pop("participants", [])) if "position" not in validated_data: column = validated_data["column"] @@ -457,11 +458,14 @@ class TaskSerializer(serializers.ModelSerializer): task.participants.set(participant_ids) if labels: task.labels.set(labels) + if skills: + task.skills.set(skills) return task def update(self, instance, validated_data): previous_assignee_id = instance.assignee_id labels = validated_data.pop("labels", None) + skills = validated_data.pop("skills", None) participants = validated_data.pop("participants", None) task = super().update(instance, validated_data) if participants is not None: @@ -483,4 +487,6 @@ class TaskSerializer(serializers.ModelSerializer): ) if labels is not None: task.labels.set(labels) + if skills is not None: + task.skills.set(skills) return task diff --git a/backend/api/v1/tasks/viewsets/tasks.py b/backend/api/v1/tasks/viewsets/tasks.py index de4377c5..35b772fe 100644 --- a/backend/api/v1/tasks/viewsets/tasks.py +++ b/backend/api/v1/tasks/viewsets/tasks.py @@ -81,6 +81,7 @@ class TaskViewSet( "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)), + "skill_ids": sorted(instance.skills.values_list("id", flat=True)), "participant_ids": sorted( instance.participants.values_list("id", flat=True) ), @@ -94,6 +95,7 @@ class TaskViewSet( "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)), + "skill_ids": sorted(task.skills.values_list("id", flat=True)), "participant_ids": sorted(task.participants.values_list("id", flat=True)), } changed_fields = [ diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 815964ab..f1973ac8 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -19,7 +19,7 @@ from rest_framework.test import APIClient from attendance.models import AttendanceAnalysisRun, AttendanceRecord from communications.models import Chat, ChatMembership, Message from documents.models import Document -from employees.models import Department, EmployeeDepartment, RoleAssignment +from employees.models import Department, EmployeeDepartment, RoleAssignment, Skill from feed.models import Post from guests.models import Guest, GuestVisit from procurement.constants import ProcurementStatus, UrgencyLevel @@ -1975,6 +1975,45 @@ def test_create_task_and_move_to_done_column(api_client, user): assert task.completed_at is not None +def test_create_and_update_task_skills(api_client, user): + api_client.force_authenticate(user=user) + board_response = api_client.get(reverse("api:v1:tasks:task-board-default")) + board_id = board_response.data["id"] + column_id = board_response.data["columns"][0]["id"] + python = Skill.objects.create(name="Python") + analytics = Skill.objects.create(name="Аналитика") + + create_response = api_client.post( + reverse("api:v1:tasks:task-list"), + { + "board": board_id, + "column": column_id, + "title": "Задача с навыком", + "skill_ids": [python.id], + }, + format="json", + ) + + assert create_response.status_code == status.HTTP_201_CREATED + task_id = create_response.data["id"] + assert [skill["id"] for skill in create_response.data["skills"]] == [python.id] + assert list(Task.objects.get(id=task_id).skills.values_list("id", flat=True)) == [ + python.id + ] + + update_response = api_client.patch( + reverse("api:v1:tasks:task-detail", kwargs={"pk": task_id}), + {"skill_ids": [analytics.id]}, + format="json", + ) + + assert update_response.status_code == status.HTTP_200_OK + assert [skill["id"] for skill in update_response.data["skills"]] == [analytics.id] + assert list(Task.objects.get(id=task_id).skills.values_list("id", flat=True)) == [ + analytics.id + ] + + def test_task_participants_can_change_assignee_in_same_update(api_client, user): previous_assignee = make_user( "task-previous-assignee@example.com", diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 7f09762f..20651e57 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -193,7 +193,7 @@ type ColumnRowFormState = { type LinkedItemMode = "document" | "calendar_event" | "external_link"; type LinkedItemTarget = "view" | "form"; -type TaskAddPanel = "due_date" | "labels" | "priority" | null; +type TaskAddPanel = "due_date" | "labels" | "priority" | "skills" | null; type TaskCoverLinkedOption = { id: number; @@ -3801,6 +3801,7 @@ function TasksPageContent() { const [taskAddMenuPlacement, setTaskAddMenuPlacement] = useState<"top" | "bottom">("top"); const [taskAddPanel, setTaskAddPanel] = useState(null); const [viewTaskDueDateDraft, setViewTaskDueDateDraft] = useState(""); + const [viewTaskSkillSearch, setViewTaskSkillSearch] = useState(""); const [viewColumnMenuOpen, setViewColumnMenuOpen] = useState(false); const [cardMenuTaskId, setCardMenuTaskId] = useState(null); const [columnMenuId, setColumnMenuId] = useState(null); @@ -4750,6 +4751,16 @@ function TasksPageContent() { () => (board?.tasks || []).find((task) => task.id === viewTaskId) || null, [board?.tasks, viewTaskId], ); + const filteredViewTaskSkills = useMemo(() => { + const query = viewTaskSkillSearch.trim().toLowerCase(); + const selectedIds = new Set((viewTask?.skills || []).map((skill) => skill.id)); + return availableSkills + .filter((skill) => !query || skill.name.toLowerCase().includes(query)) + .sort((left, right) => { + const selectedDifference = Number(selectedIds.has(right.id)) - Number(selectedIds.has(left.id)); + return selectedDifference || left.name.localeCompare(right.name, "ru"); + }); + }, [availableSkills, viewTask?.skills, viewTaskSkillSearch]); const viewTaskColumn = useMemo( () => activeColumns.find((column) => column.id === viewTask?.column) || null, @@ -5118,6 +5129,7 @@ function TasksPageContent() { setTaskAddMenuOpen(false); setTaskAddPanel(null); setTaskLabelSearch(""); + setViewTaskSkillSearch(""); setViewTaskDueDateDraft(task.due_date || ""); setViewColumnMenuOpen(false); setLinkedObjectsOpen(false); @@ -5180,6 +5192,7 @@ function TasksPageContent() { setTaskAddMenuOpen(false); setTaskAddPanel(null); setTaskLabelSearch(""); + setViewTaskSkillSearch(""); setViewTaskDueDateDraft(""); setViewColumnMenuOpen(false); setLinkedMessages([]); @@ -6338,6 +6351,27 @@ function TasksPageContent() { } }, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]); + const toggleViewedTaskSkill = useCallback(async (skillId: number) => { + if (!board || !viewTask || saving) return; + + const currentSkillIds = viewTask.skills?.map((skill) => skill.id) || []; + const nextSkillIds = currentSkillIds.includes(skillId) + ? currentSkillIds.filter((id) => id !== skillId) + : [...currentSkillIds, skillId]; + + setSaving(true); + setError(null); + try { + await apiClient.updateTask(viewTask.id, { skill_ids: nextSkillIds }); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (skillError) { + setError(getTaskError(skillError, "Не удалось изменить навыки задачи")); + } finally { + setSaving(false); + } + }, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]); + const createViewedTaskLabel = useCallback(async () => { if (!board || !viewTask || !labelName.trim() || saving) return; @@ -7634,6 +7668,18 @@ function TasksPageContent() { Метки + + + + {filteredViewTaskSkills.length ? ( +
+ {filteredViewTaskSkills.map((skill) => { + const selected = Boolean(viewTask.skills?.some((item) => item.id === skill.id)); + return ( + + ); + })} +
+ ) : ( +
+

+ {availableSkills.length ? "Навыки не найдены" : "Навыки пока не созданы"} +

+
+ )} +

+ После завершения задачи исполнители и участники с выбранными навыками получат опыт. +

+ + ) : null} + {viewTask.description ? (