import React, { useMemo, useCallback, useState } from 'react'; // @ts-ignore: Heroicons module types import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon, } from '@heroicons/react/24/outline'; import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from '@/shared/antd-imports'; import GroupProgressBar from './GroupProgressBar'; import { useTranslation } from 'react-i18next'; import { getContrastColor } from '@/utils/colorUtils'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { selectSelectedTaskIds, selectTask, deselectTask, } from '@/features/task-management/selection.slice'; import { selectGroups, fetchTasksV3, selectAllTasksArray, } from '@/features/task-management/task-management.slice'; import { selectCurrentGrouping } from '@/features/task-management/grouping.slice'; import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice'; import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; import { useAuthService } from '@/hooks/useAuth'; import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types'; import { ITaskPhase } from '@/types/tasks/taskPhase.types'; import logger from '@/utils/errorLogger'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events'; interface TaskGroupHeaderProps { group: { id: string; name: string; count: number; color?: string; // Color for the group indicator todo_progress?: number; doing_progress?: number; done_progress?: number; groupType?: string; }; isCollapsed: boolean; onToggle: () => void; projectId: string; } const TaskGroupHeader: React.FC = ({ group, isCollapsed, onToggle, projectId, }) => { const { t } = useTranslation('task-management'); const dispatch = useAppDispatch(); const selectedTaskIds = useAppSelector(selectSelectedTaskIds); const groups = useAppSelector(selectGroups); const allTasks = useAppSelector(selectAllTasksArray); const currentGrouping = useAppSelector(selectCurrentGrouping); const { statusCategories, status: statusList } = useAppSelector(state => state.taskStatusReducer); const { trackMixpanelEvent } = useMixpanelTracking(); const { isOwnerOrAdmin } = useAuthService(); const [dropdownVisible, setDropdownVisible] = useState(false); const [isRenaming, setIsRenaming] = useState(false); const [isChangingCategory, setIsChangingCategory] = useState(false); const [isEditingName, setIsEditingName] = useState(false); const [editingName, setEditingName] = useState(group.name); const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color const headerTextColor = getContrastColor(headerBackgroundColor); // Get tasks in this group const currentGroup = useMemo(() => { return groups.find(g => g.id === group.id); }, [groups, group.id]); const tasksInGroup = useMemo(() => { return currentGroup?.taskIds || []; }, [currentGroup]); // Calculate group progress values dynamically const groupProgressValues = useMemo(() => { if (!currentGroup || !allTasks.length) { return { todoProgress: 0, doingProgress: 0, doneProgress: 0 }; } const tasksInCurrentGroup = currentGroup.taskIds .map(taskId => allTasks.find(task => task.id === taskId)) .filter(task => task !== undefined); if (tasksInCurrentGroup.length === 0) { return { todoProgress: 0, doingProgress: 0, doneProgress: 0 }; } // If we're grouping by status, show progress based on task completion if (currentGrouping === 'status') { // For status grouping, calculate based on task progress values const progressStats = tasksInCurrentGroup.reduce( (acc, task) => { const progress = task.progress || 0; if (progress === 0) { acc.todo += 1; } else if (progress === 100) { acc.done += 1; } else { acc.doing += 1; } return acc; }, { todo: 0, doing: 0, done: 0 } ); const totalTasks = tasksInCurrentGroup.length; return { todoProgress: totalTasks > 0 ? Math.round((progressStats.todo / totalTasks) * 100) || 0 : 0, doingProgress: totalTasks > 0 ? Math.round((progressStats.doing / totalTasks) * 100) || 0 : 0, doneProgress: totalTasks > 0 ? Math.round((progressStats.done / totalTasks) * 100) || 0 : 0, }; } else { // For priority/phase grouping, show progress based on status distribution // Use a simplified approach based on status names and common patterns const statusCounts = tasksInCurrentGroup.reduce( (acc, task) => { // Find the status by ID first const statusInfo = statusList.find(s => s.id === task.status); const statusName = statusInfo?.name?.toLowerCase() || task.status?.toLowerCase() || ''; // Categorize based on common status name patterns if ( statusName.includes('todo') || statusName.includes('to do') || statusName.includes('pending') || statusName.includes('open') || statusName.includes('backlog') ) { acc.todo += 1; } else if ( statusName.includes('doing') || statusName.includes('progress') || statusName.includes('active') || statusName.includes('working') || statusName.includes('development') ) { acc.doing += 1; } else if ( statusName.includes('done') || statusName.includes('completed') || statusName.includes('finished') || statusName.includes('closed') || statusName.includes('resolved') ) { acc.done += 1; } else { // Default unknown statuses to "doing" (in progress) acc.doing += 1; } return acc; }, { todo: 0, doing: 0, done: 0 } ); const totalTasks = tasksInCurrentGroup.length; return { todoProgress: totalTasks > 0 ? Math.round((statusCounts.todo / totalTasks) * 100) || 0 : 0, doingProgress: totalTasks > 0 ? Math.round((statusCounts.doing / totalTasks) * 100) || 0 : 0, doneProgress: totalTasks > 0 ? Math.round((statusCounts.done / totalTasks) * 100) || 0 : 0, }; } }, [currentGroup, allTasks, statusList, currentGrouping]); // Calculate selection state for this group const { isAllSelected, isPartiallySelected } = useMemo(() => { if (tasksInGroup.length === 0) { return { isAllSelected: false, isPartiallySelected: false }; } const selectedTasksInGroup = tasksInGroup.filter(taskId => selectedTaskIds.includes(taskId)); const allSelected = selectedTasksInGroup.length === tasksInGroup.length; const partiallySelected = selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < tasksInGroup.length; return { isAllSelected: allSelected, isPartiallySelected: partiallySelected }; }, [tasksInGroup, selectedTaskIds]); // Handle select all checkbox change const handleSelectAllChange = useCallback( (e: any) => { e.stopPropagation(); if (isAllSelected) { // Deselect all tasks in this group tasksInGroup.forEach(taskId => { dispatch(deselectTask(taskId)); }); } else { // Select all tasks in this group tasksInGroup.forEach(taskId => { dispatch(selectTask(taskId)); }); } }, [dispatch, isAllSelected, tasksInGroup] ); // Handle inline name editing const handleNameSave = useCallback(async () => { // If no changes or already renaming, just exit editing mode if (!editingName.trim() || editingName.trim() === group.name || isRenaming) { setIsEditingName(false); setEditingName(group.name); return; } setIsRenaming(true); try { if (currentGrouping === 'status') { // Extract status ID from group ID (format: "status-{statusId}") const statusId = group.id.replace('status-', ''); const body: ITaskStatusUpdateModel = { name: editingName.trim(), project_id: projectId, }; await statusApiService.updateNameOfStatus(statusId, body, projectId); trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Status' }); dispatch(fetchStatuses(projectId)); } else if (currentGrouping === 'phase') { // Extract phase ID from group ID (format: "phase-{phaseId}") const phaseId = group.id.replace('phase-', ''); const body = { id: phaseId, name: editingName.trim() }; await phasesApiService.updateNameOfPhase(phaseId, body as ITaskPhase, projectId); trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' }); dispatch(fetchPhasesByProjectId(projectId)); } // Refresh task list to get updated group names dispatch(fetchTasksV3(projectId)); } catch (error) { logger.error('Error renaming group:', error); setEditingName(group.name); } finally { setIsEditingName(false); setIsRenaming(false); } }, [ editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming, ]); const handleNameClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); if (!isOwnerOrAdmin) return; setIsEditingName(true); setEditingName(group.name); }, [group.name, isOwnerOrAdmin] ); const handleNameKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter') { handleNameSave(); } else if (e.key === 'Escape') { setIsEditingName(false); setEditingName(group.name); } e.stopPropagation(); }, [group.name, handleNameSave] ); const handleNameBlur = useCallback(() => { handleNameSave(); }, [handleNameSave]); // Handle dropdown menu actions const handleRenameGroup = useCallback(() => { setDropdownVisible(false); setIsEditingName(true); setEditingName(group.name); }, [group.name]); // Handle category change const handleCategoryChange = useCallback( async (categoryId: string, e?: React.MouseEvent) => { e?.stopPropagation(); if (isChangingCategory) return; setIsChangingCategory(true); try { // Extract status ID from group ID (format: "status-{statusId}") const statusId = group.id.replace('status-', ''); await statusApiService.updateStatusCategory(statusId, categoryId, projectId); trackMixpanelEvent(evt_project_board_column_setting_click, { 'Change category': 'Status' }); // Refresh status list and tasks dispatch(fetchStatuses(projectId)); dispatch(fetchTasksV3(projectId)); } catch (error) { logger.error('Error changing category:', error); } finally { setIsChangingCategory(false); } }, [group.id, projectId, dispatch, trackMixpanelEvent, isChangingCategory] ); // Create dropdown menu items const menuItems = useMemo(() => { if (!isOwnerOrAdmin) return []; const items = [ { key: 'rename', icon: , label: currentGrouping === 'status' ? t('renameStatus') : currentGrouping === 'phase' ? t('renamePhase') : t('renameGroup'), onClick: (e: any) => { e?.domEvent?.stopPropagation(); handleRenameGroup(); }, }, ]; // Only show "Change Category" when grouped by status if (currentGrouping === 'status') { const categorySubMenuItems = statusCategories.map(category => ({ key: `category-${category.id}`, label: (
{category.name}
), onClick: (e: any) => { e?.domEvent?.stopPropagation(); handleCategoryChange(category.id || '', e?.domEvent); }, })); items.push({ key: 'changeCategory', icon: , label: t('changeCategory'), children: categorySubMenuItems, } as any); } return items; }, [ currentGrouping, handleRenameGroup, handleCategoryChange, isOwnerOrAdmin, statusCategories, t, ]); return (
{/* Drag Handle Space - ultra minimal width */}
{/* Chevron button */}
{/* Select All Checkbox Space - ultra minimal width */}
e.stopPropagation()} style={{ color: headerTextColor, }} />
{/* Group indicator and name - no gap at all */}
{/* Group name and count */}
{isEditingName ? ( setEditingName(e.target.value)} onKeyDown={handleNameKeyDown} onBlur={handleNameBlur} autoFocus size="small" className="text-sm font-semibold" style={{ width: 'auto', minWidth: '100px', backgroundColor: 'rgba(255, 255, 255, 0.1)', color: headerTextColor, border: '1px solid rgba(255, 255, 255, 0.3)', }} onClick={e => e.stopPropagation()} /> ) : ( {group.name} )} ({group.count})
{/* Three-dot menu - only show for status and phase grouping */} {menuItems.length > 0 && (currentGrouping === 'status' || currentGrouping === 'phase') && (
)}
{/* Progress Bar - sticky to the right edge during horizontal scroll */} {(currentGrouping === 'priority' || currentGrouping === 'phase') && !( groupProgressValues.todoProgress === 0 && groupProgressValues.doingProgress === 0 && groupProgressValues.doneProgress === 0 ) && (
)}
); }; export default TaskGroupHeader;