From 45866bb69c4d4280356b92d1dbee19a4042b0f6d Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 6 Aug 2026 11:38:00 +0300 Subject: [PATCH] feat(tasks): expand task resources and global labels --- backend/api/v1/tasks/serializers.py | 35 +- backend/api/v1/tasks/views.py | 31 +- backend/tasks/admin.py | 13 +- .../migrations/0030_global_task_labels.py | 58 ++ backend/tasks/models.py | 11 +- backend/tests/api/v1/tasks/test_tasks_api.py | 66 ++ frontend/src/app/tasks/page.tsx | 732 ++++++++++++++++-- frontend/src/lib/api/tasks.ts | 2 + frontend/src/types/api.ts | 3 +- 9 files changed, 844 insertions(+), 107 deletions(-) create mode 100644 backend/tasks/migrations/0030_global_task_labels.py diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index c1e823df..2504bc08 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -94,11 +94,18 @@ def validate_board_access(serializer, board): class TaskLabelSerializer(serializers.ModelSerializer): class Meta: model = TaskLabel - fields = ["id", "board", "name", "color"] + fields = ["id", "name", "color"] read_only_fields = ["id"] - def validate_board(self, board): - return validate_board_access(self, board) + def validate_name(self, value): + name = value.strip() + duplicate = TaskLabel.objects.only("id", "name") + if self.instance: + duplicate = duplicate.exclude(pk=self.instance.pk) + normalized_name = name.casefold() + if any(label.name.casefold() == normalized_name for label in duplicate): + raise serializers.ValidationError("Метка с таким названием уже существует.") + return name class TaskColumnSerializer(serializers.ModelSerializer): @@ -532,7 +539,6 @@ class TaskSerializer(serializers.ModelSerializer): if "row" in attrs else getattr(self.instance, "row", None) ) - labels = attrs.get("labels") participants = attrs.get("participants") if board and not user_can_access_task_board(user, board): @@ -557,15 +563,6 @@ class TaskSerializer(serializers.ModelSerializer): {"row": "Дорожка должна принадлежать основной колонке задачи."} ) - if labels is not None and board: - invalid_labels = [ - label.id for label in labels if label.board_id != board.id - ] - if invalid_labels: - raise serializers.ValidationError( - {"label_ids": "Метки должны принадлежать выбранной доске."} - ) - if participants is not None and board: inaccessible_participants = [ participant.id @@ -658,8 +655,8 @@ class TaskBoardSerializer(serializers.ModelSerializer): ) 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) + labels = serializers.SerializerMethodField() tasks_count = serializers.IntegerField(read_only=True, default=0) is_default_for_current_user = serializers.SerializerMethodField() group_id = serializers.IntegerField( @@ -696,8 +693,8 @@ class TaskBoardSerializer(serializers.ModelSerializer): "is_archived", "columns", "column_rows", - "labels", "tasks", + "labels", "tasks_count", "is_default_for_current_user", "group_id", @@ -708,6 +705,14 @@ class TaskBoardSerializer(serializers.ModelSerializer): "updated_at", ] + @extend_schema_field(TaskLabelSerializer(many=True)) + def get_labels(self, obj): + labels = getattr(self, "_global_labels_cache", None) + if labels is None: + labels = list(TaskLabel.objects.order_by("name", "id")) + self._global_labels_cache = labels + return TaskLabelSerializer(labels, many=True).data + def validate(self, attrs): attrs = super().validate(attrs) access_scope = attrs.get("access_scope") diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 687eb3fc..e44bcc12 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -255,7 +255,6 @@ class TaskBoardViewSet(viewsets.ModelViewSet): ), ).order_by("parent_id", "position", "id"), ), - "labels", "tasks__created_by", "tasks__assignee", "tasks__participants", @@ -877,33 +876,27 @@ class TaskLabelViewSet(viewsets.ModelViewSet): serializer_class = TaskLabelSerializer def get_queryset(self): - queryset = ( - TaskLabel.objects.filter(board__is_archived=False) - .filter( - board__in=TaskBoard.objects.filter( - task_board_access_q(self.request.user) - ) - ) - .select_related("board") - ) - board = self.request.query_params.get("board") - if board: - queryset = queryset.filter(board_id=board) - return queryset.order_by("name", "id") + return TaskLabel.objects.order_by("name", "id") def perform_create(self, serializer): - label = serializer.save() - send_task_board_update(label.board, "created", "label", label.id) + serializer.save() def perform_update(self, serializer): + board_ids = list( + serializer.instance.tasks.values_list("board_id", flat=True).distinct() + ) label = serializer.save() - send_task_board_update(label.board, "updated", "label", label.id) + for board in TaskBoard.objects.filter(id__in=board_ids): + send_task_board_update(board, "updated", "label", label.id) def perform_destroy(self, instance): - board = instance.board + board_ids = list( + instance.tasks.values_list("board_id", flat=True).distinct() + ) label_id = instance.id instance.delete() - send_task_board_update(board, "deleted", "label", label_id) + for board in TaskBoard.objects.filter(id__in=board_ids): + send_task_board_update(board, "deleted", "label", label_id) class TaskViewSet(viewsets.ModelViewSet): diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 50d63e84..021609f6 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -23,12 +23,6 @@ class TaskColumnInline(admin.TabularInline): fields = ["parent", "name", "position", "color", "is_done", "is_archived"] -class TaskLabelInline(admin.TabularInline): - model = TaskLabel - extra = 0 - fields = ["name", "color"] - - @admin.register(TaskBoard) class TaskBoardAdmin(admin.ModelAdmin): list_display = [ @@ -41,7 +35,7 @@ class TaskBoardAdmin(admin.ModelAdmin): list_filter = ["access_scope", "is_archived", "created_at"] search_fields = ["name", "description"] filter_horizontal = ["members", "departments"] - inlines = [TaskColumnInline, TaskLabelInline] + inlines = [TaskColumnInline] @admin.register(TaskBoardGroup) @@ -90,9 +84,8 @@ class TaskColumnRowAdmin(admin.ModelAdmin): @admin.register(TaskLabel) class TaskLabelAdmin(admin.ModelAdmin): - list_display = ["name", "board", "color"] - list_filter = ["board"] - search_fields = ["name", "board__name"] + list_display = ["name", "color"] + search_fields = ["name"] @admin.register(Task) diff --git a/backend/tasks/migrations/0030_global_task_labels.py b/backend/tasks/migrations/0030_global_task_labels.py new file mode 100644 index 00000000..ca9d29a8 --- /dev/null +++ b/backend/tasks/migrations/0030_global_task_labels.py @@ -0,0 +1,58 @@ +from django.db import migrations, models +from django.db.models.functions import Lower + + +def merge_duplicate_labels(apps, schema_editor): + TaskLabel = apps.get_model("tasks", "TaskLabel") + Task = apps.get_model("tasks", "Task") + through = Task._meta.get_field("labels").remote_field.through + keepers = {} + + for label in TaskLabel.objects.order_by("id"): + normalized_name = label.name.strip() + if normalized_name != label.name: + label.name = normalized_name + label.save(update_fields=["name"]) + key = normalized_name.casefold() + keeper = keepers.get(key) + if keeper is None: + keepers[key] = label + continue + + task_ids = through.objects.filter(tasklabel_id=label.id).values_list( + "task_id", + flat=True, + ) + through.objects.bulk_create( + [ + through(task_id=task_id, tasklabel_id=keeper.id) + for task_id in task_ids + ], + ignore_conflicts=True, + ) + label.delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0029_taskattachment_is_cover"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="tasklabel", + name="uniq_task_label_board_name", + ), + migrations.RunPython(merge_duplicate_labels, migrations.RunPython.noop), + migrations.RemoveField( + model_name="tasklabel", + name="board", + ), + migrations.AddConstraint( + model_name="tasklabel", + constraint=models.UniqueConstraint( + Lower("name"), + name="uniq_task_label_name_ci", + ), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index af9585a4..f2dd81f3 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -2,6 +2,7 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models +from django.db.models.functions import Lower from django.utils import timezone from employees.models import Department @@ -258,12 +259,6 @@ class TaskColumnRow(models.Model): class TaskLabel(models.Model): - board = models.ForeignKey( - TaskBoard, - on_delete=models.CASCADE, - related_name="labels", - verbose_name="Доска", - ) name = models.CharField("Название", max_length=80) color = models.CharField("Цвет", max_length=32, default="#38bdf8") @@ -273,8 +268,8 @@ class TaskLabel(models.Model): ordering = ["name", "id"] constraints = [ models.UniqueConstraint( - fields=["board", "name"], - name="uniq_task_label_board_name", + Lower("name"), + name="uniq_task_label_name_ci", ) ] diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 43eb9a7b..9255660a 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -33,6 +33,7 @@ from tasks.models import ( TaskColumn, TaskColumnRow, TaskExternalLink, + TaskLabel, TaskUserSettings, ) from tasks.realtime import get_task_board_recipient_ids @@ -491,6 +492,71 @@ def test_board_view_state_is_persisted_per_user(api_client, user): } +def test_task_labels_are_global_and_can_be_used_on_different_boards( + api_client, + user, +): + first_board = TaskBoard.objects.create(name="Первая доска", created_by=user) + second_board = TaskBoard.objects.create(name="Вторая доска", created_by=user) + first_column = TaskColumn.objects.create( + board=first_board, + name="Новые", + position=1000, + ) + second_column = TaskColumn.objects.create( + board=second_board, + name="Новые", + position=1000, + ) + labels_url = reverse("api:v1:tasks:task-label-list") + api_client.force_authenticate(user=user) + + create_response = api_client.post( + labels_url, + {"name": "Срочно", "color": "#ef4444"}, + format="json", + ) + assert create_response.status_code == status.HTTP_201_CREATED + assert "board" not in create_response.data + label_id = create_response.data["id"] + + duplicate_response = api_client.post( + labels_url, + {"name": "срочно", "color": "#38bdf8"}, + format="json", + ) + assert duplicate_response.status_code == status.HTTP_400_BAD_REQUEST + assert TaskLabel.objects.count() == 1 + + for board, column, title in [ + (first_board, first_column, "Первая задача"), + (second_board, second_column, "Вторая задача"), + ]: + task_response = api_client.post( + reverse("api:v1:tasks:task-list"), + { + "board": board.id, + "column": column.id, + "title": title, + "label_ids": [label_id], + }, + format="json", + ) + assert task_response.status_code == status.HTTP_201_CREATED + assert task_response.data["labels"][0]["id"] == label_id + + board_response = api_client.get( + reverse( + "api:v1:tasks:task-board-detail", + kwargs={"pk": first_board.id}, + ) + ) + assert board_response.status_code == status.HTTP_200_OK + assert board_response.data["labels"] == [ + {"id": label_id, "name": "Срочно", "color": "#ef4444"} + ] + + 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) diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 84aa09e4..11b1d183 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -111,6 +111,7 @@ import type { TaskLinkedPost, TaskLinkedProcurementRequest, TaskLinkedRequest, + TaskLabel, TaskPriority, User, } from "@/types/api"; @@ -163,6 +164,7 @@ type ColumnRowFormState = { type LinkedItemMode = "document" | "calendar_event" | "external_link"; type LinkedItemTarget = "view" | "form"; +type TaskAddPanel = "due_date" | "labels" | null; type TaskFormExternalLinkDraft = { key: string; @@ -177,6 +179,11 @@ type TaskFormChecklistItemDraft = { is_completed: boolean; }; +type QuickTaskTarget = { + columnId: number; + rowId: number | null; +}; + type TaskBoardSocketEvent = { type: string; data?: { @@ -724,6 +731,71 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) { ); } +function QuickTaskComposer({ + title, + saving, + columnName, + onTitleChange, + onSubmit, + onCancel, +}: { + title: string; + saving: boolean; + columnName: string; + onTitleChange: (value: string) => void; + onSubmit: () => void; + onCancel: () => void; +}) { + return ( +
{ + event.preventDefault(); + onSubmit(); + }} + onPointerDown={(event) => event.stopPropagation()} + className="app-surface-elevated rounded-xl border border-[var(--accent-primary)] p-2 shadow-sm" + > + onTitleChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + } + }} + disabled={saving} + maxLength={255} + className="app-input w-full rounded-lg px-3 py-2 text-sm" + placeholder="Название задачи" + aria-label={`Название новой задачи в колонке ${columnName}`} + /> +
+ + +
+
+ ); +} + function BoardColumn({ column, tasks, @@ -762,6 +834,7 @@ function BoardColumn({ fillAvailableHeight = false, hideOnMobile = false, nested = false, + quickTaskComposer, }: { column: TaskColumn; tasks: TaskCard[]; @@ -800,6 +873,7 @@ function BoardColumn({ fillAvailableHeight?: boolean; hideOnMobile?: boolean; nested?: boolean; + quickTaskComposer?: ReactNode; }) { const { attributes, @@ -972,6 +1046,7 @@ function BoardColumn({ {showBody && contentVisible ?
+ {quickTaskComposer} {tasks.length > 0 ? ( tasks.map((task) => ( onToggleTaskMenu(task.id)} /> )) - ) : ( + ) : quickTaskComposer ? null : (

Нет задач

@@ -2637,6 +2712,141 @@ function TaskActivityCard({ activity }: { activity: TaskActivity }) { ); } +function TaskLabelPicker({ + labels, + selectedIds, + search, + onSearchChange, + onToggle, + disabled = false, +}: { + labels: TaskLabel[]; + selectedIds: number[]; + search: string; + onSearchChange: (value: string) => void; + onToggle: (labelId: number) => void; + disabled?: boolean; +}) { + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); + const sortedLabels = useMemo(() => [...labels].sort((left, right) => ( + left.name.localeCompare(right.name, "ru") + )), [labels]); + const selectedLabels = useMemo(() => sortedLabels.filter((label) => ( + selectedIdSet.has(label.id) + )), [selectedIdSet, sortedLabels]); + const normalizedSearch = search.trim().toLocaleLowerCase("ru-RU"); + const filteredLabels = useMemo(() => sortedLabels.filter((label) => ( + !normalizedSearch + || label.name.toLocaleLowerCase("ru-RU").includes(normalizedSearch) + )), [normalizedSearch, sortedLabels]); + + return ( +
+
+ + onSearchChange(event.target.value)} + className="app-input h-10 w-full rounded-lg py-2 pl-9 pr-16 text-sm" + placeholder="Поиск по общим меткам" + aria-label="Поиск по общим меткам" + /> + {search ? ( + + ) : ( + + {labels.length} + + )} +
+ + {selectedLabels.length > 0 ? ( +
+
+ + Выбрано + + + {selectedLabels.length} + +
+
+ {selectedLabels.map((label) => ( + + ))} +
+
+ ) : null} + +
+
+ Все метки + + {filteredLabels.length} из {labels.length} + +
+
+ {filteredLabels.length > 0 ? filteredLabels.map((label) => { + const selected = selectedIdSet.has(label.id); + return ( + + ); + }) : ( +

+ {labels.length > 0 ? "Метки не найдены" : "Общих меток пока нет"} +

+ )} +
+
+
+ ); +} + export default function TasksPage() { return (

Загрузка задач...

}> @@ -2662,6 +2872,7 @@ function TasksPageContent() { const [employees, setEmployees] = useState([]); const [taskParticipantCandidates, setTaskParticipantCandidates] = useState([]); const [departments, setDepartments] = useState([]); + const [taskLabels, setTaskLabels] = useState([]); const [loading, setLoading] = useState(true); const [boardLoading, setBoardLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -2677,12 +2888,18 @@ function TasksPageContent() { const [columnDragOverId, setColumnDragOverId] = useState(null); const [viewTaskId, setViewTaskId] = useState(null); const [taskModalOpen, setTaskModalOpen] = useState(false); + const [quickTaskTarget, setQuickTaskTarget] = useState(null); + const [quickTaskTitle, setQuickTaskTitle] = useState(""); + const [quickTaskSaving, setQuickTaskSaving] = useState(false); const [taskParticipantsOpen, setTaskParticipantsOpen] = useState(false); const [taskParticipantSearch, setTaskParticipantSearch] = useState(""); const [participantModalOpen, setParticipantModalOpen] = useState(false); const [participantModalSearch, setParticipantModalSearch] = useState(""); const [participantModalSelection, setParticipantModalSelection] = useState([]); const [taskMenuOpen, setTaskMenuOpen] = useState(false); + const [taskAddMenuOpen, setTaskAddMenuOpen] = useState(false); + const [taskAddPanel, setTaskAddPanel] = useState(null); + const [viewTaskDueDateDraft, setViewTaskDueDateDraft] = useState(""); const [viewColumnMenuOpen, setViewColumnMenuOpen] = useState(false); const [cardMenuTaskId, setCardMenuTaskId] = useState(null); const [columnMenuId, setColumnMenuId] = useState(null); @@ -2700,6 +2917,7 @@ function TasksPageContent() { const [columnRowForm, setColumnRowForm] = useState(emptyColumnRowForm); const [labelName, setLabelName] = useState(""); const [labelColor, setLabelColor] = useState("#38bdf8"); + const [taskLabelSearch, setTaskLabelSearch] = useState(""); const [linkedObjectsOpen, setLinkedObjectsOpen] = useState(false); const [linkedPosts, setLinkedPosts] = useState([]); const [linkedMessages, setLinkedMessages] = useState([]); @@ -2766,6 +2984,7 @@ function TasksPageContent() { const [taskActivityLoading, setTaskActivityLoading] = useState(false); const boardActionsMenuRef = useRef(null); const taskMenuRef = useRef(null); + const taskAddMenuRef = useRef(null); const viewColumnMenuRef = useRef(null); const cardMenuRef = useRef(null); const columnMenuRef = useRef(null); @@ -2881,6 +3100,7 @@ function TasksPageContent() { ? await apiClient.getTaskBoard(boardId) : await apiClient.getDefaultTaskBoard(); setBoard(data); + setTaskLabels(data.labels || []); let nextViewState = normalizeBoardViewState(data.view_state); pendingBoardViewStateRef.current.forEach((collapsed, mutationKey) => { const [pendingBoardId, scope, objectId] = mutationKey.split(":"); @@ -3341,7 +3561,14 @@ function TasksPageContent() { if (initialLoad) setLoading(true); else setBoardLoading(true); try { - const [boardData, boardsData, boardGroupsData, employeesData, departmentsData] = await Promise.all([ + const [ + boardData, + boardsData, + boardGroupsData, + employeesData, + departmentsData, + labelsData, + ] = await Promise.all([ requestedBoardId > 0 ? apiClient.getTaskBoard(requestedBoardId) : apiClient.getDefaultTaskBoard(), @@ -3349,6 +3576,7 @@ function TasksPageContent() { apiClient.getTaskBoardGroups(), apiClient.getEmployees({ limit: 200, is_active: true, ordering: "last_name" }), apiClient.getDepartments({ limit: 200 }), + apiClient.getTaskLabels(), ]); if (!mounted) return; const boardList = Array.isArray(boardsData) ? boardsData : boardsData.results || []; @@ -3370,6 +3598,7 @@ function TasksPageContent() { } setEmployees(employeesData.results || employeesData || []); setDepartments(departmentsData.results || departmentsData || []); + setTaskLabels(Array.isArray(labelsData) ? labelsData : labelsData.results || []); if ( requestedTaskId > 0 && (boardData.tasks || []).some((task: TaskCard) => task.id === requestedTaskId) @@ -3434,6 +3663,22 @@ function TasksPageContent() { return () => document.removeEventListener("mousedown", handlePointerDown); }, [taskMenuOpen]); + useEffect(() => { + if (!taskAddMenuOpen) return undefined; + + function handlePointerDown(event: MouseEvent) { + if ( + taskAddMenuRef.current + && !taskAddMenuRef.current.contains(event.target as Node) + ) { + setTaskAddMenuOpen(false); + } + } + + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, [taskAddMenuOpen]); + useEffect(() => { if (!viewColumnMenuOpen) return undefined; @@ -3787,6 +4032,86 @@ function TasksPageContent() { void loadTaskActivity(viewTaskId); }, [activityOpen, loadTaskActivity, viewTask?.updated_at, viewTaskId]); + const cancelQuickTask = useCallback(() => { + if (quickTaskSaving) return; + setQuickTaskTarget(null); + setQuickTaskTitle(""); + }, [quickTaskSaving]); + + const startQuickTask = useCallback((columnId: number, rowId: number | null = null) => { + if (quickTaskSaving) return; + const targetColumn = board?.columns.find((column) => column.id === columnId); + if (!targetColumn) return; + const parentColumnId = targetColumn.parent || targetColumn.id; + + if (boardViewStateRef.current.hidden_column_ids.includes(parentColumnId)) { + toggleBoardViewState("column", parentColumnId); + } + if ( + rowId === null + && rowsByColumn.get(parentColumnId)?.length + && boardViewStateRef.current.collapsed_base_column_ids.includes(parentColumnId) + ) { + toggleBoardViewState("base", parentColumnId); + } + if ( + rowId !== null + && boardViewStateRef.current.collapsed_row_ids.includes(rowId) + ) { + toggleBoardViewState("row", rowId); + } + + if ( + quickTaskTarget?.columnId !== columnId + || quickTaskTarget?.rowId !== rowId + ) { + setQuickTaskTitle(""); + } + setQuickTaskTarget({ columnId, rowId }); + setCardMenuTaskId(null); + setColumnMenuId(null); + }, [board?.columns, quickTaskSaving, quickTaskTarget, rowsByColumn, toggleBoardViewState]); + + const submitQuickTask = useCallback(async () => { + const title = quickTaskTitle.trim(); + const target = quickTaskTarget; + if (!board || !target || !title || quickTaskSaving || saving) return; + + setQuickTaskSaving(true); + setError(null); + try { + await apiClient.createTask({ + board: board.id, + column: target.columnId, + row: target.rowId, + title, + }); + setQuickTaskTarget(null); + setQuickTaskTitle(""); + await loadBoard(board.id); + } catch (quickCreateError) { + setError(getTaskError(quickCreateError, "Не удалось создать задачу")); + } finally { + setQuickTaskSaving(false); + } + }, [board, loadBoard, quickTaskSaving, quickTaskTarget, quickTaskTitle, saving]); + + const renderQuickTaskComposer = useCallback(( + column: TaskColumn, + rowId: number | null, + ) => ( + quickTaskTarget?.columnId === column.id && quickTaskTarget.rowId === rowId ? ( + void submitQuickTask()} + onCancel={cancelQuickTask} + /> + ) : null + ), [cancelQuickTask, quickTaskSaving, quickTaskTarget, quickTaskTitle, submitQuickTask]); + const openCreateTask = useCallback((columnId?: number, rowId: number | null = null) => { const requestedColumn = leafColumns.find((column) => column.id === columnId); const requestedSubcolumn = columnId @@ -3794,6 +4119,8 @@ function TasksPageContent() { : undefined; const firstColumn = requestedColumn || requestedSubcolumn || leafColumns[0]; taskFormResourcesRequestRef.current += 1; + setQuickTaskTarget(null); + setQuickTaskTitle(""); clearTaskFormResources(); setForm({ ...emptyForm, @@ -3805,6 +4132,7 @@ function TasksPageContent() { setTaskParticipantSearch(""); setLabelName(""); setLabelColor("#38bdf8"); + setTaskLabelSearch(""); setTaskModalOpen(true); }, [clearTaskFormResources, leafColumns, subcolumnsByParent, user?.id]); @@ -3814,11 +4142,15 @@ function TasksPageContent() { setParticipantModalSearch(""); setParticipantModalSelection([]); setTaskMenuOpen(false); + setTaskAddMenuOpen(false); + setTaskAddPanel(null); + setTaskLabelSearch(""); + setViewTaskDueDateDraft(task.due_date || ""); setViewColumnMenuOpen(false); setLinkedObjectsOpen(false); setCommentsOpen(false); setAttachmentsOpen(false); - setChecklistOpen(true); + setChecklistOpen(false); setActivityOpen(false); setLinkedMessages([]); setLinkedEvents([]); @@ -3853,6 +4185,10 @@ function TasksPageContent() { if (saving || taskAttachmentsUploading || taskChecklistSaving) return; setViewTaskId(null); setTaskMenuOpen(false); + setTaskAddMenuOpen(false); + setTaskAddPanel(null); + setTaskLabelSearch(""); + setViewTaskDueDateDraft(""); setViewColumnMenuOpen(false); setLinkedMessages([]); setLinkedEvents([]); @@ -3957,6 +4293,7 @@ function TasksPageContent() { label_ids: (task.labels || []).map((label) => label.id), }); setLabelName(""); + setTaskLabelSearch(""); setTaskParticipantsOpen(false); setTaskParticipantSearch(""); setLabelColor("#38bdf8"); @@ -3974,6 +4311,7 @@ function TasksPageContent() { setTaskModalOpen(false); setForm(emptyForm); setLabelName(""); + setTaskLabelSearch(""); setTaskParticipantsOpen(false); setTaskParticipantSearch(""); clearTaskFormResources(); @@ -4154,6 +4492,8 @@ function TasksPageContent() { setError(null); setSearch(""); setOnlyMine(false); + setQuickTaskTarget(null); + setQuickTaskTitle(""); router.push(`/tasks?board=${boardId}`, { scroll: false }); }, [requestedBoardId, requestedTaskId, router, selectedBoardId]); @@ -4698,19 +5038,18 @@ function TasksPageContent() { setError(null); try { const label = await apiClient.createTaskLabel({ - board: board.id, name: labelName.trim(), color: labelColor, }); - setBoard((current) => current ? { - ...current, - labels: [...current.labels, label], - } : current); + setTaskLabels((current) => [...current, label].sort((left, right) => ( + left.name.localeCompare(right.name, "ru") + ))); setForm((current) => ({ ...current, label_ids: [...current.label_ids, label.id], })); setLabelName(""); + setTaskLabelSearch(""); } catch (labelError) { setError(getTaskError(labelError, "Не удалось создать метку")); } finally { @@ -4718,6 +5057,91 @@ function TasksPageContent() { } }, [board, labelColor, labelName]); + const saveViewedTaskDueDate = useCallback(async () => { + if (!board || !viewTask || saving) return; + + setSaving(true); + setError(null); + try { + await apiClient.updateTask(viewTask.id, { + due_date: viewTaskDueDateDraft || null, + }); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + setTaskAddPanel(null); + } catch (dueDateError) { + setError(getTaskError(dueDateError, "Не удалось изменить срок задачи")); + } finally { + setSaving(false); + } + }, [ + activityOpen, + board, + loadBoard, + loadTaskActivity, + saving, + viewTask, + viewTaskDueDateDraft, + ]); + + const toggleViewedTaskLabel = useCallback(async (labelId: number) => { + if (!board || !viewTask || saving) return; + + const currentLabelIds = viewTask.labels?.map((label) => label.id) || []; + const nextLabelIds = currentLabelIds.includes(labelId) + ? currentLabelIds.filter((id) => id !== labelId) + : [...currentLabelIds, labelId]; + + setSaving(true); + setError(null); + try { + await apiClient.updateTask(viewTask.id, { label_ids: nextLabelIds }); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (labelError) { + setError(getTaskError(labelError, "Не удалось изменить метки задачи")); + } finally { + setSaving(false); + } + }, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]); + + const createViewedTaskLabel = useCallback(async () => { + if (!board || !viewTask || !labelName.trim() || saving) return; + + setSaving(true); + setError(null); + try { + const label = await apiClient.createTaskLabel({ + name: labelName.trim(), + color: labelColor, + }); + setTaskLabels((current) => [...current, label].sort((left, right) => ( + left.name.localeCompare(right.name, "ru") + ))); + const currentLabelIds = viewTask.labels?.map((item) => item.id) || []; + await apiClient.updateTask(viewTask.id, { + label_ids: [...currentLabelIds, label.id], + }); + setLabelName(""); + setTaskLabelSearch(""); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (labelError) { + setError(getTaskError(labelError, "Не удалось создать метку")); + } finally { + setSaving(false); + } + }, [ + activityOpen, + board, + labelColor, + labelName, + loadBoard, + loadTaskActivity, + saving, + viewTask, + ]); + const toggleLabel = useCallback((labelId: number) => { setForm((current) => ({ ...current, @@ -5959,7 +6383,7 @@ function TasksPageContent() { column={column} tasks={tasksByPlacement.get(taskPlacementKey(column.id, null)) || []} displayTasksCount={columnCounts.get(column.id) || 0} - onCreateTask={openCreateTask} + onCreateTask={startQuickTask} onOpenTask={openTaskView} onEditTask={openEditTask} onDeleteTask={deleteTask} @@ -5986,6 +6410,7 @@ function TasksPageContent() { synchronizedColumnDrag={activeColumnDragId !== null} fillAvailableHeight={desktopWideMode} hideOnMobile={hideOnMobile} + quickTaskComposer={renderQuickTaskComposer(column, null)} /> ); } @@ -5996,8 +6421,8 @@ function TasksPageContent() { column={column} tasksCount={columnCounts.get(column.id) || 0} onCreateTask={() => { - if (subcolumns[0]) openCreateTask(subcolumns[0].id); - else openCreateTask(column.id); + if (subcolumns[0]) startQuickTask(subcolumns[0].id); + else startQuickTask(column.id); }} onCreateSubcolumn={() => openCreateColumn(column.id)} onCreateRow={() => openCreateColumnRow(column.id)} @@ -6045,7 +6470,7 @@ function TasksPageContent() { column={cellColumn} tasks={tasksByPlacement.get(taskPlacementKey(cellColumn.id, rowId)) || []} displayTasksCount={columnCounts.get(cellColumn.id) || 0} - onCreateTask={openCreateTask} + onCreateTask={startQuickTask} onOpenTask={openTaskView} onEditTask={openEditTask} onDeleteTask={deleteTask} @@ -6074,6 +6499,7 @@ function TasksPageContent() { synchronizedColumnDrag={activeColumnDragId !== null} fillAvailableHeight={desktopWideMode && rowId === null} nested={Boolean(cellColumn.parent)} + quickTaskComposer={renderQuickTaskComposer(cellColumn, rowId)} /> ); return ( @@ -6115,7 +6541,9 @@ function TasksPageContent() { key={row.id} row={{ ...row, tasks_count: rowTasksCount }} gridTemplateColumns={gridTemplateColumns} - onCreateTask={() => openCreateTask(cellColumns[0]?.id, row.id)} + onCreateTask={() => { + if (cellColumns[0]) startQuickTask(cellColumns[0].id, row.id); + }} onEdit={() => openEditColumnRow(row)} onDelete={() => void deleteColumnRow(row)} expanded={!boardViewState.collapsed_row_ids.includes(row.id)} @@ -6602,6 +7030,110 @@ function TasksPageContent() {
+
+ + {taskAddMenuOpen ? ( +
+ + + + + + + +
+ ) : null} +
{!viewTask.assignee && !viewTask.completed_at ? (
+ { + const files = Array.from(event.currentTarget.files || []); + event.currentTarget.value = ""; + setAttachmentsOpen(true); + void uploadViewedTaskAttachments(files); + }} + /> + + {taskAddPanel === "due_date" ? ( +
+
+ + + Срок + + +
+
+ setViewTaskDueDateDraft(event.target.value)} + className="app-input min-w-0 flex-1 rounded-xl px-3 py-2 text-sm" + autoFocus + /> + +
+
+ ) : null} + + {taskAddPanel === "labels" ? ( +
+
+ + + Метки + + +
+ label.id) || []} + search={taskLabelSearch} + onSearchChange={setTaskLabelSearch} + onToggle={(labelId) => void toggleViewedTaskLabel(labelId)} + disabled={saving} + /> +
+ setLabelColor(event.target.value)} + className="h-10 w-10 rounded-lg border border-[var(--border-subtle)] bg-transparent p-1" + aria-label="Цвет новой метки" + /> + setLabelName(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + void createViewedTaskLabel(); + }} + className="app-input min-w-0 rounded-xl px-3 py-2 text-sm" + placeholder="Новая общая метка" + /> + +
+
+ ) : null} + {viewTask.description ? (

@@ -6809,7 +7450,8 @@ function TasksPageContent() {

) : null} -
+ {checklistBadgeTotal > 0 || checklistOpen ? ( +
) : null} -
+
+ ) : null} {viewTask.labels && viewTask.labels.length > 0 ? (
@@ -6940,7 +7583,8 @@ function TasksPageContent() {
) : null} -
+ {attachmentsBadgeCount > 0 || attachmentsOpen ? ( +
- { - const files = Array.from(event.currentTarget.files || []); - event.currentTarget.value = ""; - setAttachmentsOpen(true); - void uploadViewedTaskAttachments(files); - }} - />
) : null} -
+
+ ) : null} -
+ {commentsBadgeCount > 0 || commentsOpen ? ( +
) : null} -
+ + ) : null} -
+ {linkedObjectsBadgeCount > 0 || linkedObjectsOpen ? ( +
+
+ ) : null}
-
- {(board?.labels || []).map((label) => { - const selected = form.label_ids.includes(label.id); - return ( - - ); - })} -
+
setLabelName(event.target.value)} className="app-input min-w-0 rounded-xl px-3 py-2 text-sm" - placeholder="Новая метка" + placeholder="Новая общая метка" />