feat(tasks): improve mobile board interactions
This commit is contained in:
parent
8cad1832cd
commit
69a124a5eb
@ -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")
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user