Merge branch 'develop'
This commit is contained in:
commit
436e39d065
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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: (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user