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>(new Map()); const columnNodeRefs = useRef>(new Map()); + const boardSearchInputRef = useRef(null); const taskAttachmentInputRef = useRef(null); const taskCoverUploadInputRef = useRef(null); const taskFormAttachmentInputRef = useRef(null); @@ -3842,9 +3848,15 @@ function TasksPageContent() { } = useTaskBoardScroll({ board, desktopWideMode, loading }); const sensors = useSensors( - useSensor(PointerSensor, { + useSensor(MouseSensor, { activationConstraint: { distance: 8 }, }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 450, + tolerance: 8, + }, + }), ); useEffect(() => { @@ -4962,6 +4974,7 @@ function TasksPageContent() { setParticipantModalOpen(false); setParticipantModalSearch(""); setParticipantModalSelection([]); + setParticipantModalAssigneeId(null); setTaskMenuOpen(false); setTaskAddMenuOpen(false); setTaskAddPanel(null); @@ -5058,6 +5071,7 @@ function TasksPageContent() { setParticipantModalOpen(false); setParticipantModalSearch(""); setParticipantModalSelection([]); + setParticipantModalAssigneeId(null); }, [saving, taskAttachmentsUploading, taskChecklistSaving]); const openParticipantModal = useCallback(() => { @@ -5068,6 +5082,7 @@ function TasksPageContent() { if (viewTask.created_by?.id) participantIds.add(viewTask.created_by.id); if (viewTask.assignee?.id) participantIds.add(viewTask.assignee.id); setParticipantModalSelection([...participantIds]); + setParticipantModalAssigneeId(viewTask.assignee?.id || null); setParticipantModalSearch(""); setParticipantModalOpen(true); }, [viewTask]); @@ -5077,12 +5092,14 @@ function TasksPageContent() { setParticipantModalOpen(false); setParticipantModalSearch(""); setParticipantModalSelection([]); + setParticipantModalAssigneeId(null); }, [saving]); const toggleParticipantModalEmployee = useCallback((employeeId: number) => { if ( employeeId === viewTask?.created_by?.id || - employeeId === viewTask?.assignee?.id + employeeId === viewTask?.assignee?.id || + employeeId === participantModalAssigneeId ) { return; } @@ -5091,7 +5108,18 @@ function TasksPageContent() { ? current.filter((id) => id !== employeeId) : [...current, employeeId] )); - }, [viewTask?.assignee?.id, viewTask?.created_by?.id]); + }, [ + participantModalAssigneeId, + viewTask?.assignee?.id, + viewTask?.created_by?.id, + ]); + + const toggleParticipantModalAssignee = useCallback((employeeId: number) => { + setParticipantModalAssigneeId((current) => current === employeeId ? null : employeeId); + setParticipantModalSelection((current) => ( + current.includes(employeeId) ? current : [...current, employeeId] + )); + }, []); const addCurrentUserAsParticipant = useCallback(() => { if (!user?.id) return; @@ -5107,17 +5135,26 @@ function TasksPageContent() { try { await apiClient.updateTask(viewTask.id, { participant_ids: participantModalSelection, + assignee_id: participantModalAssigneeId, }); await loadBoard(board.id); setParticipantModalOpen(false); setParticipantModalSearch(""); setParticipantModalSelection([]); + setParticipantModalAssigneeId(null); } catch (participantError) { setError(getTaskError(participantError, "Не удалось изменить участников задачи")); } finally { setSaving(false); } - }, [board, loadBoard, participantModalSelection, saving, viewTask]); + }, [ + board, + loadBoard, + participantModalAssigneeId, + participantModalSelection, + saving, + viewTask, + ]); const openEditTask = useCallback((task: TaskCard) => { taskFormResourcesRequestRef.current += 1; @@ -7381,7 +7418,7 @@ function TasksPageContent() { wideMode={desktopWideMode} onWideModeChange={changeDesktopWideMode} userId={user?.id ?? null} - className={`${mobileBoardOpen ? "hidden" : "flex"} h-[calc(100dvh-7rem)] w-full ${desktopWideMode ? "lg:flex lg:h-full lg:shrink-0" : ""}`} + className={`${mobileBoardOpen ? "hidden" : "flex"} w-full ${desktopWideMode ? "lg:flex lg:h-full lg:shrink-0" : ""}`} />
@@ -7431,6 +7468,26 @@ function TasksPageContent() { Создать задачу +
-
+
+ + setAutomationModalOpen(false)} @@ -9826,7 +9957,7 @@ function TasksPageContent() {

Сотрудники

- Выбрано: {participantModalSelection.length} + Выбрано: {participantModalSelection.length}. Значок справа назначает ответственного.

+
); }) ) : ( diff --git a/frontend/src/components/tasks/TaskBoardNavigator.tsx b/frontend/src/components/tasks/TaskBoardNavigator.tsx index 1f3fdef3..adee08c6 100644 --- a/frontend/src/components/tasks/TaskBoardNavigator.tsx +++ b/frontend/src/components/tasks/TaskBoardNavigator.tsx @@ -466,10 +466,12 @@ export default function TaskBoardNavigator({ <>