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 (
+
+ );
+}
+
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}
+ />
+
+
+ ) : null}
+
{viewTask.description ? (
@@ -6809,7 +7450,8 @@ function TasksPageContent() {
) : null}
-
+ {checklistBadgeTotal > 0 || checklistOpen ? (
+
+
+ ) : null}
{viewTask.labels && viewTask.labels.length > 0 ? (
@@ -6940,7 +7583,8 @@ function TasksPageContent() {
) : null}
-
+ {attachmentsBadgeCount > 0 || attachmentsOpen ? (
+
+
+ ) : 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="Новая общая метка"
/>