diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py
index ddef64b4..322013a5 100644
--- a/backend/tests/api/v1/tasks/test_tasks_api.py
+++ b/backend/tests/api/v1/tasks/test_tasks_api.py
@@ -1484,6 +1484,47 @@ def test_create_task_and_move_to_done_column(api_client, user):
assert task.completed_at is not None
+def test_task_participants_can_change_assignee_in_same_update(api_client, user):
+ previous_assignee = make_user(
+ "task-previous-assignee@example.com",
+ "+79994440115",
+ )
+ next_assignee = make_user(
+ "task-next-assignee@example.com",
+ "+79994440116",
+ )
+ board = TaskBoard.objects.create(name="Доска участников", created_by=user)
+ board.members.add(previous_assignee, next_assignee)
+ column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
+ task = Task.objects.create(
+ board=board,
+ column=column,
+ title="Смена ответственного",
+ created_by=user,
+ assignee=previous_assignee,
+ )
+ task.participants.set([user, previous_assignee])
+ api_client.force_authenticate(user=user)
+
+ response = api_client.patch(
+ reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id}),
+ {
+ "assignee_id": next_assignee.id,
+ "participant_ids": [user.id, next_assignee.id],
+ },
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ task.refresh_from_db()
+ assert task.assignee_id == next_assignee.id
+ assert set(task.participants.values_list("id", flat=True)) == {
+ user.id,
+ previous_assignee.id,
+ next_assignee.id,
+ }
+
+
def test_member_can_claim_unassigned_task(api_client, user):
claimant = make_user("task-claimant@example.com", "+79994440095")
other_member = make_user("task-claim-other@example.com", "+79994440096")
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 23264cf2..762a87f0 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -1170,6 +1170,29 @@ html[data-bs-theme="dark"] .tasks-board-group-header-sticky {
background: var(--accent-primary);
}
+/* На мобильной доске каждая колонка, подколонка и ячейка дорожки имеет
+ собственную область прокрутки. contain не передаёт скролл всей странице,
+ когда пользователь дошёл до начала или конца конкретной ленты. */
+@media (max-width: 79.999rem) {
+ .tasks-board-column-header,
+ .tasks-board-subcolumn-header {
+ box-shadow: none !important;
+ }
+
+ .tasks-mobile-lane-viewport {
+ height: var(--tasks-mobile-lane-height, calc(100dvh - 18rem));
+ min-height: var(--tasks-mobile-lane-height, calc(100dvh - 18rem));
+ max-height: var(--tasks-mobile-lane-height, calc(100dvh - 18rem));
+ }
+
+ .tasks-mobile-lane-scroll {
+ overscroll-behavior-y: contain;
+ scrollbar-gutter: stable;
+ -webkit-overflow-scrolling: touch;
+ }
+
+}
+
@media (min-width: 80rem) {
.tasks-board-column-group {
--tasks-board-stack-gap: 0.5rem;
diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx
index 79fcffbd..1ec26ce4 100644
--- a/frontend/src/app/tasks/page.tsx
+++ b/frontend/src/app/tasks/page.tsx
@@ -3,11 +3,12 @@
import Image from "next/image";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
-import { createPortal } from "react-dom";
+import { createPortal, flushSync } from "react-dom";
import {
DndContext,
DragOverlay,
- PointerSensor,
+ MouseSensor,
+ TouchSensor,
closestCorners,
pointerWithin,
useDraggable,
@@ -60,6 +61,7 @@ import {
Tag,
Trash2,
UserPlus,
+ UserCheck,
UserRound,
Workflow,
X,
@@ -713,21 +715,16 @@ const TaskCardContent = memo(function TaskCardContent({
className="-mx-3 -mt-3 mb-3 aspect-[16/7] max-h-32 w-[calc(100%+1.5rem)] rounded-t-[11px] border-b border-[var(--border-subtle)]"
/>
) : null}
-
-
-
-
- #{task.id}
-
-
- {task.title}
-
-
- {task.description ?
: null}
-
-
+
+
+
+ #{task.id}
+
+
+ {task.title}
+
event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
@@ -746,6 +743,9 @@ const TaskCardContent = memo(function TaskCardContent({
/>
+
+ {task.description ?
: null}
+
{priority.urgencyLabel} срочность
@@ -914,6 +914,12 @@ const TaskCardView = memo(function TaskCardView(props: TaskCardViewProps) {
}}
onPointerDownCapture={() => {
pointerPressedRef.current = true;
+ if (!dragArmed) {
+ // На сенсорном экране pointerdown приходит непосредственно перед
+ // touchstart. Подключаем ленивую drag-привязку синхронно, чтобы
+ // TouchSensor получил это же первое касание и запустил удержание.
+ flushSync(() => setDragArmed(true));
+ }
}}
onPointerUpCapture={() => {
pointerPressedRef.current = false;
@@ -1356,7 +1362,6 @@ function BoardColumn({
linkedColumnHighlight = false,
synchronizedColumnDrag = false,
fillAvailableHeight = false,
- hideOnMobile = false,
quickTaskComposer,
}: {
column: TaskColumn;
@@ -1386,7 +1391,6 @@ function BoardColumn({
linkedColumnHighlight?: boolean;
synchronizedColumnDrag?: boolean;
fillAvailableHeight?: boolean;
- hideOnMobile?: boolean;
quickTaskComposer?: ReactNode;
}) {
const {
@@ -1424,9 +1428,9 @@ function BoardColumn({
{contentVisible ?
@@ -1791,7 +1795,6 @@ function BoardColumnGroup({
onToggleContentVisibility,
subcolumnHeaders,
subcolumnGridTemplate,
- hideOnMobile = false,
}: {
column: TaskColumn;
tasksCount: number;
@@ -1809,7 +1812,6 @@ function BoardColumnGroup({
onToggleContentVisibility: () => void;
subcolumnHeaders: ReactNode;
subcolumnGridTemplate: string;
- hideOnMobile?: boolean;
}) {
const {
attributes,
@@ -1836,13 +1838,13 @@ function BoardColumnGroup({
transition,
zIndex: isDragging ? 20 : undefined,
}}
- className={`${hideOnMobile ? "hidden xl:flex" : "flex"} tasks-board-column-group min-w-0 flex-col ${isDragging ? "opacity-70" : ""}`}
+ className={`tasks-board-column-group flex min-w-max flex-col ${isDragging ? "opacity-70" : ""}`}
>
{contentVisible ? (
-
+
{subcolumnHeaders}
@@ -1959,7 +1961,7 @@ function BoardColumnGroup({
) : null}
{contentVisible ? (
-
+
{children}
) : null}
@@ -2044,7 +2046,7 @@ function BoardColumnRowView({
{expanded ? (
{children}
@@ -2079,7 +2081,7 @@ function BoardBaseColumnsView({
}`}>
{headers ? (
{headers}
@@ -2104,7 +2106,7 @@ function BoardBaseColumnsView({
) : null}
{!collapsible || expanded ? (
{children}
@@ -2128,7 +2130,7 @@ function AddColumnCard({
type="button"
onClick={onClick}
style={{ height: cardHeight }}
- className="group hidden min-w-[18rem] self-start rounded-xl border border-dashed border-[var(--border-subtle)] bg-[var(--surface-muted)] p-4 text-center transition hover:border-[var(--accent-primary)] hover:bg-[var(--surface-elevated)] xl:sticky xl:top-0 xl:flex xl:flex-col xl:items-center xl:justify-center"
+ className="group flex min-w-[18rem] self-start flex-col items-center justify-center rounded-xl border border-dashed border-[var(--border-subtle)] bg-[var(--surface-muted)] p-4 text-center transition hover:border-[var(--accent-primary)] hover:bg-[var(--surface-elevated)] xl:sticky xl:top-0"
>
@@ -3698,9 +3700,11 @@ function TasksPageContent() {
const [completingTaskId, setCompletingTaskId] = useState(null);
const [error, setError] = useState(null);
const [search, setSearch] = useState("");
+ const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [onlyMine, setOnlyMine] = useState(false);
const [desktopWideMode, setDesktopWideMode] = useState(false);
const [selectedColumnTarget, setSelectedColumnTarget] = useState("all");
+ const [mobileColumnFiltersOpen, setMobileColumnFiltersOpen] = useState(false);
const [activeTaskId, setActiveTaskId] = useState(null);
const [activeColumnDragId, setActiveColumnDragId] = useState(null);
const [columnDragOverId, setColumnDragOverId] = useState(null);
@@ -3715,6 +3719,7 @@ function TasksPageContent() {
const [participantModalOpen, setParticipantModalOpen] = useState(false);
const [participantModalSearch, setParticipantModalSearch] = useState("");
const [participantModalSelection, setParticipantModalSelection] = useState([]);
+ const [participantModalAssigneeId, setParticipantModalAssigneeId] = useState(null);
const [taskMenuOpen, setTaskMenuOpen] = useState(false);
const [taskAddMenuOpen, setTaskAddMenuOpen] = useState(false);
const [taskAddMenuPlacement, setTaskAddMenuPlacement] = useState<"top" | "bottom">("top");
@@ -3825,6 +3830,7 @@ function TasksPageContent() {
const boardViewStateRef = useRef(EMPTY_BOARD_VIEW_STATE);
const pendingBoardViewStateRef = useRef