Merge branch 'develop'

This commit is contained in:
lizerk 2026-08-07 20:02:50 +03:00
commit 436e39d065
7 changed files with 419 additions and 229 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

@ -53,8 +53,8 @@ function StandardMessagesList() {
}
return (
<section className="app-surface rounded-2xl p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<section className="app-surface flex h-[calc(100dvh_-_8rem_-_env(safe-area-inset-bottom))] min-h-0 flex-col overflow-hidden rounded-2xl p-4 lg:block lg:h-auto lg:overflow-visible">
<div className="mb-4 flex shrink-0 flex-wrap items-center justify-between gap-2">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Сообщения</p>
<div className="flex items-center gap-1">
<MessagesFocusModeButton enabled={false} onChange={(enabled) => setMode(enabled ? "wide" : "standard")} />
@ -65,9 +65,11 @@ function StandardMessagesList() {
</div>
</div>
<MessagesChatListControls userId={user?.id} chatTypeCounts={chatTypeCounts} />
<div className="shrink-0">
<MessagesChatListControls userId={user?.id} chatTypeCounts={chatTypeCounts} />
</div>
<div className="space-y-2">
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-y-contain pr-1 lg:overflow-visible lg:pr-0">
{visibleChats.length === 0 ? (
<div className="app-surface-muted rounded-xl p-6 text-center">
<p className="app-text-muted text-sm">Чаты не найдены</p>

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,
@ -56,10 +57,10 @@ import {
Rows3,
Search,
ShoppingCart,
Star,
Tag,
Trash2,
UserPlus,
UserCheck,
UserRound,
Workflow,
X,
@ -713,21 +714,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 +742,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 +913,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 +1361,6 @@ function BoardColumn({
linkedColumnHighlight = false,
synchronizedColumnDrag = false,
fillAvailableHeight = false,
hideOnMobile = false,
quickTaskComposer,
}: {
column: TaskColumn;
@ -1386,7 +1390,6 @@ function BoardColumn({
linkedColumnHighlight?: boolean;
synchronizedColumnDrag?: boolean;
fillAvailableHeight?: boolean;
hideOnMobile?: boolean;
quickTaskComposer?: ReactNode;
}) {
const {
@ -1424,9 +1427,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 +1443,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 +1556,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 +1652,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 +1764,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 +1794,6 @@ function BoardColumnGroup({
onToggleContentVisibility,
subcolumnHeaders,
subcolumnGridTemplate,
hideOnMobile = false,
}: {
column: TaskColumn;
tasksCount: number;
@ -1809,7 +1811,6 @@ function BoardColumnGroup({
onToggleContentVisibility: () => void;
subcolumnHeaders: ReactNode;
subcolumnGridTemplate: string;
hideOnMobile?: boolean;
}) {
const {
attributes,
@ -1836,13 +1837,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 +1949,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 +1960,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 +2045,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 +2080,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 +2105,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 +2129,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 +3699,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 +3718,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 +3829,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 +3847,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(() => {
@ -3912,9 +3923,15 @@ function TasksPageContent() {
const loadBoard = useCallback(async (boardId?: number | null) => {
setError(null);
const data = boardId
? await apiClient.getTaskBoard(boardId)
: await apiClient.getDefaultTaskBoard();
if (!boardId) {
setBoard(null);
setSelectedBoardId(null);
boardViewStateRef.current = EMPTY_BOARD_VIEW_STATE;
setBoardViewState(EMPTY_BOARD_VIEW_STATE);
setViewTaskId(null);
return null;
}
const data = await apiClient.getTaskBoard(boardId);
setBoard(data);
setTaskLabels(data.labels || []);
let nextViewState = normalizeBoardViewState(data.view_state);
@ -4288,15 +4305,6 @@ function TasksPageContent() {
const boardList = await loadBoards();
if (!currentBoardId) {
const nextBoard = eventBoardId
? boardList.find((item) => item.id === eventBoardId)
: boardList[0];
if (nextBoard) {
await loadBoard(nextBoard.id);
} else {
await loadBoard(null);
await loadBoards();
}
return;
}
@ -4304,12 +4312,8 @@ function TasksPageContent() {
(item) => item.id === currentBoardId,
);
if (!currentBoardAvailable) {
if (boardList[0]) {
await loadBoard(boardList[0].id);
} else {
await loadBoard(null);
await loadBoards();
}
await loadBoard(null);
router.replace("/tasks", { scroll: false });
setSearch("");
setOnlyMine(false);
setSelectedColumnTarget("all");
@ -4322,7 +4326,7 @@ function TasksPageContent() {
} catch (syncError) {
setError(getTaskError(syncError, "Не удалось синхронизировать доску"));
}
}, [loadBoard, loadBoards]);
}, [loadBoard, loadBoards, router]);
useTaskBoardRealtime({
viewTaskId,
@ -4360,7 +4364,7 @@ function TasksPageContent() {
] = await Promise.all([
requestedBoardId > 0
? apiClient.getTaskBoard(requestedBoardId)
: apiClient.getDefaultTaskBoard(),
: Promise.resolve(null),
apiClient.getTaskBoards({ view: "summary" }),
apiClient.getTaskBoardGroups(),
apiClient.getEmployees({ limit: 200, is_active: true, ordering: "last_name" }),
@ -4375,11 +4379,13 @@ function TasksPageContent() {
setBoards(boardList as TaskBoardSummary[]);
setBoardGroups(groupList as TaskBoardGroup[]);
setBoard(boardData);
const initialViewState = normalizeBoardViewState(boardData.view_state);
const initialViewState = boardData
? normalizeBoardViewState(boardData.view_state)
: EMPTY_BOARD_VIEW_STATE;
boardViewStateRef.current = initialViewState;
setBoardViewState(initialViewState);
setSelectedBoardId(boardData.id);
if (window.matchMedia("(max-width: 1023px)").matches) {
setSelectedBoardId(boardData?.id ?? null);
if (boardData && window.matchMedia("(max-width: 1023px)").matches) {
const firstColumn = (boardData.columns || []).find(
(column: TaskColumn) => !column.is_archived && !column.parent,
);
@ -4389,6 +4395,7 @@ function TasksPageContent() {
setDepartments(departmentsData.results || departmentsData || []);
setTaskLabels(Array.isArray(labelsData) ? labelsData : labelsData.results || []);
if (
boardData &&
requestedTaskId > 0 &&
(boardData.tasks || []).some((task: TaskCard) => task.id === requestedTaskId)
) {
@ -4962,6 +4969,7 @@ function TasksPageContent() {
setParticipantModalOpen(false);
setParticipantModalSearch("");
setParticipantModalSelection([]);
setParticipantModalAssigneeId(null);
setTaskMenuOpen(false);
setTaskAddMenuOpen(false);
setTaskAddPanel(null);
@ -5058,6 +5066,7 @@ function TasksPageContent() {
setParticipantModalOpen(false);
setParticipantModalSearch("");
setParticipantModalSelection([]);
setParticipantModalAssigneeId(null);
}, [saving, taskAttachmentsUploading, taskChecklistSaving]);
const openParticipantModal = useCallback(() => {
@ -5068,6 +5077,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 +5087,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 +5103,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 +5130,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;
@ -5357,35 +5389,6 @@ function TasksPageContent() {
}
}, [board, loadBoard, loadBoards]);
const setDefaultBoard = useCallback(async (
targetBoard: Pick<TaskBoardSummary, "id" | "is_default_for_current_user">,
) => {
if (saving || targetBoard.is_default_for_current_user) return;
setBoardActionsMenuOpen(false);
setSaving(true);
setError(null);
try {
const updatedBoard = await apiClient.setDefaultTaskBoard(targetBoard.id);
setBoard((current) => (
current
? {
...current,
is_default_for_current_user: current.id === updatedBoard.id,
}
: current
));
setBoards((current) => current.map((item) => ({
...item,
is_default_for_current_user: item.id === updatedBoard.id,
})));
} catch (defaultError) {
setError(getTaskError(defaultError, "Не удалось выбрать доску по умолчанию"));
} finally {
setSaving(false);
}
}, [saving]);
const deleteBoard = useCallback(async (
targetBoard: Pick<TaskBoardSummary, "id" | "name">,
) => {
@ -5397,16 +5400,10 @@ function TasksPageContent() {
setError(null);
try {
await apiClient.deleteTaskBoard(targetBoard.id);
const nextBoards = await loadBoards();
await loadBoards();
if (selectedBoardId === targetBoard.id) {
const nextBoard = nextBoards[0] || null;
if (nextBoard) {
router.replace(`/tasks?board=${nextBoard.id}`, { scroll: false });
} else {
setBoard(null);
setSelectedBoardId(null);
router.replace("/tasks", { scroll: false });
}
await loadBoard(null);
router.replace("/tasks", { scroll: false });
setSearch("");
setOnlyMine(false);
setSelectedColumnTarget("all");
@ -5416,7 +5413,7 @@ function TasksPageContent() {
} finally {
setSaving(false);
}
}, [loadBoards, router, saving, selectedBoardId]);
}, [loadBoard, loadBoards, router, saving, selectedBoardId]);
const deleteSelectedBoard = useCallback(() => {
if (!board) return;
@ -7374,17 +7371,18 @@ function TasksPageContent() {
onToggleHiddenGroup={(group) => void toggleBoardGroupHidden(group)}
onEditBoard={openEditBoard}
onDeleteBoard={(targetBoard) => void deleteBoard(targetBoard)}
onSetDefaultBoard={(targetBoard) => void setDefaultBoard(targetBoard)}
onToggleBoardPin={(targetBoard) => void toggleBoardPin(targetBoard)}
onToggleBoardNotifications={(targetBoard) => void toggleBoardNotifications(targetBoard)}
onMoveBoard={setBoardGroupMemberships}
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"} h-[calc(100dvh_-_8rem_-_env(safe-area-inset-bottom))] 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" : ""}`}>
{board ? (
<>
<section className="app-surface relative z-50 shrink-0 overflow-visible rounded-2xl p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
@ -7431,6 +7429,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"
@ -7490,26 +7508,6 @@ function TasksPageContent() {
<Workflow size={14} className="app-text-muted shrink-0" />
<span>Автоматизация</span>
</button>
<button
type="button"
onClick={() => board && void setDefaultBoard(board)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:cursor-default disabled:opacity-70 disabled:hover:bg-transparent"
disabled={saving || Boolean(board?.is_default_for_current_user)}
>
<Star
size={14}
className={`shrink-0 ${
board?.is_default_for_current_user
? "fill-current app-accent-text"
: "app-text-muted"
}`}
/>
<span>
{board?.is_default_for_current_user
? "Доска по умолчанию"
: "Сделать доской по умолчанию"}
</span>
</button>
<button
type="button"
onClick={() => void deleteSelectedBoard()}
@ -7525,10 +7523,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 +7565,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 +7670,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 +7709,6 @@ function TasksPageContent() {
linkedColumnHighlight={activeColumnDragId === column.id}
synchronizedColumnDrag={activeColumnDragId !== null}
fillAvailableHeight={desktopWideMode}
hideOnMobile={hideOnMobile}
quickTaskComposer={renderQuickTaskComposer(column, null)}
/>
);
@ -7807,7 +7825,6 @@ function TasksPageContent() {
onToggleContentVisibility={() => toggleBoardViewState("column", column.id)}
subcolumnHeaders={subcolumnHeaders}
subcolumnGridTemplate={gridTemplateColumns}
hideOnMobile={hideOnMobile}
>
{baseColumns}
{columnRows.length > 0 ? (
@ -7874,10 +7891,75 @@ function TasksPageContent() {
</button>
</TaskCardMenuPopover>
) : null}
</>
) : (
<section className="app-surface-muted flex h-full min-h-0 flex-1 items-center justify-center rounded-xl border border-dashed border-[var(--border-subtle)]">
<div className="max-w-xs text-center">
<Kanban size={28} className="app-text-muted mx-auto mb-3" />
<p className="text-sm font-semibold text-[var(--foreground)]">Выберите доску</p>
<p className="app-text-muted mt-1 text-xs">Доска откроется справа от списка</p>
</div>
</section>
)}
</div>
</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 +9908,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 +9944,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

@ -62,7 +62,6 @@ type TaskBoardNavigatorProps = {
onToggleHiddenGroup: (group: TaskBoardGroup) => void;
onEditBoard: (board: TaskBoardSummary) => void;
onDeleteBoard: (board: TaskBoardSummary) => void;
onSetDefaultBoard: (board: TaskBoardSummary) => void;
onToggleBoardPin: (board: TaskBoardSummary) => void;
onToggleBoardNotifications: (board: TaskBoardSummary) => void;
onMoveBoard: (
@ -102,7 +101,6 @@ function BoardRow({
onToggleMenu,
onEdit,
onDelete,
onSetDefault,
onTogglePin,
onToggleNotifications,
onMove,
@ -118,7 +116,6 @@ function BoardRow({
onToggleMenu: () => void;
onEdit: () => void;
onDelete: () => void;
onSetDefault: () => void;
onTogglePin: () => void;
onToggleNotifications: () => void;
onMove: () => void;
@ -170,9 +167,6 @@ function BoardRow({
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--foreground)]">
{board.name}
</span>
{board.is_default_for_current_user ? (
<Star size={12} className="app-accent-text shrink-0 fill-current" />
) : null}
{board.is_pinned ? (
<Pin size={12} className="app-accent-text shrink-0 fill-current" />
) : null}
@ -244,20 +238,6 @@ function BoardRow({
/>
{board.is_pinned ? "Открепить" : "Закрепить"}
</button>
<button
type="button"
onClick={onSetDefault}
disabled={board.is_default_for_current_user}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:cursor-default disabled:opacity-70 disabled:hover:bg-transparent"
>
<Star
size={14}
className={board.is_default_for_current_user ? "app-accent-text fill-current" : "app-text-muted"}
/>
{board.is_default_for_current_user
? "Доска по умолчанию"
: "Сделать по умолчанию"}
</button>
<button
type="button"
onClick={onToggleNotifications}
@ -310,7 +290,6 @@ export default function TaskBoardNavigator({
onToggleHiddenGroup,
onEditBoard,
onDeleteBoard,
onSetDefaultBoard,
onToggleBoardPin,
onToggleBoardNotifications,
onMoveBoard,
@ -751,7 +730,7 @@ 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 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pb-4 ${compactNavigator ? "px-2 pt-2" : "px-4"}`}>
{visibleBoards.length > 0 ? (
<div className="space-y-1">
{visibleBoards.map((board) => (
@ -776,10 +755,6 @@ export default function TaskBoardNavigator({
onDeleteBoard(board);
setBoardMenuId(null);
}}
onSetDefault={() => {
onSetDefaultBoard(board);
setBoardMenuId(null);
}}
onTogglePin={() => {
onToggleBoardPin(board);
setBoardMenuId(null);

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);
}

View File

@ -43,8 +43,6 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
return {
getTaskBoards: (params?: Record<string, string | number>) =>
request(`/api/v1/tasks/boards/${buildQuery(params)}`),
getDefaultTaskBoard: (): Promise<TaskBoard> =>
request("/api/v1/tasks/boards/default/"),
getTaskBoard: (id: number): Promise<TaskBoard> =>
request(`/api/v1/tasks/boards/${id}/`),
getTaskBoardParticipantCandidates: (id: number): Promise<User[]> =>
@ -85,8 +83,6 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
method: "POST",
body: JSON.stringify({ groups }),
}),
setDefaultTaskBoard: (id: number): Promise<TaskBoard> =>
request(`/api/v1/tasks/boards/${id}/set-default/`, { method: "POST" }),
toggleTaskBoardPin: (id: number): Promise<{ ok: boolean; is_pinned: boolean }> =>
request(`/api/v1/tasks/boards/${id}/pin/`, { method: "POST" }),
toggleTaskBoardNotifications: (