feat: surface task links in calendar events

This commit is contained in:
lizerk 2026-07-06 14:14:25 +03:00
parent cb5b520e01
commit d97b8e765a
8 changed files with 532 additions and 46 deletions

View File

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

View File

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

View File

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

View File

@ -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<HTMLDivElement | null>;
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 (
<section
ref={setNodeRef}
ref={setColumnNodeRef}
className={`flex max-h-[calc(100vh-14rem)] min-h-[28rem] min-w-[18rem] flex-col rounded-xl border bg-[var(--surface-muted)] transition ${
isOver ? "border-[var(--accent-primary)]" : "border-[var(--border-subtle)]"
}`}
@ -730,7 +736,7 @@ export default function TasksPage() {
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [onlyMine, setOnlyMine] = useState(false);
const [columnFilter, setColumnFilter] = useState<number | "all">("all");
const [selectedColumnTarget, setSelectedColumnTarget] = useState<number | "all">("all");
const [activeTaskId, setActiveTaskId] = useState<number | null>(null);
const [viewTaskId, setViewTaskId] = useState<number | null>(null);
const [taskModalOpen, setTaskModalOpen] = useState(false);
@ -760,6 +766,8 @@ export default function TasksPage() {
const cardMenuRef = useRef<HTMLDivElement | null>(null);
const selectedBoardIdRef = useRef<number | null>(null);
const taskBoardSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const boardScrollRef = useRef<HTMLDivElement | null>(null);
const columnNodeRefs = useRef<Map<number, HTMLElement>>(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<number, number>();
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() {
<button
type="button"
onClick={() => setColumnFilter("all")}
onClick={focusAllColumns}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
columnFilter === "all" ? "app-pill-active" : "app-pill"
selectedColumnTarget === "all" ? "app-pill-active" : "app-pill"
}`}
>
<span>Все</span>
<span className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
columnFilter === "all" ? "app-pill-count-active" : "app-pill-count"
selectedColumnTarget === "all" ? "app-pill-count-active" : "app-pill-count"
}`}>
{filteredTasks.length}
</span>
</button>
{activeColumns.map((column) => {
const active = columnFilter === column.id;
const active = selectedColumnTarget === column.id;
return (
<button
key={column.id}
type="button"
onClick={() => setColumnFilter(column.id)}
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"
}`}
@ -1741,9 +1790,9 @@ export default function TasksPage() {
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<div className="tasks-board-scroll overflow-x-auto pb-3">
<div ref={boardScrollRef} className="tasks-board-scroll overflow-x-auto pb-3">
<div className="flex min-w-max gap-3">
{visibleColumns.map((column) => (
{activeColumns.map((column) => (
<BoardColumn
key={column.id}
column={column}
@ -1755,6 +1804,7 @@ export default function TasksPage() {
openMenuTaskId={cardMenuTaskId}
menuRef={cardMenuRef}
onToggleTaskMenu={toggleCardTaskMenu}
onColumnMount={registerColumnNode}
/>
))}
<AddColumnCard onClick={openCreateColumn} />

View File

@ -1,13 +1,25 @@
"use client";
import { Edit2, Trash2, Clock, Calendar, FileText, Users, Link2, Loader2 } from "lucide-react";
import {
Calendar,
ChevronRight,
Clock,
Edit2,
FileText,
Kanban,
Link2,
Loader2,
Trash2,
Users,
} from "lucide-react";
import { Modal } from "@/components/ui";
import { resolveEventColor } from "@/lib/calendar-event-colors";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
import { useState, useEffect, useCallback } from "react";
import { apiClient } from "@/lib/api";
import type { TaskBoard, TaskCard } from "@/types/api";
import { displayUserName } from "@/lib/shared";
import type { TaskBoard, TaskCard, TaskPriority, User } from "@/types/api";
type EventRuleData = {
frequency?: string;
@ -38,6 +50,13 @@ type EventParticipant = {
distinction?: string;
};
const taskPriorityOptions: { value: TaskPriority; label: string }[] = [
{ value: "low", label: "Низкая" },
{ value: "medium", label: "Средняя" },
{ value: "high", label: "Высокая" },
{ value: "critical", label: "Критическая" },
];
interface ViewEventDetailsModalProps {
isOpen: boolean;
onClose: () => void;
@ -94,13 +113,23 @@ export function ViewEventDetailsModal({
}: ViewEventDetailsModalProps) {
const [participants, setParticipants] = useState<EventParticipant[]>([]);
const [loadingParticipants, setLoadingParticipants] = useState(false);
const [linkedTasks, setLinkedTasks] = useState<TaskCard[]>([]);
const [linkedTasksLoading, setLinkedTasksLoading] = useState(false);
const [linkedTasksError, setLinkedTasksError] = useState<string | null>(null);
const [taskLinkOpen, setTaskLinkOpen] = useState(false);
const [taskLinkBoards, setTaskLinkBoards] = useState<TaskBoard[]>([]);
const [taskLinkEmployees, setTaskLinkEmployees] = useState<User[]>([]);
const [taskLinkBoardId, setTaskLinkBoardId] = useState<number | "">("");
const [taskLinkTaskId, setTaskLinkTaskId] = useState<number | "">("");
const [taskLinkMode, setTaskLinkMode] = useState<"existing" | "create">("existing");
const [taskLinkTitle, setTaskLinkTitle] = useState("");
const [taskLinkDescription, setTaskLinkDescription] = useState("");
const [taskLinkDetailsOpen, setTaskLinkDetailsOpen] = useState(false);
const [taskLinkColumnId, setTaskLinkColumnId] = useState<number | "">("");
const [taskLinkAssigneeId, setTaskLinkAssigneeId] = useState<number | "">("");
const [taskLinkPriority, setTaskLinkPriority] = useState<TaskPriority>("medium");
const [taskLinkDueDate, setTaskLinkDueDate] = useState("");
const [taskLinkLabelIds, setTaskLinkLabelIds] = useState<number[]>([]);
const [taskLinkLoading, setTaskLinkLoading] = useState(false);
const [taskLinkError, setTaskLinkError] = useState<string | null>(null);
@ -119,6 +148,21 @@ export function ViewEventDetailsModal({
}
}, [event?.id]);
const loadLinkedTasks = useCallback(async () => {
if (!event?.id) return;
try {
setLinkedTasksLoading(true);
setLinkedTasksError(null);
const tasks = await apiClient.getCalendarEventLinkedTasks(event.id);
setLinkedTasks(tasks);
} catch (error) {
setLinkedTasksError(error instanceof Error ? error.message : "Не удалось загрузить связанные объекты");
} finally {
setLinkedTasksLoading(false);
}
}, [event?.id]);
useEffect(() => {
if (isOpen && event?.id && showParticipants) {
void loadParticipants();
@ -127,6 +171,15 @@ export function ViewEventDetailsModal({
}
}, [isOpen, event?.id, showParticipants, loadParticipants]);
useEffect(() => {
if (isOpen && event?.id) {
void loadLinkedTasks();
} else {
setLinkedTasks([]);
setLinkedTasksError(null);
}
}, [isOpen, event?.id, loadLinkedTasks]);
if (!isOpen || !event) return null;
const formatDateTime = (dateStr?: string | Date | null) => {
@ -156,7 +209,11 @@ export function ViewEventDetailsModal({
const ruleParams = event.rule_data?.params;
const selectedTaskLinkBoard = taskLinkBoards.find((board) => board.id === taskLinkBoardId) || null;
const selectedTaskLinkTask = (selectedTaskLinkBoard?.tasks || []).find((task) => task.id === taskLinkTaskId) || null;
const selectedTaskLinkColumn = (selectedTaskLinkBoard?.columns || []).find((column) => !column.is_archived) || null;
const selectedTaskLinkColumn = (
(selectedTaskLinkBoard?.columns || []).find((column) => column.id === taskLinkColumnId) ||
(selectedTaskLinkBoard?.columns || []).find((column) => !column.is_archived) ||
null
);
const openTaskLinkModal = async () => {
if (!event?.id) return;
@ -166,13 +223,25 @@ export function ViewEventDetailsModal({
setTaskLinkTitle(event.title || "Задача по событию");
setTaskLinkDescription(event.description || "");
try {
const response = await apiClient.getTaskBoards();
const [response, employeesResponse] = await Promise.all([
apiClient.getTaskBoards(),
apiClient.getEmployees({ limit: 200, is_active: true, ordering: "last_name" }),
]);
const boards = (response.results || response || []) as TaskBoard[];
const employees = (employeesResponse.results || employeesResponse || []) as User[];
setTaskLinkBoards(boards);
setTaskLinkEmployees(employees);
const firstBoardWithTasks = boards.find((board) => (board.tasks || []).length > 0) || boards[0] || null;
const firstColumn = firstBoardWithTasks?.columns?.find((column) => !column.is_archived) || null;
setTaskLinkBoardId(firstBoardWithTasks?.id || "");
setTaskLinkTaskId(firstBoardWithTasks?.tasks?.[0]?.id || "");
setTaskLinkColumnId(firstColumn?.id || "");
setTaskLinkMode(firstBoardWithTasks?.tasks?.length ? "existing" : "create");
setTaskLinkDetailsOpen(false);
setTaskLinkAssigneeId("");
setTaskLinkPriority("medium");
setTaskLinkDueDate("");
setTaskLinkLabelIds([]);
} catch (error) {
setTaskLinkError(error instanceof Error ? error.message : "Не удалось загрузить задачи");
} finally {
@ -189,17 +258,33 @@ export function ViewEventDetailsModal({
setTaskLinkMode("existing");
setTaskLinkTitle("");
setTaskLinkDescription("");
setTaskLinkDetailsOpen(false);
setTaskLinkColumnId("");
setTaskLinkAssigneeId("");
setTaskLinkPriority("medium");
setTaskLinkDueDate("");
setTaskLinkLabelIds([]);
};
const handleTaskLinkBoardChange = (boardId: number | "") => {
setTaskLinkBoardId(boardId);
const nextBoard = taskLinkBoards.find((board) => board.id === boardId) || null;
setTaskLinkTaskId(nextBoard?.tasks?.[0]?.id || "");
setTaskLinkColumnId(nextBoard?.columns?.find((column) => !column.is_archived)?.id || "");
setTaskLinkLabelIds([]);
if (!nextBoard?.tasks?.length) {
setTaskLinkMode("create");
}
};
const toggleTaskLinkLabel = (labelId: number) => {
setTaskLinkLabelIds((current) => (
current.includes(labelId)
? current.filter((id) => id !== labelId)
: [...current, labelId]
));
};
const saveTaskLink = async () => {
if (!event?.id || !selectedTaskLinkBoard) return;
if (taskLinkMode === "existing" && !selectedTaskLinkTask) return;
@ -219,12 +304,16 @@ export function ViewEventDetailsModal({
column: selectedTaskLinkColumn.id,
title: taskLinkTitle.trim(),
description: taskLinkDescription.trim(),
priority: "medium",
assignee_id: taskLinkAssigneeId || null,
priority: taskLinkPriority,
due_date: taskLinkDueDate || null,
label_ids: taskLinkLabelIds,
});
}
if (!taskToLink) return;
await apiClient.linkTaskCalendarEvent(taskToLink.id, event.id);
await loadLinkedTasks();
closeTaskLinkModal();
} catch (error) {
setTaskLinkError(error instanceof Error ? error.message : "Не удалось связать событие с задачей");
@ -348,6 +437,79 @@ export function ViewEventDetailsModal({
</div>
)}
{event.id ? (
<div className="flex items-start gap-2.5">
<Link2 size={18} className="app-text-muted mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<p className="text-sm font-medium text-[var(--foreground)]">
Связанные объекты
</p>
<span className="app-pill-count rounded-full px-2 py-0.5 text-[10px] font-bold">
{linkedTasks.length}
</span>
</div>
<button
type="button"
onClick={() => void openTaskLinkModal()}
className="app-action-ghost shrink-0 rounded-lg px-2 py-1 text-xs font-medium"
>
Добавить
</button>
</div>
{linkedTasksLoading ? (
<div className="app-surface-muted flex items-center gap-2 rounded-lg border border-[var(--border-subtle)] px-3 py-2">
<Loader2 size={14} className="app-text-muted animate-spin" />
<span className="app-text-muted text-xs">Загрузка связанных объектов...</span>
</div>
) : linkedTasksError ? (
<div className="app-feedback-danger rounded-lg px-3 py-2 text-xs">
{linkedTasksError}
</div>
) : linkedTasks.length > 0 ? (
<div className="space-y-2">
{linkedTasks.map((task) => (
<a
key={task.id}
href={`/tasks?board=${task.board}&task=${task.id}`}
className="app-surface-muted group flex min-w-0 items-center gap-3 rounded-lg border border-[var(--border-subtle)] px-3 py-2 transition hover:border-[var(--accent-primary)] hover:bg-[var(--surface-elevated)]"
>
<span className="app-selected flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<Kanban size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="app-card-caption block">Задача</span>
<span className="app-text-wrap block truncate text-sm font-medium text-[var(--foreground)]">
{task.title}
</span>
<span className="app-text-muted mt-0.5 block truncate text-xs">
{task.board_name || `Доска #${task.board}`}
{task.column_name ? ` · ${task.column_name}` : ""}
</span>
</span>
<ChevronRight
size={15}
className="app-text-muted shrink-0 transition-transform group-hover:translate-x-0.5"
/>
</a>
))}
</div>
) : (
<div className="app-surface-muted rounded-lg border border-dashed border-[var(--border-subtle)] px-3 py-3">
<p className="text-xs font-medium text-[var(--foreground)]">
Связанных объектов пока нет
</p>
<p className="app-text-muted mt-1 text-xs">
Можно связать событие с существующей задачей или создать новую.
</p>
</div>
)}
</div>
</div>
) : null}
{/* Participants */}
{showParticipants && (
<div className="flex items-start gap-2.5">
@ -515,6 +677,124 @@ export function ViewEventDetailsModal({
rows={3}
/>
</label>
<div className="rounded-xl border border-[var(--border-subtle)]">
<button
type="button"
onClick={() => setTaskLinkDetailsOpen((current) => !current)}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left"
disabled={taskLinkLoading}
aria-expanded={taskLinkDetailsOpen}
>
<span>
<span className="block text-sm font-medium text-[var(--foreground)]">
Дополнительная информация
</span>
<span className="app-text-muted mt-0.5 block text-xs">
Исполнитель, срочность, срок, колонка и метки
</span>
</span>
<ChevronRight
size={16}
className={`app-text-muted shrink-0 transition-transform ${taskLinkDetailsOpen ? "rotate-90" : ""}`}
/>
</button>
{taskLinkDetailsOpen ? (
<div className="space-y-3 border-t border-[var(--border-subtle)] px-3 py-3">
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Колонка</span>
<select
value={taskLinkColumnId}
onChange={(selectEvent) => setTaskLinkColumnId(Number(selectEvent.target.value) || "")}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading || !selectedTaskLinkBoard}
>
<option value="">Выберите колонку</option>
{(selectedTaskLinkBoard?.columns || [])
.filter((column) => !column.is_archived)
.map((column) => (
<option key={column.id} value={column.id}>
{column.name}
</option>
))}
</select>
</label>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Исполнитель</span>
<select
value={taskLinkAssigneeId}
onChange={(selectEvent) => setTaskLinkAssigneeId(Number(selectEvent.target.value) || "")}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading}
>
<option value="">Не назначен</option>
{taskLinkEmployees.map((employee) => (
<option key={employee.id} value={employee.id}>
{displayUserName(employee)}
</option>
))}
</select>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Срочность</span>
<select
value={taskLinkPriority}
onChange={(selectEvent) => setTaskLinkPriority(selectEvent.target.value as TaskPriority)}
className="app-select w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading}
>
{taskPriorityOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Срок</span>
<input
type="date"
value={taskLinkDueDate}
onChange={(inputEvent) => setTaskLinkDueDate(inputEvent.target.value)}
className="app-input w-full rounded-xl px-3 py-2 text-sm"
disabled={taskLinkLoading}
/>
</label>
</div>
<div>
<span className="app-text-muted mb-2 block text-xs font-medium">Метки</span>
{(selectedTaskLinkBoard?.labels || []).length > 0 ? (
<div className="flex flex-wrap gap-2">
{(selectedTaskLinkBoard?.labels || []).map((label) => {
const selected = taskLinkLabelIds.includes(label.id);
return (
<button
key={label.id}
type="button"
onClick={() => toggleTaskLinkLabel(label.id)}
className={`inline-flex max-w-full items-center rounded-full border px-2.5 py-1 text-xs font-medium transition ${
selected ? "border-transparent text-white" : "border-[var(--border-subtle)] text-[var(--muted-foreground)]"
}`}
style={selected ? { backgroundColor: label.color || "#38bdf8" } : undefined}
disabled={taskLinkLoading}
>
<span className="truncate">{label.name}</span>
</button>
);
})}
</div>
) : (
<p className="app-text-muted text-xs">На этой доске меток нет</p>
)}
</div>
</div>
) : null}
</div>
</div>
)}

View File

@ -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<Record<number, TaskCard[]>>({});
const fileInputRef = useRef<HTMLInputElement>(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<number>();
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({
</div>
{(() => {
const displayEvents = eventsViewMode === "week" ? weekEvents : monthEvents;
if (loading) return <p className="app-text-muted text-xs">Загрузка...</p>;
if (error) return <p className="app-feedback-danger rounded-lg px-3 py-2 text-xs">{error}</p>;
if (displayEvents.length === 0) return <p className="app-text-muted text-xs">Событий нет</p>;
if (visibleEvents.length === 0) return <p className="app-text-muted text-xs">Событий нет</p>;
return (
<div className="space-y-2">
{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 (
<button
<div
key={`${eventsViewMode}-${event.id}-${event.start || ""}`}
type="button"
onClick={() => handleEventClick(event)}
className="w-full rounded-lg bg-[var(--surface-secondary)] px-2.5 py-2 text-left transition hover:bg-[var(--surface-tertiary)]"
className="w-full rounded-lg bg-[var(--surface-secondary)] px-2.5 py-2 transition hover:bg-[var(--surface-tertiary)]"
>
<div className="flex items-center gap-1">
<div
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: resolveEventColor(event.color_event) }}
/>
<p className="truncate text-xs font-medium text-[var(--foreground)]">{event.title}</p>
{(event.is_recurring || event.rule) && (
<span className="app-accent-text shrink-0 text-[10px]" title="Повторяющееся событие">
⟲
</span>
)}
</div>
<p className="app-text-muted mt-0.5 text-[11px]">{dateLabel}</p>
</button>
<button
type="button"
onClick={() => handleEventClick(event)}
className="block w-full text-left"
>
<div className="flex items-center gap-1">
<div
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: resolveEventColor(event.color_event) }}
/>
<p className="truncate text-xs font-medium text-[var(--foreground)]">{event.title}</p>
{(event.is_recurring || event.rule) && (
<span className="app-accent-text shrink-0 text-[10px]" title="Повторяющееся событие">
⟲
</span>
)}
</div>
<p className="app-text-muted mt-0.5 text-[11px]">{dateLabel}</p>
</button>
{linkedTasks.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{linkedTasks.slice(0, 2).map((task) => (
<TaskLinkPill
key={task.id}
task={{
id: task.id,
title: task.title,
board_id: task.board,
board_name: task.board_name || "",
column_color: task.column_color,
priority: task.priority,
priority_display: task.priority_display,
}}
maxTitleClassName="max-w-32"
/>
))}
{linkedTasks.length > 2 ? (
<span className="app-pill inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium">
+{linkedTasks.length - 2}
</span>
) : null}
</div>
) : null}
</div>
);
})}
</div>

View File

@ -60,6 +60,8 @@ export function createTasksApi(request: RequestFn) {
}),
getTaskActivity: (id: number): Promise<TaskActivity[]> =>
request(`/api/v1/tasks/${id}/activity/`),
getCalendarEventLinkedTasks: (eventId: number): Promise<TaskCard[]> =>
request(`/api/v1/tasks/linked-event-tasks/?event_id=${eventId}`),
getTaskLinkedMessages: (id: number): Promise<TaskLinkedMessage[]> =>
request(`/api/v1/tasks/${id}/linked-messages/`),
linkTaskMessage: (id: number, messageId: number): Promise<TaskLinkedMessage> =>

View File

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