feat: surface task links in calendar events
This commit is contained in:
parent
cb5b520e01
commit
d97b8e765a
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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> =>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user