feat(tasks): improve mobile board interactions

This commit is contained in:
lizerk 2026-08-07 19:51:30 +03:00
parent 8cad1832cd
commit 69a124a5eb
5 changed files with 384 additions and 114 deletions

View File

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

View File

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

View File

@ -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}
<div className="mb-2 flex items-start gap-2">
<div className="min-w-0 flex-1 overflow-hidden whitespace-normal text-left">
<div className="flex min-w-0 items-center gap-2">
<span className="app-badge shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold">
#{task.id}
</span>
<h3 className="min-w-0 max-w-[9rem] truncate text-sm font-semibold text-[var(--foreground)]">
{task.title}
</h3>
</div>
{task.description ? <TaskDescriptionPreview description={task.description} /> : null}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<div className="mb-2 min-w-0 overflow-hidden whitespace-normal text-left">
<div className="flex min-w-0 items-center gap-2">
<span className="app-badge shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold">
#{task.id}
</span>
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--foreground)]">
{task.title}
</h3>
<div
className="relative"
className="relative shrink-0"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
@ -746,6 +743,9 @@ const TaskCardContent = memo(function TaskCardContent({
/>
</button>
</div>
</div>
{task.description ? <TaskDescriptionPreview description={task.description} /> : null}
<div className="mt-1 flex justify-end">
<span className={`whitespace-nowrap text-right text-[11px] font-medium ${priority.textClassName}`}>
{priority.urgencyLabel} срочность
</span>
@ -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({
<section
ref={setColumnNodeRef}
style={style}
className={`${hideOnMobile ? "hidden xl:flex" : "flex"} max-h-[calc(100dvh-15rem)] ${
contentVisible ? "min-h-[28rem]" : ""
} w-full min-w-0 flex-col transition xl:max-h-none xl:w-auto xl:min-w-[18rem] ${
className={`flex ${
contentVisible ? "tasks-mobile-lane-viewport min-h-[28rem]" : ""
} w-[18rem] min-w-[18rem] flex-col transition xl:max-h-none ${
fillAvailableHeight ? "xl:min-h-full" : ""
} ${
isDragging ? "opacity-70" : ""
@ -1440,7 +1444,7 @@ function BoardColumn({
>
<div
ref={setActivatorNodeRef}
className="min-w-0 flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
className="min-w-0 flex-1 cursor-grab overflow-hidden active:cursor-grabbing"
title="Перетащите, чтобы изменить порядок колонок"
{...attributes}
{...listeners}
@ -1553,7 +1557,7 @@ function BoardColumn({
</div>
</div>
{contentVisible ? <div
className={`tasks-column-scroll mt-2 min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border bg-[var(--surface-primary)] p-3 transition xl:mt-0 xl:flex-none xl:overflow-visible ${columnBorderClass}`}
className={`tasks-mobile-lane-scroll tasks-column-scroll mt-2 min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border bg-[var(--surface-primary)] p-3 transition xl:mt-0 xl:flex-none xl:overflow-visible ${columnBorderClass}`}
>
<TaskColumnCards
tasks={tasks}
@ -1649,7 +1653,7 @@ function BoardSubcolumnHeader({
<div
ref={setActivatorNodeRef}
className={`min-w-0 flex-1 overflow-hidden ${
sortable ? "cursor-grab touch-none active:cursor-grabbing" : ""
sortable ? "cursor-grab active:cursor-grabbing" : ""
}`}
title={sortable ? "Перетащите, чтобы изменить порядок подколонок" : undefined}
{...(sortable ? attributes : {})}
@ -1761,12 +1765,12 @@ function BoardTaskCell({
return (
<section
ref={setNodeRef}
className={`flex w-full min-w-0 flex-col transition ${
className={`tasks-mobile-lane-viewport flex w-full min-w-0 flex-col transition ${
rowId ? "min-h-0" : "min-h-[28rem]"
} xl:w-full ${fillAvailableHeight ? "xl:min-h-full" : ""}`}
>
<div
className={`tasks-column-scroll min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border bg-[var(--surface-primary)] p-3 transition xl:flex-none xl:overflow-visible ${columnBorderClass}`}
className={`tasks-mobile-lane-scroll tasks-column-scroll min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border bg-[var(--surface-primary)] p-3 transition xl:flex-none xl:overflow-visible ${columnBorderClass}`}
>
<TaskColumnCards {...cardsProps} />
</div>
@ -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" : ""}`}
>
<div className="tasks-board-group-header-sticky">
<div className="tasks-board-column-header app-surface flex min-h-16 items-center justify-between gap-3 rounded-xl px-3 py-3">
<div
ref={setActivatorNodeRef}
className="min-w-0 flex-1 cursor-grab touch-none active:cursor-grabbing"
className="min-w-0 flex-1 cursor-grab active:cursor-grabbing"
title="Перетащите, чтобы изменить порядок колонок"
{...attributes}
{...listeners}
@ -1948,9 +1950,9 @@ function BoardColumnGroup({
</div>
</div>
{contentVisible ? (
<div className="tasks-board-subcolumn-header-surface relative mt-2 rounded-t-xl border border-b-0 border-[var(--border-subtle)] bg-[var(--surface-secondary)] p-2">
<div className="tasks-board-subcolumn-header-surface relative mt-2 w-max min-w-full rounded-t-xl border border-b-0 border-[var(--border-subtle)] bg-[var(--surface-secondary)] p-2">
<div
className="tasks-board-subcolumn-header-row flex min-w-0 flex-col gap-2 xl:grid xl:w-max"
className="tasks-board-subcolumn-header-row grid w-max min-w-0 gap-2"
style={{ gridTemplateColumns: subcolumnGridTemplate }}
>
{subcolumnHeaders}
@ -1959,7 +1961,7 @@ function BoardColumnGroup({
) : null}
</div>
{contentVisible ? (
<div className="tasks-board-column-content flex w-full min-w-0 flex-col gap-2 rounded-b-xl border border-t-0 border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-2 pb-2 xl:w-auto xl:min-w-max">
<div className="tasks-board-column-content flex w-auto min-w-max flex-col gap-2 rounded-b-xl border border-t-0 border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-2 pb-2">
{children}
</div>
) : null}
@ -2044,7 +2046,7 @@ function BoardColumnRowView({
</div>
{expanded ? (
<div
className="flex min-w-0 flex-col gap-2 xl:grid xl:w-max"
className="grid w-max min-w-0 gap-2"
style={{ gridTemplateColumns }}
>
{children}
@ -2079,7 +2081,7 @@ function BoardBaseColumnsView({
}`}>
{headers ? (
<div
className="flex min-w-0 flex-col gap-2 xl:grid xl:w-max"
className="grid w-max min-w-0 gap-2"
style={{ gridTemplateColumns }}
>
{headers}
@ -2104,7 +2106,7 @@ function BoardBaseColumnsView({
) : null}
{!collapsible || expanded ? (
<div
className="flex min-w-0 flex-col gap-2 xl:grid xl:w-max"
className="grid w-max min-w-0 gap-2"
style={{ gridTemplateColumns }}
>
{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"
>
<span className="app-selected mb-3 flex h-10 w-10 items-center justify-center rounded-xl transition group-hover:scale-105">
<Plus size={18} />
@ -3698,9 +3700,11 @@ function TasksPageContent() {
const [completingTaskId, setCompletingTaskId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [onlyMine, setOnlyMine] = useState(false);
const [desktopWideMode, setDesktopWideMode] = useState(false);
const [selectedColumnTarget, setSelectedColumnTarget] = useState<number | "all">("all");
const [mobileColumnFiltersOpen, setMobileColumnFiltersOpen] = useState(false);
const [activeTaskId, setActiveTaskId] = useState<number | null>(null);
const [activeColumnDragId, setActiveColumnDragId] = useState<number | null>(null);
const [columnDragOverId, setColumnDragOverId] = useState<number | null>(null);
@ -3715,6 +3719,7 @@ function TasksPageContent() {
const [participantModalOpen, setParticipantModalOpen] = useState(false);
const [participantModalSearch, setParticipantModalSearch] = useState("");
const [participantModalSelection, setParticipantModalSelection] = useState<number[]>([]);
const [participantModalAssigneeId, setParticipantModalAssigneeId] = useState<number | null>(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<TaskBoardViewState>(EMPTY_BOARD_VIEW_STATE);
const pendingBoardViewStateRef = useRef<Map<string, boolean>>(new Map());
const columnNodeRefs = useRef<Map<number, HTMLElement>>(new Map());
const boardSearchInputRef = useRef<HTMLInputElement | null>(null);
const taskAttachmentInputRef = useRef<HTMLInputElement | null>(null);
const taskCoverUploadInputRef = useRef<HTMLInputElement | null>(null);
const taskFormAttachmentInputRef = useRef<HTMLInputElement | null>(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" : ""}`}
/>
<div className={`${mobileBoardOpen ? "flex" : "hidden"} relative min-h-0 min-w-0 flex-1 flex-col gap-4 ${desktopWideMode ? "lg:flex lg:h-full" : ""}`}>
@ -7431,6 +7468,26 @@ function TasksPageContent() {
<Plus size={16} />
Создать задачу
</button>
<button
type="button"
onClick={() => {
setMobileSearchOpen((current) => {
const next = !current;
if (next) {
window.requestAnimationFrame(() => boardSearchInputRef.current?.focus());
}
return next;
});
}}
className={`app-icon-button flex h-8 w-8 items-center justify-center rounded-md lg:hidden ${
mobileSearchOpen || search ? "app-selected" : ""
}`}
title={mobileSearchOpen ? "Скрыть поиск" : "Поиск по задачам"}
aria-label={mobileSearchOpen ? "Скрыть поиск" : "Открыть поиск по задачам"}
aria-expanded={mobileSearchOpen}
>
<Search size={15} />
</button>
<div ref={boardActionsMenuRef} className="relative">
<button
type="button"
@ -7525,10 +7582,11 @@ function TasksPageContent() {
</div>
</div>
<div className="mt-4">
<div className={`${mobileSearchOpen ? "block" : "hidden"} mt-4 lg:block`}>
<label className="relative block min-w-0">
<Search size={16} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input
ref={boardSearchInputRef}
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Поиск по задачам"
@ -7566,42 +7624,65 @@ function TasksPageContent() {
) : null}
<button
type="button"
onClick={focusAllColumns}
className={`hidden items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition xl:inline-flex ${
selectedColumnTarget === "all" ? "app-pill-active" : "app-pill"
onClick={() => setMobileColumnFiltersOpen(true)}
className={`inline-flex h-8 items-center justify-center rounded-full px-2.5 transition xl:hidden ${
selectedColumnTarget === "all" ? "app-pill" : "app-pill-active"
}`}
title="Фильтры колонок"
aria-label="Открыть фильтры колонок"
aria-expanded={mobileColumnFiltersOpen}
aria-haspopup="dialog"
>
<span>Все</span>
<span className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
selectedColumnTarget === "all" ? "app-pill-count-active" : "app-pill-count"
}`}>
{filteredTasks.length}
</span>
</button>
{topLevelColumns.map((column) => {
const active = selectedColumnTarget === column.id;
return (
<button
key={column.id}
type="button"
onClick={() => focusColumn(column.id)}
className={`inline-flex max-w-48 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
active ? "app-pill-active" : "app-pill"
}`}
>
<span className="flex -space-x-1" aria-hidden="true">
{topLevelColumns.slice(0, 6).map((column) => (
<span
className="h-2 w-2 shrink-0 rounded-full"
key={column.id}
className="h-3 w-3 rounded-full border-2 border-[var(--surface-elevated)]"
style={{ backgroundColor: column.color || "#38bdf8" }}
/>
<span className="truncate">{column.name}</span>
<span className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
active ? "app-pill-count-active" : "app-pill-count"
}`}>
{columnCounts.get(column.id) || 0}
</span>
</button>
);
})}
))}
</span>
</button>
<div className="hidden xl:contents">
<button
type="button"
onClick={focusAllColumns}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
selectedColumnTarget === "all" ? "app-pill-active" : "app-pill"
}`}
>
<span>Все</span>
<span className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
selectedColumnTarget === "all" ? "app-pill-count-active" : "app-pill-count"
}`}>
{filteredTasks.length}
</span>
</button>
{topLevelColumns.map((column) => {
const active = selectedColumnTarget === column.id;
return (
<button
key={column.id}
type="button"
onClick={() => focusColumn(column.id)}
className={`inline-flex max-w-48 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
active ? "app-pill-active" : "app-pill"
}`}
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: column.color || "#38bdf8" }}
/>
<span className="truncate">{column.name}</span>
<span className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
active ? "app-pill-count-active" : "app-pill-count"
}`}>
{columnCounts.get(column.id) || 0}
</span>
</button>
);
})}
</div>
<button
type="button"
onClick={() => openCreateColumn()}
@ -7648,17 +7729,14 @@ function TasksPageContent() {
onAuxClick={(event) => {
if (event.button === 1) event.preventDefault();
}}
className={`tasks-board-scroll min-w-0 overflow-hidden pb-3 xl:overflow-auto ${
className={`tasks-board-scroll min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-contain pb-3 xl:overflow-auto ${
desktopWideMode ? "lg:min-h-0 lg:flex-1" : "xl:min-h-0 xl:flex-1"
}`}
>
<div className="flex w-full min-w-0 gap-3 xl:min-h-full xl:w-auto xl:min-w-max xl:items-stretch">
<div className="flex w-auto min-w-max items-stretch gap-3 xl:min-h-full">
{topLevelColumns.map((column) => {
const subcolumns = subcolumnsByParent.get(column.id) || [];
const columnRows = rowsByColumn.get(column.id) || [];
const hideOnMobile = selectedColumnTarget === "all"
? topLevelColumns[0]?.id !== column.id
: selectedColumnTarget !== column.id;
if (subcolumns.length === 0 && columnRows.length === 0) {
return (
<BoardColumn
@ -7690,7 +7768,6 @@ function TasksPageContent() {
linkedColumnHighlight={activeColumnDragId === column.id}
synchronizedColumnDrag={activeColumnDragId !== null}
fillAvailableHeight={desktopWideMode}
hideOnMobile={hideOnMobile}
quickTaskComposer={renderQuickTaskComposer(column, null)}
/>
);
@ -7807,7 +7884,6 @@ function TasksPageContent() {
onToggleContentVisibility={() => toggleBoardViewState("column", column.id)}
subcolumnHeaders={subcolumnHeaders}
subcolumnGridTemplate={gridTemplateColumns}
hideOnMobile={hideOnMobile}
>
{baseColumns}
{columnRows.length > 0 ? (
@ -7878,6 +7954,61 @@ function TasksPageContent() {
</div>
</div>
<Modal
isOpen={mobileColumnFiltersOpen}
onClose={() => setMobileColumnFiltersOpen(false)}
title="Колонки"
size="sm"
closeOnClickOutside
>
<div className="flex flex-wrap gap-2 pb-1">
<button
type="button"
onClick={() => {
focusAllColumns();
setMobileColumnFiltersOpen(false);
}}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
selectedColumnTarget === "all" ? "app-pill-active" : "app-pill"
}`}
>
<span>Все</span>
<span className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
selectedColumnTarget === "all" ? "app-pill-count-active" : "app-pill-count"
}`}>
{filteredTasks.length}
</span>
</button>
{topLevelColumns.map((column) => {
const active = selectedColumnTarget === column.id;
return (
<button
key={column.id}
type="button"
onClick={() => {
focusColumn(column.id);
setMobileColumnFiltersOpen(false);
}}
className={`inline-flex max-w-full items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
active ? "app-pill-active" : "app-pill"
}`}
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: column.color || "#38bdf8" }}
/>
<span className="min-w-0 truncate">{column.name}</span>
<span className={`app-badge shrink-0 px-1.5 py-0.5 text-[10px] font-bold ${
active ? "app-pill-count-active" : "app-pill-count"
}`}>
{columnCounts.get(column.id) || 0}
</span>
</button>
);
})}
</div>
</Modal>
<TaskAutomationManager
isOpen={automationModalOpen}
onClose={() => setAutomationModalOpen(false)}
@ -9826,7 +9957,7 @@ function TasksPageContent() {
<div>
<p className="text-sm font-medium text-[var(--foreground)]">Сотрудники</p>
<p className="app-text-muted mt-0.5 text-xs">
Выбрано: {participantModalSelection.length}
Выбрано: {participantModalSelection.length}. Значок справа назначает ответственного.
</p>
</div>
<button
@ -9862,44 +9993,66 @@ function TasksPageContent() {
filteredParticipantModalCandidates.map((employee) => {
const selected = participantModalSelection.includes(employee.id);
const isCreator = employee.id === viewTask?.created_by?.id;
const isAssignee = employee.id === viewTask?.assignee?.id;
const required = isCreator || isAssignee;
const isAssignee = employee.id === participantModalAssigneeId;
const isPreviousAssignee = employee.id === viewTask?.assignee?.id;
const required = isCreator || isAssignee || isPreviousAssignee;
return (
<label
<div
key={employee.id}
className={`flex items-center gap-2 rounded-lg px-2 py-2 text-sm transition ${
selected ? "app-selected" : "hover:bg-[var(--surface-elevated)]"
} ${required ? "cursor-default" : "cursor-pointer"}`}
}`}
>
<input
type="checkbox"
checked={selected}
onChange={() => toggleParticipantModalEmployee(employee.id)}
disabled={required}
className="h-4 w-4 shrink-0"
/>
<RequestAvatar
alt={displayUserName(employee)}
fallback={getEmployeeInitials(employee)}
src={employee.avatar}
size="sm"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-[var(--foreground)]">
{displayUserName(employee)}
</span>
{employee.email ? (
<span className="app-text-muted block truncate text-[10px]">
{employee.email}
<label className={`flex min-w-0 flex-1 items-center gap-2 ${
required ? "cursor-default" : "cursor-pointer"
}`}>
<input
type="checkbox"
checked={selected}
onChange={() => toggleParticipantModalEmployee(employee.id)}
disabled={required}
className="h-4 w-4 shrink-0"
/>
<RequestAvatar
alt={displayUserName(employee)}
fallback={getEmployeeInitials(employee)}
src={employee.avatar}
size="sm"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-[var(--foreground)]">
{displayUserName(employee)}
</span>
) : null}
</span>
{required ? (
{employee.email ? (
<span className="app-text-muted block truncate text-[10px]">
{employee.email}
</span>
) : null}
</span>
</label>
{isCreator ? (
<span className="app-badge shrink-0 rounded-full px-2 py-0.5 text-[10px]">
{isAssignee ? "исполнитель" : "автор"}
автор
</span>
) : null}
</label>
<button
type="button"
onClick={() => toggleParticipantModalAssignee(employee.id)}
className={`inline-flex shrink-0 items-center justify-center gap-1 rounded-lg px-2 py-1.5 text-[10px] font-medium transition ${
isAssignee
? "app-selected app-accent-text"
: "app-action-ghost app-text-muted"
}`}
title={isAssignee ? "Снять ответственного" : "Назначить ответственным"}
aria-label={isAssignee
? `Снять ответственного ${displayUserName(employee)}`
: `Назначить ответственным ${displayUserName(employee)}`}
aria-pressed={isAssignee}
>
<UserCheck size={14} />
{isAssignee ? <span>Ответственный</span> : null}
</button>
</div>
);
})
) : (

View File

@ -466,10 +466,12 @@ export default function TaskBoardNavigator({
<>
<aside
ref={menusRef}
className={`flex min-h-0 flex-col overflow-hidden transition-[width] duration-200 ${
className={`flex min-h-0 flex-col overflow-visible transition-[width] duration-200 ${
compactNavigator ? "bg-transparent" : "app-surface rounded-2xl"
} ${
wideMode ? (compactNavigator ? "lg:w-[4.75rem]" : "lg:w-[24rem]") : ""
wideMode
? `${compactNavigator ? "lg:w-[4.75rem]" : "lg:w-[24rem]"} lg:overflow-hidden`
: ""
} ${className}`}
>
<div
@ -751,7 +753,9 @@ export default function TaskBoardNavigator({
</div>
) : null}
<div className={`tasks-column-scroll min-h-0 flex-1 overflow-y-auto pb-4 ${compactNavigator ? "px-2 pt-2" : "px-4"}`}>
<div className={`tasks-column-scroll flex-none overflow-visible pb-4 ${
wideMode ? "lg:min-h-0 lg:flex-1 lg:overflow-y-auto" : ""
} ${compactNavigator ? "px-2 pt-2" : "px-4"}`}>
{visibleBoards.length > 0 ? (
<div className="space-y-1">
{visibleBoards.map((board) => (

View File

@ -107,6 +107,42 @@ export function useTaskBoardScroll({
let scrollFrameId: number | null = null;
const updateMobileLaneHeights = () => {
const lanes = container.querySelectorAll<HTMLElement>(".tasks-mobile-lane-viewport");
if (window.matchMedia("(min-width: 80rem)").matches) {
lanes.forEach((lane) => lane.style.removeProperty("--tasks-mobile-lane-height"));
return;
}
const visualViewport = window.visualViewport;
const viewportTop = visualViewport?.offsetTop || 0;
const viewportBottom = viewportTop + (visualViewport?.height || window.innerHeight);
const appHeader = document.querySelector<HTMLElement>(".app-header");
const appHeaderRect = appHeader?.getBoundingClientRect();
const headerIsAtBottom = Boolean(
appHeaderRect
&& appHeaderRect.top > viewportTop + (viewportBottom - viewportTop) / 2
&& appHeaderRect.bottom >= viewportBottom - 2,
);
const availableBottom = (headerIsAtBottom ? appHeaderRect?.top : viewportBottom) || viewportBottom;
const scrollbarSpace = 12;
lanes.forEach((lane) => {
const laneTop = lane.getBoundingClientRect().top;
// Ячейка подколонки находится внутри обёртки с нижним padding и border.
// Учитываем их, чтобы низ группы совпадал с низом обычной колонки.
const nestedGroupSpacing = lane.closest(".tasks-board-column-group") ? 9 : 0;
const nextHeight = Math.max(
160,
Math.floor(availableBottom - laneTop - scrollbarSpace - nestedGroupSpacing),
);
const value = `${nextHeight}px`;
if (lane.style.getPropertyValue("--tasks-mobile-lane-height") !== value) {
lane.style.setProperty("--tasks-mobile-lane-height", value);
}
});
};
const updateViewportHeight = () => {
const nextHeight = container.clientHeight;
setViewportHeight((currentHeight) => (
@ -142,23 +178,36 @@ export function useTaskBoardScroll({
};
const handleResize = () => {
updateMobileLaneHeights();
updateViewportHeight();
scheduleContentBottomUpdate();
};
updateMobileLaneHeights();
updateViewportHeight();
updateContentBottomState();
container.addEventListener("scroll", scheduleContentBottomUpdate, { passive: true });
window.addEventListener("resize", handleResize, { passive: true });
window.visualViewport?.addEventListener("resize", handleResize, { passive: true });
window.visualViewport?.addEventListener("scroll", handleResize, { passive: true });
const observer = new ResizeObserver(handleResize);
observer.observe(container);
if (container.parentElement) observer.observe(container.parentElement);
container.querySelectorAll<HTMLElement>(".tasks-board-column-content").forEach((content) => {
observer.observe(content);
});
const mutationObserver = new MutationObserver(handleResize);
mutationObserver.observe(container, { childList: true, subtree: true });
return () => {
container.removeEventListener("scroll", scheduleContentBottomUpdate);
window.removeEventListener("resize", handleResize);
window.visualViewport?.removeEventListener("resize", handleResize);
window.visualViewport?.removeEventListener("scroll", handleResize);
observer.disconnect();
mutationObserver.disconnect();
if (scrollFrameId !== null) {
window.cancelAnimationFrame(scrollFrameId);
}