feat: manage skills from task quick menu

This commit is contained in:
lizerk 2026-08-25 14:43:34 +03:00
parent 572c433bbd
commit efc93b03e9
4 changed files with 161 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@ -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<TaskAddPanel>(null);
const [viewTaskDueDateDraft, setViewTaskDueDateDraft] = useState("");
const [viewTaskSkillSearch, setViewTaskSkillSearch] = useState("");
const [viewColumnMenuOpen, setViewColumnMenuOpen] = useState(false);
const [cardMenuTaskId, setCardMenuTaskId] = useState<number | null>(null);
const [columnMenuId, setColumnMenuId] = useState<number | null>(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() {
<Tag size={15} className="app-text-muted" />
Метки
</button>
<button
type="button"
onClick={() => {
setViewTaskSkillSearch("");
setTaskAddPanel("skills");
setTaskAddMenuOpen(false);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
>
<Sparkles size={15} className="app-text-muted" />
Навыки
</button>
<button
type="button"
onClick={() => void openTaskCoverPicker()}
@ -9492,6 +9538,72 @@ function TasksPageContent() {
</div>
) : null}
{taskAddPanel === "skills" ? (
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-2 text-sm font-medium text-[var(--foreground)]">
<Sparkles size={15} className="app-text-muted" />
Навыки задачи
{(viewTask.skills?.length || 0) > 0 ? (
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">
{viewTask.skills?.length}
</span>
) : null}
</span>
<button
type="button"
onClick={() => setTaskAddPanel(null)}
className="app-icon-button flex h-7 w-7 items-center justify-center rounded-lg"
title="Закрыть"
aria-label="Закрыть выбор навыков"
>
<X size={14} />
</button>
</div>
<label className="relative mb-3 block">
<Search size={14} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={viewTaskSkillSearch}
onChange={(event) => setViewTaskSkillSearch(event.target.value)}
className="app-input w-full rounded-xl py-2 pl-8 pr-3 text-sm"
placeholder="Поиск навыка"
autoFocus
/>
</label>
{filteredViewTaskSkills.length ? (
<div className="tasks-column-scroll flex max-h-52 flex-wrap content-start gap-2 overflow-y-auto pr-1">
{filteredViewTaskSkills.map((skill) => {
const selected = Boolean(viewTask.skills?.some((item) => item.id === skill.id));
return (
<button
key={skill.id}
type="button"
onClick={() => void toggleViewedTaskSkill(skill.id)}
disabled={saving}
className={`app-pill app-badge-interactive inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-xs font-medium disabled:opacity-50 ${
selected ? "app-selected" : ""
}`}
aria-pressed={selected}
>
{selected ? <Check size={13} /> : null}
<span>{skill.name}</span>
</button>
);
})}
</div>
) : (
<div className="app-surface rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-5 text-center">
<p className="app-text-muted text-xs">
{availableSkills.length ? "Навыки не найдены" : "Навыки пока не созданы"}
</p>
</div>
)}
<p className="app-text-muted mt-2 text-[11px] leading-relaxed">
После завершения задачи исполнители и участники с выбранными навыками получат опыт.
</p>
</div>
) : null}
{viewTask.description ? (
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<p className="app-text-wrap whitespace-pre-wrap text-sm text-[var(--foreground)]">