diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 43a2bdf8..88d6db71 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -129,7 +129,9 @@ class TaskSerializer(serializers.ModelSerializer): source="get_priority_display", read_only=True, ) + board_name = serializers.CharField(source="board.name", read_only=True) column_name = serializers.CharField(source="column.name", read_only=True) + column_color = serializers.CharField(source="column.color", read_only=True) linked_messages_count = serializers.SerializerMethodField() linked_events_count = serializers.SerializerMethodField() linked_objects_count = serializers.SerializerMethodField() @@ -139,8 +141,10 @@ class TaskSerializer(serializers.ModelSerializer): fields = [ "id", "board", + "board_name", "column", "column_name", + "column_color", "title", "description", "created_by", @@ -162,7 +166,9 @@ class TaskSerializer(serializers.ModelSerializer): read_only_fields = [ "id", "created_by", + "board_name", "column_name", + "column_color", "priority_display", "completed_at", "linked_messages_count", diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 1f94b056..27b12953 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -378,6 +378,49 @@ class TaskViewSet(viewsets.ModelViewSet): serializer = TaskActivitySerializer(queryset, many=True) return Response(serializer.data) + @action(detail=False, methods=["get"], url_path="linked-event-tasks") + def linked_event_tasks(self, request): + event_id = request.query_params.get("event_id") + try: + event_id = int(event_id) + except (TypeError, ValueError): + return Response( + {"event_id": ["Укажите ID события."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + event = Event.objects.select_related( + "calendar", + "calendar__binding", + "calendar__binding__context_content_type", + "rule", + "creator", + ).get(id=event_id) + except Event.DoesNotExist: + return Response( + {"event_id": ["Событие не найдено."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_calendar_event(request.user, event): + return Response( + {"detail": "Нет доступа к этому событию."}, + status=status.HTTP_403_FORBIDDEN, + ) + + queryset = ( + self.get_queryset() + .filter( + linked_objects__kind=TaskLinkedObjectKind.CALENDAR_EVENT, + linked_objects__content_type=get_calendar_event_content_type(), + linked_objects__object_id=event.id, + ) + .distinct() + ) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + def _message_links_queryset(self, task): return ( TaskLinkedObject.objects.filter( diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index 987fc31b..29c2dde7 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -414,6 +414,22 @@ def test_task_linked_calendar_event_respects_event_access(api_client): assert linked_response.data[0]["can_open"] is False assert linked_response.data[0]["object_url"] is None + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-event-tasks"), + {"event_id": event.id}, + ) + assert reverse_linked_response.status_code == status.HTTP_403_FORBIDDEN + + api_client.force_authenticate(user=owner) + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-event-tasks"), + {"event_id": event.id}, + ) + assert reverse_linked_response.status_code == status.HTTP_200_OK + assert reverse_linked_response.data[0]["id"] == task.id + assert reverse_linked_response.data[0]["title"] == "Подготовить встречу" + assert reverse_linked_response.data[0]["board_name"] == "Доска с событиями" + def test_task_activity_records_core_actions(api_client, user): api_client.force_authenticate(user=user) diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 8c7361cc..279e74f6 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -365,6 +365,7 @@ function BoardColumn({ openMenuTaskId, menuRef, onToggleTaskMenu, + onColumnMount, }: { column: TaskColumn; tasks: TaskCard[]; @@ -375,15 +376,20 @@ function BoardColumn({ openMenuTaskId: number | null; menuRef: RefObject; onToggleTaskMenu: (taskId: number) => void; + onColumnMount: (columnId: number, node: HTMLElement | null) => void; }) { const { setNodeRef, isOver } = useDroppable({ id: `column-${column.id}`, data: { columnId: column.id }, }); + const setColumnNodeRef = useCallback((node: HTMLElement | null) => { + setNodeRef(node); + onColumnMount(column.id, node); + }, [column.id, onColumnMount, setNodeRef]); return (
(null); const [search, setSearch] = useState(""); const [onlyMine, setOnlyMine] = useState(false); - const [columnFilter, setColumnFilter] = useState("all"); + const [selectedColumnTarget, setSelectedColumnTarget] = useState("all"); const [activeTaskId, setActiveTaskId] = useState(null); const [viewTaskId, setViewTaskId] = useState(null); const [taskModalOpen, setTaskModalOpen] = useState(false); @@ -760,6 +766,8 @@ export default function TasksPage() { const cardMenuRef = useRef(null); const selectedBoardIdRef = useRef(null); const taskBoardSyncTimerRef = useRef | null>(null); + const boardScrollRef = useRef(null); + const columnNodeRefs = useRef>(new Map()); const sensors = useSensors( useSensor(PointerSensor, { @@ -816,6 +824,43 @@ export default function TasksPage() { selectedBoardIdRef.current = selectedBoardId; }, [selectedBoardId]); + const registerColumnNode = useCallback((columnId: number, node: HTMLElement | null) => { + if (node) { + columnNodeRefs.current.set(columnId, node); + } else { + columnNodeRefs.current.delete(columnId); + } + }, []); + + const scrollBoardToStart = useCallback(() => { + boardScrollRef.current?.scrollTo({ left: 0, behavior: "smooth" }); + }, []); + + const scrollToColumn = useCallback((columnId: number) => { + const container = boardScrollRef.current; + const node = columnNodeRefs.current.get(columnId); + if (!container || !node) return; + + const containerRect = container.getBoundingClientRect(); + const nodeRect = node.getBoundingClientRect(); + const targetLeft = container.scrollLeft + nodeRect.left - containerRect.left - 12; + + container.scrollTo({ + left: Math.max(0, targetLeft), + behavior: "smooth", + }); + }, []); + + const focusAllColumns = useCallback(() => { + setSelectedColumnTarget("all"); + scrollBoardToStart(); + }, [scrollBoardToStart]); + + const focusColumn = useCallback((columnId: number) => { + setSelectedColumnTarget(columnId); + window.requestAnimationFrame(() => scrollToColumn(columnId)); + }, [scrollToColumn]); + const syncTaskBoardState = useCallback(async (eventBoardId?: number | null) => { const currentBoardId = selectedBoardIdRef.current; try { @@ -846,7 +891,7 @@ export default function TasksPage() { } setSearch(""); setOnlyMine(false); - setColumnFilter("all"); + setSelectedColumnTarget("all"); return; } @@ -911,7 +956,7 @@ export default function TasksPage() { (boardData.tasks || []).some((task: TaskCard) => task.id === requestedTaskId) ) { setViewTaskId(requestedTaskId); - setColumnFilter("all"); + setSelectedColumnTarget("all"); setOnlyMine(false); setSearch(""); } @@ -1048,11 +1093,6 @@ export default function TasksPage() { [board?.columns], ); - const visibleColumns = useMemo( - () => activeColumns.filter((column) => columnFilter === "all" || column.id === columnFilter), - [activeColumns, columnFilter], - ); - const columnCounts = useMemo(() => { const counts = new Map(); for (const column of activeColumns) counts.set(column.id, 0); @@ -1084,6 +1124,15 @@ export default function TasksPage() { const linkedObjectsCount = linkedMessages.length + linkedEvents.length; const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount; + useEffect(() => { + if ( + selectedColumnTarget !== "all" && + !activeColumns.some((column) => column.id === selectedColumnTarget) + ) { + setSelectedColumnTarget("all"); + } + }, [activeColumns, selectedColumnTarget]); + useEffect(() => { if (!viewTaskId) { setLinkedMessages([]); @@ -1235,7 +1284,7 @@ export default function TasksPage() { await loadBoard(boardId); setSearch(""); setOnlyMine(false); - setColumnFilter("all"); + setSelectedColumnTarget("all"); setBoardMenuOpen(false); } catch (loadError) { setError(getTaskError(loadError, "Не удалось загрузить доску")); @@ -1273,7 +1322,7 @@ export default function TasksPage() { await loadBoards(); setSearch(""); setOnlyMine(false); - setColumnFilter("all"); + setSelectedColumnTarget("all"); } catch (deleteError) { setError(getTaskError(deleteError, "Не удалось удалить доску")); } finally { @@ -1301,7 +1350,7 @@ export default function TasksPage() { setBoardForm(emptyBoardForm); setSearch(""); setOnlyMine(false); - setColumnFilter("all"); + setSelectedColumnTarget("all"); } catch (saveError) { setError(getTaskError(saveError, "Не удалось создать доску")); } finally { @@ -1663,25 +1712,25 @@ export default function TasksPage() { {activeColumns.map((column) => { - const active = columnFilter === column.id; + const active = selectedColumnTarget === column.id; return ( + + + {linkedTasksLoading ? ( +
+ + Загрузка связанных объектов... +
+ ) : linkedTasksError ? ( +
+ {linkedTasksError} +
+ ) : linkedTasks.length > 0 ? ( + + ) : ( +
+

+ Связанных объектов пока нет +

+

+ Можно связать событие с существующей задачей или создать новую. +

+
+ )} + + + ) : null} + {/* Participants */} {showParticipants && (
@@ -515,6 +677,124 @@ export function ViewEventDetailsModal({ rows={3} /> + +
+ + + {taskLinkDetailsOpen ? ( +
+ + + + +
+ + +
+ +
+ Метки + {(selectedTaskLinkBoard?.labels || []).length > 0 ? ( +
+ {(selectedTaskLinkBoard?.labels || []).map((label) => { + const selected = taskLinkLabelIds.includes(label.id); + return ( + + ); + })} +
+ ) : ( +

На этой доске меток нет

+ )} +
+
+ ) : null} +
)} diff --git a/frontend/src/components/calendar/CalendarCard.tsx b/frontend/src/components/calendar/CalendarCard.tsx index f2017aa8..eb01ed0a 100644 --- a/frontend/src/components/calendar/CalendarCard.tsx +++ b/frontend/src/components/calendar/CalendarCard.tsx @@ -13,6 +13,7 @@ import { type CalendarEventDraft, toCalendarEventDraft, } from "@/services/calendarService"; +import TaskLinkPill from "@/components/tasks/TaskLinkPill"; import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-colors"; import { buildCalendarOptions, @@ -23,6 +24,7 @@ import { toParticipantsTarget, type CalendarParticipantsTarget, } from "@/lib/calendar/ui"; +import type { TaskCard } from "@/types/api"; // Типы interface CalendarCardProps { @@ -42,6 +44,12 @@ const weekdays = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; const sameDate = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +const getLinkedCalendarEventId = (event: CalendarEvent): number | null => { + const rawId = event.is_recurring && event.event_id ? event.event_id : event.id; + const id = typeof rawId === "number" ? rawId : Number(rawId); + return Number.isInteger(id) && id > 0 ? id : null; +}; + /** * Компонент мини-календаря в сайдбаре */ @@ -76,6 +84,7 @@ export const CalendarCard = memo(function CalendarCard({ const [showCalendarMenu, setShowCalendarMenu] = useState(false); const [eventsViewMode, setEventsViewMode] = useState<"week" | "month">("week"); + const [linkedTasksByEventId, setLinkedTasksByEventId] = useState>({}); const fileInputRef = useRef(null); const selectedCalendar = useMemo( @@ -317,6 +326,55 @@ export const CalendarCard = memo(function CalendarCard({ .slice(0, 30); }, [events, monthDate]); + const visibleEvents = useMemo( + () => (eventsViewMode === "week" ? weekEvents : monthEvents), + [eventsViewMode, monthEvents, weekEvents], + ); + + const visibleEventIds = useMemo(() => { + const ids = new Set(); + for (const event of visibleEvents) { + const eventId = getLinkedCalendarEventId(event); + if (eventId) ids.add(eventId); + } + return Array.from(ids); + }, [visibleEvents]); + + useEffect(() => { + const missingEventIds = visibleEventIds.filter((eventId) => linkedTasksByEventId[eventId] === undefined); + if (missingEventIds.length === 0) return undefined; + + let cancelled = false; + + async function loadLinkedTasks() { + const entries = await Promise.all( + missingEventIds.map(async (eventId) => { + try { + const tasks = await apiClient.getCalendarEventLinkedTasks(eventId); + return [eventId, tasks] as const; + } catch { + return [eventId, [] as TaskCard[]] as const; + } + }), + ); + + if (cancelled) return; + setLinkedTasksByEventId((current) => { + const next = { ...current }; + for (const [eventId, tasks] of entries) { + next[eventId] = tasks; + } + return next; + }); + } + + void loadLinkedTasks(); + + return () => { + cancelled = true; + }; + }, [linkedTasksByEventId, visibleEventIds]); + return ( <> {/* Селектор календаря */} @@ -592,16 +650,16 @@ export const CalendarCard = memo(function CalendarCard({ {(() => { - const displayEvents = eventsViewMode === "week" ? weekEvents : monthEvents; - if (loading) return

Загрузка...

; if (error) return

{error}

; - if (displayEvents.length === 0) return

Событий нет

; + if (visibleEvents.length === 0) return

Событий нет

; return (
- {displayEvents.map((event) => { + {visibleEvents.map((event) => { const start = event.start ? new Date(event.start) : null; + const eventId = getLinkedCalendarEventId(event); + const linkedTasks = eventId ? linkedTasksByEventId[eventId] || [] : []; const dateLabel = start && !Number.isNaN(start.getTime()) ? start.toLocaleString("ru-RU", { @@ -614,26 +672,55 @@ export const CalendarCard = memo(function CalendarCard({ : "Без даты"; return ( - + + + {linkedTasks.length > 0 ? ( +
+ {linkedTasks.slice(0, 2).map((task) => ( + + ))} + {linkedTasks.length > 2 ? ( + + +{linkedTasks.length - 2} + + ) : null} +
+ ) : null} +
); })} diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index 8848bddd..147d0b88 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -60,6 +60,8 @@ export function createTasksApi(request: RequestFn) { }), getTaskActivity: (id: number): Promise => request(`/api/v1/tasks/${id}/activity/`), + getCalendarEventLinkedTasks: (eventId: number): Promise => + request(`/api/v1/tasks/linked-event-tasks/?event_id=${eventId}`), getTaskLinkedMessages: (id: number): Promise => request(`/api/v1/tasks/${id}/linked-messages/`), linkTaskMessage: (id: number, messageId: number): Promise => diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 887e450a..825af1eb 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -165,8 +165,10 @@ export interface TaskLabel { export interface TaskCard { id: number; board: number; + board_name?: string; column: number; column_name?: string; + column_color?: string; title: string; description?: string; created_by?: User;