import { useCallback, useMemo, useState } from 'react'; import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { arrayMove } from '@dnd-kit/sortable'; import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useResizableColumns } from '@renderer/hooks/useResizableColumns'; import { cn } from '@renderer/lib/utils'; import { CheckCircle2, ClipboardList, Columns3, Eye, LayoutGrid, PlayCircle, Plus, ShieldCheck, Trash2, } from 'lucide-react'; import { KanbanColumn } from './KanbanColumn'; import { KanbanFilterPopover } from './KanbanFilterPopover'; import { KanbanGridLayout } from './KanbanGridLayout'; import { KanbanSortPopover } from './KanbanSortPopover'; import { KanbanTaskCard } from './KanbanTaskCard'; import type { KanbanFilterState } from './KanbanFilterPopover'; import type { KanbanSortField, KanbanSortState } from './KanbanSortPopover'; import type { DragEndEvent } from '@dnd-kit/core'; import type { Session } from '@renderer/types/data'; import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types'; const COLUMN_ACCENTS: Record< KanbanColumnId, { headerBg: string; bodyBg: string; icon: React.ReactNode } > = { todo: { headerBg: 'rgba(59, 130, 246, 0.12)', bodyBg: 'rgba(59, 130, 246, 0.05)', icon: , }, in_progress: { headerBg: 'rgba(234, 179, 8, 0.14)', bodyBg: 'rgba(234, 179, 8, 0.06)', icon: , }, done: { headerBg: 'rgba(34, 197, 94, 0.12)', bodyBg: 'rgba(34, 197, 94, 0.05)', icon: , }, review: { headerBg: 'rgba(139, 92, 246, 0.12)', bodyBg: 'rgba(139, 92, 246, 0.05)', icon: , }, approved: { headerBg: 'rgba(34, 197, 94, 0.24)', bodyBg: 'rgba(34, 197, 94, 0.11)', icon: , }, }; interface KanbanBoardProps { tasks: TeamTask[]; teamName: string; kanbanState: KanbanState; filter: KanbanFilterState; sort: KanbanSortState; sessions: Session[]; leadSessionId?: string; members: ResolvedTeamMember[]; onFilterChange: (filter: KanbanFilterState) => void; onSortChange: (sort: KanbanSortState) => void; onRequestReview: (taskId: string) => void; onApprove: (taskId: string) => void; onRequestChanges: (taskId: string) => void; onMoveBackToDone: (taskId: string) => void; onStartTask: (taskId: string) => void; onCompleteTask: (taskId: string) => void; onCancelTask: (taskId: string) => void; onScrollToTask?: (taskId: string) => void; onTaskClick?: (task: TeamTask) => void; /** Открывает diff-просмотр изменений задачи. */ onViewChanges?: (taskId: string) => void; /** Вызывается после изменения порядка задач в колонке (drag-and-drop). */ onColumnOrderChange?: (columnId: KanbanColumnId, orderedTaskIds: string[]) => void; /** Слот слева в одной строке с фильтром и переключателем вида (например, поле поиска). */ toolbarLeft?: React.ReactNode; /** Opens the create-task dialog with pre-set startImmediately value. */ onAddTask?: (startImmediately: boolean) => void; /** Soft-delete a task. */ onDeleteTask?: (taskId: string) => void; /** Number of soft-deleted tasks (for trash button badge). */ deletedTaskCount?: number; /** Opens the trash dialog. */ onOpenTrash?: () => void; } type KanbanViewMode = 'grid' | 'columns'; const COLUMNS: { id: KanbanColumnId; title: string }[] = [ { id: 'todo', title: 'TODO' }, { id: 'in_progress', title: 'IN PROGRESS' }, { id: 'review', title: 'REVIEW' }, { id: 'done', title: 'DONE' }, { id: 'approved', title: 'APPROVED' }, ]; function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId | null { // Kanban state is authoritative for review/approved placement. // When clearKanban removes a task, the entry is deleted — so we must NOT // fall back to task.reviewState, otherwise the task reappears in approved/review. const kanbanEntry = kanbanState.tasks[task.id]; if (kanbanEntry?.column) { return kanbanEntry.column; } if (task.status === 'pending') { return 'todo'; } if (task.status === 'in_progress') { return 'in_progress'; } if (task.status === 'completed') { return 'done'; } return null; } /** Сортирует задачи колонки по сохранённому порядку; задачи без порядка — в конце. */ function sortColumnTasksByOrder(columnTasks: TeamTask[], order?: string[]): TeamTask[] { if (!order?.length) { return columnTasks; } const byId = new Map(columnTasks.map((t) => [t.id, t])); const ordered: TeamTask[] = []; const seen = new Set(); for (const id of order) { const task = byId.get(id); if (task) { ordered.push(task); seen.add(id); } } for (const task of columnTasks) { if (!seen.has(task.id)) { ordered.push(task); } } return ordered; } /** Сортирует задачи по выбранному полю. */ function sortColumnTasksByField( columnTasks: TeamTask[], field: KanbanSortField, order?: string[] ): TeamTask[] { if (field === 'manual') { return sortColumnTasksByOrder(columnTasks, order); } return [...columnTasks].sort((a, b) => { if (field === 'updatedAt') { const tsA = a.updatedAt ? new Date(a.updatedAt).getTime() : a.createdAt ? new Date(a.createdAt).getTime() : 0; const tsB = b.updatedAt ? new Date(b.updatedAt).getTime() : b.createdAt ? new Date(b.createdAt).getTime() : 0; return tsB - tsA; // desc — свежие вверху } if (field === 'createdAt') { const tsA = a.createdAt ? new Date(a.createdAt).getTime() : 0; const tsB = b.createdAt ? new Date(b.createdAt).getTime() : 0; return tsB - tsA; // desc — новые вверху } if (field === 'owner') { const ownerA = (a.owner ?? '').toLowerCase(); const ownerB = (b.owner ?? '').toLowerCase(); if (!ownerA && !ownerB) return 0; if (!ownerA) return 1; // unassigned — в конец if (!ownerB) return -1; return ownerA.localeCompare(ownerB); } return 0; }); } interface SortableKanbanTaskCardProps { task: TeamTask; columnId: KanbanColumnId; teamName: string; kanbanState: KanbanState; compact?: boolean; taskMap: Map; members: ResolvedTeamMember[]; onRequestReview: (taskId: string) => void; onApprove: (taskId: string) => void; onRequestChanges: (taskId: string) => void; onMoveBackToDone: (taskId: string) => void; onStartTask: (taskId: string) => void; onCompleteTask: (taskId: string) => void; onCancelTask: (taskId: string) => void; onScrollToTask?: (taskId: string) => void; onTaskClick?: (task: TeamTask) => void; onViewChanges?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; } const SortableKanbanTaskCard = ({ task, columnId, teamName, kanbanState, compact, taskMap, members, onRequestReview, onApprove, onRequestChanges, onMoveBackToDone, onStartTask, onCompleteTask, onCancelTask, onScrollToTask, onTaskClick, onViewChanges, onDeleteTask, }: SortableKanbanTaskCardProps): React.JSX.Element => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id, data: { type: 'kanban-task', columnId, taskId: task.id }, }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; return ( // eslint-disable-next-line react/jsx-props-no-spreading -- dnd-kit useSortable requires spreading attributes/listeners
0} compact={compact} taskMap={taskMap} members={members} onRequestReview={onRequestReview} onApprove={onApprove} onRequestChanges={onRequestChanges} onMoveBackToDone={onMoveBackToDone} onStartTask={onStartTask} onCompleteTask={onCompleteTask} onCancelTask={onCancelTask} onScrollToTask={onScrollToTask} onTaskClick={onTaskClick} onViewChanges={onViewChanges} onDeleteTask={onDeleteTask} />
); }; export const KanbanBoard = ({ tasks, teamName, kanbanState, filter, sort, sessions, leadSessionId, members, onFilterChange, onSortChange, onRequestReview, onApprove, onRequestChanges, onMoveBackToDone, onStartTask, onCompleteTask, onCancelTask, onScrollToTask, onTaskClick, onViewChanges, onColumnOrderChange, toolbarLeft, onAddTask, onDeleteTask, deletedTaskCount, onOpenTrash, }: KanbanBoardProps): React.JSX.Element => { const [viewMode, setViewMode] = useState('grid'); const enableTaskSorting = viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]); const grouped = useMemo(() => { const result = new Map( COLUMNS.map(({ id }) => [id, [] as TeamTask[]]) ); for (const task of tasks) { const column = getTaskColumn(task, kanbanState); if (!column) { continue; } result.get(column)?.push(task); } return result; }, [tasks, kanbanState]); const groupedOrdered = useMemo(() => { const result = new Map(); for (const column of COLUMNS) { const columnTasks = grouped.get(column.id) ?? []; const order = kanbanState.columnOrder?.[column.id]; result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); } return result; }, [grouped, kanbanState.columnOrder, sort.field]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 }, }) ); const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; if (!onColumnOrderChange || !over || active.id === over.id) { return; } const activeData = active.data.current; if (activeData?.type !== 'kanban-task') { return; } const columnId = activeData.columnId as KanbanColumnId; const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? []; const oldIndex = orderedIds.indexOf(active.id as string); const newIndex = orderedIds.indexOf(over.id as string); if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { return; } const newOrder = arrayMove(orderedIds, oldIndex, newIndex); onColumnOrderChange(columnId, newOrder); }, [onColumnOrderChange, groupedOrdered] ); const renderCards = ( columnId: KanbanColumnId, columnTasks: TeamTask[], compact?: boolean ): React.JSX.Element => { const addHandler = onAddTask && columnId === 'todo' ? () => onAddTask(false) : onAddTask && columnId === 'in_progress' ? () => onAddTask(true) : undefined; const addButton = addHandler ? ( ) : null; if (columnTasks.length === 0) { return ( addButton ?? (
No tasks
) ); } if (enableTaskSorting) { const itemIds = columnTasks.map((t) => t.id); return ( <> {columnTasks.map((task) => ( ))} {addButton} ); } return ( <> {columnTasks.map((task) => ( 0} compact={compact} taskMap={taskMap} members={members} onRequestReview={onRequestReview} onApprove={onApprove} onRequestChanges={onRequestChanges} onMoveBackToDone={onMoveBackToDone} onStartTask={onStartTask} onCompleteTask={onCompleteTask} onCancelTask={onCancelTask} onScrollToTask={onScrollToTask} onTaskClick={onTaskClick} onViewChanges={onViewChanges} onDeleteTask={onDeleteTask} /> ))} {addButton} ); }; const visibleColumns = useMemo( () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), [filter.columns] ); const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); const { widths: columnWidths, getHandleProps } = useResizableColumns({ storageKey: teamName, columnIds: resizableColumnIds, }); const boardContent = ( <>
{toolbarLeft != null &&
{toolbarLeft}
}
{deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( Trash ) : null}
Grid view Columns view
{viewMode === 'grid' ? ( column.id)} columns={visibleColumns.map((column) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; return { id: column.id, title: column.title, count: columnTasks.length, icon: accent.icon, headerBg: accent.headerBg, bodyBg: accent.bodyBg, content: renderCards(column.id, columnTasks), }; })} /> ) : (
{visibleColumns.map((column, index) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; const width = columnWidths.get(column.id) ?? 256; const handleProps = getHandleProps(column.id); return (
{renderCards(column.id, columnTasks, true)}
{index < visibleColumns.length - 1 ? (
) : null}
); })}
)} ); if (enableTaskSorting) { return ( {boardContent} ); } return boardContent; };