import { memo, useCallback, useEffect, useMemo, useRef, 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 { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { CheckCircle2, ChevronDown, ClipboardList, Columns3, Eye, LayoutGrid, PlayCircle, Plus, ShieldCheck, Trash2, } from 'lucide-react'; import { KanbanColumn } from './KanbanColumn'; import { KanbanFilterPopover } from './KanbanFilterPopover'; import { KanbanGridLayout, SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH, } 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.22)', bodyBg: 'rgba(59, 130, 246, 0.05)', icon: , }, in_progress: { headerBg: 'rgba(234, 179, 8, 0.24)', bodyBg: 'rgba(234, 179, 8, 0.06)', icon: , }, done: { headerBg: 'rgba(34, 197, 94, 0.22)', bodyBg: 'rgba(34, 197, 94, 0.05)', icon: , }, review: { headerBg: 'rgba(139, 92, 246, 0.22)', bodyBg: 'rgba(139, 92, 246, 0.05)', icon: , }, approved: { headerBg: 'rgba(34, 197, 94, 0.34)', bodyBg: 'rgba(34, 197, 94, 0.08)', icon: , }, }; interface KanbanBoardProps { tasks: TeamTask[]; teamName: string; kanbanState: KanbanState; filter: KanbanFilterState; sort: KanbanSortState; sessions: Session[]; leadSessionId?: string; members: ResolvedTeamMember[]; /** Shows all cards when another UI flow, such as search, must not hide matches. */ forceShowAllTasks?: boolean; 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 SCROLLABLE_OVERFLOW_VALUES = new Set(['auto', 'scroll', 'overlay']); const INITIAL_VISIBLE_TASKS_PER_COLUMN = 20; const LOAD_MORE_TASKS_PER_COLUMN = 20; 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 columnSupportsAddButton( columnId: KanbanColumnId, onAddTask?: (startImmediately: boolean) => void ): boolean { return Boolean(onAddTask && (columnId === 'todo' || columnId === 'in_progress')); } function estimateGridSkeletonCardHeight( task: TeamTask, columnId: KanbanColumnId, kanbanState: KanbanState, hasReviewers: boolean ): number { let height = 122; if (task.subject.length > 54) height += 10; if (task.subject.length > 92) height += 8; if (task.needsClarification) height += 16; if (task.reviewState === 'needsFix') height += 14; if ((task.blockedBy?.length ?? 0) > 0) height += 18; if ((task.blocks?.length ?? 0) > 0) height += 18; const effectiveReviewer = (kanbanState.tasks[task.id]?.reviewer ?? '').trim(); if (columnId === 'review' && !hasReviewers && effectiveReviewer.length === 0) { height += 14; } return Math.min(Math.max(height, 116), 196); } /** Сортирует задачи колонки по сохранённому порядку; задачи без порядка — в конце. */ 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; memberColorMap: Map; 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, memberColorMap, 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} memberColorMap={memberColorMap} 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 = memo(function KanbanBoard({ tasks, teamName, kanbanState, filter, sort, sessions, leadSessionId, members, forceShowAllTasks = false, onFilterChange, onSortChange, onRequestReview, onApprove, onRequestChanges, onMoveBackToDone, onStartTask, onCompleteTask, onCancelTask, onScrollToTask, onTaskClick, onViewChanges, onColumnOrderChange, toolbarLeft, onAddTask, onDeleteTask, deletedTaskCount, onOpenTrash, }: KanbanBoardProps): React.JSX.Element { const boardRef = useRef(null); const scrollRestoreTimeoutsRef = useRef([]); const [viewMode, setViewMode] = useState('grid'); const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); const [visibleTaskLimitsByColumn, setVisibleTaskLimitsByColumn] = useState< Partial> >({}); const hasReviewers = kanbanState.reviewers.length > 0; const enableTaskSorting = viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; const shouldLimitTaskCards = !forceShowAllTasks && !enableTaskSorting; const stableTaskMapRef = useRef<{ signatures: string[]; map: Map; } | null>(null); const taskMap = useMemo(() => { const signatures = tasks.map( (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` ); const previous = stableTaskMapRef.current; if ( previous?.signatures.length === signatures.length && previous.signatures.every((signature, index) => signature === signatures[index]) ) { return previous.map; } const next = new Map(tasks.map((task) => [task.id, task])); stableTaskMapRef.current = { signatures, map: next }; return next; }, [tasks]); const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]); 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 filterOwnerKey = useMemo( () => Array.from(filter.selectedOwners) .sort((a, b) => a.localeCompare(b)) .join('\0'), [filter.selectedOwners] ); const filterColumnKey = useMemo( () => Array.from(filter.columns) .sort((a, b) => a.localeCompare(b)) .join('\0'), [filter.columns] ); useEffect(() => { setVisibleTaskLimitsByColumn({}); }, [teamName, viewMode, sort.field, filter.sessionId, filterOwnerKey, filterColumnKey]); const getVisibleTaskLimit = useCallback( (columnId: KanbanColumnId) => visibleTaskLimitsByColumn[columnId] ?? INITIAL_VISIBLE_TASKS_PER_COLUMN, [visibleTaskLimitsByColumn] ); const revealNextTasks = useCallback((columnId: KanbanColumnId, totalTasks: number) => { setVisibleTaskLimitsByColumn((prev) => { const current = prev[columnId] ?? INITIAL_VISIBLE_TASKS_PER_COLUMN; return { ...prev, [columnId]: Math.min(current + LOAD_MORE_TASKS_PER_COLUMN, totalTasks), }; }); }, []); const renderableColumnTasks = useCallback( (columnId: KanbanColumnId, columnTasks: TeamTask[]) => { if (!shouldLimitTaskCards) { return columnTasks; } return columnTasks.slice(0, getVisibleTaskLimit(columnId)); }, [getVisibleTaskLimit, shouldLimitTaskCards] ); const handleScrollToTask = useCallback( (taskId: string) => { if (!onScrollToTask) { return; } if (!shouldLimitTaskCards) { onScrollToTask(taskId); return; } for (const column of COLUMNS) { const columnTasks = groupedOrdered.get(column.id) ?? []; const taskIndex = columnTasks.findIndex((task) => task.id === taskId); if (taskIndex === -1) { continue; } const currentLimit = getVisibleTaskLimit(column.id); if (taskIndex < currentLimit) { onScrollToTask(taskId); return; } setVisibleTaskLimitsByColumn((prev) => ({ ...prev, [column.id]: Math.max(prev[column.id] ?? INITIAL_VISIBLE_TASKS_PER_COLUMN, taskIndex + 1), })); window.requestAnimationFrame(() => { window.requestAnimationFrame(() => onScrollToTask(taskId)); }); return; } onScrollToTask(taskId); }, [getVisibleTaskLimit, groupedOrdered, onScrollToTask, shouldLimitTaskCards] ); 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 = useCallback( (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
) ); } const visibleTasks = renderableColumnTasks(columnId, columnTasks); const hiddenTaskCount = Math.max(0, columnTasks.length - visibleTasks.length); const nextRevealCount = Math.min(LOAD_MORE_TASKS_PER_COLUMN, hiddenTaskCount); const showMoreButton = hiddenTaskCount > 0 ? ( ) : null; if (enableTaskSorting) { const itemIds = visibleTasks.map((t) => t.id); return ( <> {visibleTasks.map((task) => ( ))} {showMoreButton} {addButton} ); } return ( <> {visibleTasks.map((task) => ( ))} {showMoreButton} {addButton} ); }, [ enableTaskSorting, handleScrollToTask, hasReviewers, kanbanState, memberColorMap, onAddTask, onApprove, onCancelTask, onCompleteTask, onDeleteTask, onMoveBackToDone, onRequestChanges, onRequestReview, onStartTask, onTaskClick, onViewChanges, renderableColumnTasks, revealNextTasks, taskMap, teamName, ] ); const visibleColumns = useMemo( () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), [filter.columns] ); const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); const { widths: columnWidths, getHandleProps } = useResizableColumns({ storageKey: teamName, columnIds: resizableColumnIds, }); const columnModeSearchWidth = primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; const toolbarLeftWidth = viewMode === 'grid' ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) : columnModeSearchWidth; const clearScheduledScrollRestore = useCallback(() => { for (const timeoutId of scrollRestoreTimeoutsRef.current) { window.clearTimeout(timeoutId); } scrollRestoreTimeoutsRef.current = []; }, []); useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { let current = startNode?.parentElement ?? null; while (current) { const { overflowY } = window.getComputedStyle(current); if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { return current; } current = current.parentElement; } return null; }, []); const scheduleScrollRestore = useCallback( (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { const container = findScrollContainer(boardRef.current); if (!container) { return; } const savedScrollTop = container.scrollTop; clearScheduledScrollRestore(); const restore = (): void => { container.scrollTop = savedScrollTop; }; const delays = nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); }, [clearScheduledScrollRestore, findScrollContainer] ); const switchViewMode = useCallback( (nextViewMode: KanbanViewMode) => { const nextSkeletonDelayMs = nextViewMode === 'grid' && viewMode === 'columns' ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH : SKELETON_HIDE_DELAY_MS; setGridSkeletonDelayMs(nextSkeletonDelayMs); scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); setViewMode(nextViewMode); }, [scheduleScrollRestore, viewMode] ); const gridColumns = useMemo( () => 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), showAddButton: columnSupportsAddButton(column.id, onAddTask), skeletonCards: renderableColumnTasks(column.id, columnTasks).map((task) => ({ key: task.id, height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers), })), }; }), [ visibleColumns, groupedOrdered, renderCards, onAddTask, renderableColumnTasks, kanbanState, hasReviewers, ] ); const boardContent = (
{toolbarLeft != null && (
{toolbarLeft}
)}
{deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( Trash ) : null}
Grid view Columns view
{viewMode === 'grid' ? ( column.id)} primaryColumnId={primaryVisibleColumnId} onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} skeletonDelayMs={gridSkeletonDelayMs} columns={gridColumns} /> ) : (
{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; });