feat(task-management): implement customizable task list fields and configuration modal
- Added a new slice for managing task list fields, allowing users to toggle visibility and order of fields in the task list. - Introduced a ColumnConfigurationModal for users to configure which fields appear in the dropdown and their order. - Updated ShowFieldsFilterDropdown to integrate the new configuration modal and manage field visibility. - Enhanced task management components to utilize the new field visibility settings, improving the overall user experience and customization options.
This commit is contained in:
@@ -83,6 +83,7 @@ import homePageApiService from '@/api/home-page/home-page.api.service';
|
|||||||
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||||
|
|
||||||
import projectViewReducer from '@features/project/project-view-slice';
|
import projectViewReducer from '@features/project/project-view-slice';
|
||||||
|
import taskManagementFields from '@features/task-management/taskListFields.slice';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
middleware: getDefaultMiddleware =>
|
middleware: getDefaultMiddleware =>
|
||||||
@@ -171,6 +172,7 @@ export const store = configureStore({
|
|||||||
taskManagement: taskManagementReducer,
|
taskManagement: taskManagementReducer,
|
||||||
grouping: groupingReducer,
|
grouping: groupingReducer,
|
||||||
taskManagementSelection: selectionReducer,
|
taskManagementSelection: selectionReducer,
|
||||||
|
taskManagementFields,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Modal, Checkbox, Button, Flex, Typography, Space, Divider, message } from 'antd';
|
||||||
|
import { SettingOutlined, UpOutlined, DownOutlined } from '@ant-design/icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
// Configuration interface for column visibility
|
||||||
|
interface ColumnConfig {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
showInDropdown: boolean;
|
||||||
|
order: number;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnConfigurationModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
projectId?: string;
|
||||||
|
onSave: (config: ColumnConfig[]) => void;
|
||||||
|
currentConfig: ColumnConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ColumnConfigurationModal: React.FC<ColumnConfigurationModalProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
projectId,
|
||||||
|
onSave,
|
||||||
|
currentConfig,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('task-list-filters');
|
||||||
|
const [config, setConfig] = useState<ColumnConfig[]>(currentConfig);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setConfig(currentConfig);
|
||||||
|
setHasChanges(false);
|
||||||
|
}, [currentConfig, open]);
|
||||||
|
|
||||||
|
const handleToggleColumn = (key: string) => {
|
||||||
|
const newConfig = config.map(col =>
|
||||||
|
col.key === key ? { ...col, showInDropdown: !col.showInDropdown } : col
|
||||||
|
);
|
||||||
|
setConfig(newConfig);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveColumn = (key: string, direction: 'up' | 'down') => {
|
||||||
|
const currentIndex = config.findIndex(col => col.key === key);
|
||||||
|
if (currentIndex === -1) return;
|
||||||
|
|
||||||
|
const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||||
|
if (newIndex < 0 || newIndex >= config.length) return;
|
||||||
|
|
||||||
|
const newConfig = [...config];
|
||||||
|
[newConfig[currentIndex], newConfig[newIndex]] = [newConfig[newIndex], newConfig[currentIndex]];
|
||||||
|
|
||||||
|
// Update order numbers
|
||||||
|
const updatedConfig = newConfig.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
order: index + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setConfig(updatedConfig);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(config);
|
||||||
|
setHasChanges(false);
|
||||||
|
message.success('Column configuration saved successfully');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setConfig(currentConfig);
|
||||||
|
setHasChanges(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedColumns = config.reduce((groups, column) => {
|
||||||
|
const category = column.category || 'other';
|
||||||
|
if (!groups[category]) {
|
||||||
|
groups[category] = [];
|
||||||
|
}
|
||||||
|
groups[category].push(column);
|
||||||
|
return groups;
|
||||||
|
}, {} as Record<string, ColumnConfig[]>);
|
||||||
|
|
||||||
|
const categoryLabels: Record<string, string> = {
|
||||||
|
basic: 'Basic Information',
|
||||||
|
time: 'Time & Estimation',
|
||||||
|
dates: 'Dates',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<SettingOutlined />
|
||||||
|
<span>Configure Show Fields Dropdown</span>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
width={600}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>,
|
||||||
|
<Button key="reset" onClick={handleReset} disabled={!hasChanges}>
|
||||||
|
Reset
|
||||||
|
</Button>,
|
||||||
|
<Button key="save" type="primary" onClick={handleSave} disabled={!hasChanges}>
|
||||||
|
Save Configuration
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
Configure which columns appear in the "Show Fields" dropdown and their order.
|
||||||
|
Use the up/down arrows to reorder columns.
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.entries(groupedColumns).map(([category, columns]) => (
|
||||||
|
<div key={category}>
|
||||||
|
<Divider orientation="left">
|
||||||
|
<Typography.Text strong>{categoryLabels[category] || category}</Typography.Text>
|
||||||
|
</Divider>
|
||||||
|
|
||||||
|
{columns.map((column, index) => (
|
||||||
|
<div
|
||||||
|
key={column.key}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
margin: '4px 0',
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: '6px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
background: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={column.showInDropdown}
|
||||||
|
onChange={() => handleToggleColumn(column.key)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<Typography.Text>{column.label}</Typography.Text>
|
||||||
|
</Checkbox>
|
||||||
|
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: '12px', minWidth: '60px' }}>
|
||||||
|
Order: {column.order}
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<UpOutlined />}
|
||||||
|
onClick={() => moveColumn(column.key, 'up')}
|
||||||
|
disabled={index === 0}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<DownOutlined />}
|
||||||
|
onClick={() => moveColumn(column.key, 'down')}
|
||||||
|
disabled={index === columns.length - 1}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ColumnConfigurationModal;
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { MoreOutlined } from '@ant-design/icons';
|
import { MoreOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Button from 'antd/es/button';
|
import Button from 'antd/es/button';
|
||||||
import Checkbox from 'antd/es/checkbox';
|
import Checkbox from 'antd/es/checkbox';
|
||||||
import Dropdown from 'antd/es/dropdown';
|
import Dropdown from 'antd/es/dropdown';
|
||||||
import Space from 'antd/es/space';
|
import Space from 'antd/es/space';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
@@ -14,20 +15,113 @@ import {
|
|||||||
import { ITaskListColumn } from '@/types/tasks/taskList.types';
|
import { ITaskListColumn } from '@/types/tasks/taskList.types';
|
||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import ColumnConfigurationModal from './column-configuration-modal';
|
||||||
|
|
||||||
|
// Configuration interface for column visibility
|
||||||
|
interface ColumnConfig {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
showInDropdown: boolean;
|
||||||
|
order: number;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default column configuration - this can be customized per project or globally
|
||||||
|
const DEFAULT_COLUMN_CONFIG: ColumnConfig[] = [
|
||||||
|
{ key: 'KEY', label: 'Key', showInDropdown: true, order: 1, category: 'basic' },
|
||||||
|
{ key: 'TASK', label: 'Task', showInDropdown: false, order: 2, category: 'basic' }, // Always visible, not in dropdown
|
||||||
|
{ key: 'DESCRIPTION', label: 'Description', showInDropdown: true, order: 3, category: 'basic' },
|
||||||
|
{ key: 'PROGRESS', label: 'Progress', showInDropdown: true, order: 4, category: 'basic' },
|
||||||
|
{ key: 'ASSIGNEES', label: 'Assignees', showInDropdown: true, order: 5, category: 'basic' },
|
||||||
|
{ key: 'LABELS', label: 'Labels', showInDropdown: true, order: 6, category: 'basic' },
|
||||||
|
{ key: 'PHASE', label: 'Phase', showInDropdown: true, order: 7, category: 'basic' },
|
||||||
|
{ key: 'STATUS', label: 'Status', showInDropdown: true, order: 8, category: 'basic' },
|
||||||
|
{ key: 'PRIORITY', label: 'Priority', showInDropdown: true, order: 9, category: 'basic' },
|
||||||
|
{ key: 'TIME_TRACKING', label: 'Time Tracking', showInDropdown: true, order: 10, category: 'time' },
|
||||||
|
{ key: 'ESTIMATION', label: 'Estimation', showInDropdown: true, order: 11, category: 'time' },
|
||||||
|
{ key: 'START_DATE', label: 'Start Date', showInDropdown: true, order: 12, category: 'dates' },
|
||||||
|
{ key: 'DUE_DATE', label: 'Due Date', showInDropdown: true, order: 13, category: 'dates' },
|
||||||
|
{ key: 'DUE_TIME', label: 'Due Time', showInDropdown: true, order: 14, category: 'dates' },
|
||||||
|
{ key: 'COMPLETED_DATE', label: 'Completed Date', showInDropdown: true, order: 15, category: 'dates' },
|
||||||
|
{ key: 'CREATED_DATE', label: 'Created Date', showInDropdown: true, order: 16, category: 'dates' },
|
||||||
|
{ key: 'LAST_UPDATED', label: 'Last Updated', showInDropdown: true, order: 17, category: 'dates' },
|
||||||
|
{ key: 'REPORTER', label: 'Reporter', showInDropdown: true, order: 18, category: 'basic' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Hook to get column configuration - can be extended to fetch from API or localStorage
|
||||||
|
const useColumnConfig = (projectId?: string): ColumnConfig[] => {
|
||||||
|
// In the future, this could fetch from:
|
||||||
|
// 1. Project-specific settings from API
|
||||||
|
// 2. User preferences from localStorage
|
||||||
|
// 3. Global settings from configuration
|
||||||
|
// 4. Team-level settings
|
||||||
|
|
||||||
|
// For now, return default configuration
|
||||||
|
// You can extend this to load from localStorage or API
|
||||||
|
const storedConfig = localStorage.getItem(`worklenz.column-config.${projectId}`);
|
||||||
|
|
||||||
|
if (storedConfig) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(storedConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse stored column config, using default');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_COLUMN_CONFIG;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook to save column configuration
|
||||||
|
const useSaveColumnConfig = () => {
|
||||||
|
return (projectId: string, config: ColumnConfig[]) => {
|
||||||
|
localStorage.setItem(`worklenz.column-config.${projectId}`, JSON.stringify(config));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const ShowFieldsFilterDropdown = () => {
|
const ShowFieldsFilterDropdown = () => {
|
||||||
const { socket, connected } = useSocket();
|
const { socket } = useSocket();
|
||||||
// localization
|
|
||||||
const { t } = useTranslation('task-list-filters');
|
const { t } = useTranslation('task-list-filters');
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const columnList = useAppSelector(state => state.taskReducer.columns);
|
const columnList = useAppSelector(state => state.taskReducer.columns);
|
||||||
const { projectId, project } = useAppSelector(state => state.projectReducer);
|
const { projectId, project } = useAppSelector(state => state.projectReducer);
|
||||||
|
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||||
|
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(useColumnConfig(projectId || undefined));
|
||||||
|
const saveColumnConfig = useSaveColumnConfig();
|
||||||
|
|
||||||
const visibilityChangableColumnList = columnList.filter(
|
// Update config if projectId changes
|
||||||
column => column.key !== 'selector' && column.key !== 'TASK' && column.key !== 'customColumn'
|
React.useEffect(() => {
|
||||||
);
|
setColumnConfig(useColumnConfig(projectId || undefined));
|
||||||
|
}, [projectId, configModalOpen]);
|
||||||
|
|
||||||
|
// Filter columns based on configuration
|
||||||
|
const visibilityChangableColumnList = columnList.filter(column => {
|
||||||
|
// Always exclude selector and TASK columns from dropdown
|
||||||
|
if (column.key === 'selector' || column.key === 'TASK') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find configuration for this column
|
||||||
|
const config = columnConfig.find(c => c.key === column.key);
|
||||||
|
|
||||||
|
// If no config found, show custom columns by default
|
||||||
|
if (!config) {
|
||||||
|
return column.custom_column;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return based on configuration
|
||||||
|
return config.showInDropdown;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort columns based on configuration order
|
||||||
|
const sortedColumns = visibilityChangableColumnList.sort((a, b) => {
|
||||||
|
const configA = columnConfig.find(c => c.key === a.key);
|
||||||
|
const configB = columnConfig.find(c => c.key === b.key);
|
||||||
|
|
||||||
|
const orderA = configA?.order ?? 999;
|
||||||
|
const orderB = configB?.order ?? 999;
|
||||||
|
|
||||||
|
return orderA - orderB;
|
||||||
|
});
|
||||||
|
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
|
|
||||||
@@ -51,30 +145,68 @@ const ShowFieldsFilterDropdown = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuItems = visibilityChangableColumnList.map(col => ({
|
const handleConfigSave = (newConfig: ColumnConfig[]) => {
|
||||||
key: col.key,
|
setColumnConfig(newConfig);
|
||||||
label: (
|
if (projectId) saveColumnConfig(projectId, newConfig);
|
||||||
<Space>
|
};
|
||||||
<Checkbox checked={col.pinned} onChange={e => handleColumnVisibilityChange(col)}>
|
|
||||||
{col.key === 'PHASE' ? project?.phase_label : ''}
|
const menuItems = [
|
||||||
{col.key !== 'PHASE' &&
|
...sortedColumns.map(col => ({
|
||||||
(col.custom_column
|
key: col.key || '',
|
||||||
? col.name
|
type: 'item' as const,
|
||||||
: t(`${col.key?.replace('_', '').toLowerCase() + 'Text'}`))}
|
label: (
|
||||||
</Checkbox>
|
<Space>
|
||||||
</Space>
|
<Checkbox checked={col.pinned} onChange={e => handleColumnVisibilityChange(col)}>
|
||||||
),
|
{col.key === 'PHASE' ? project?.phase_label : ''}
|
||||||
}));
|
{col.key !== 'PHASE' &&
|
||||||
|
(col.custom_column
|
||||||
|
? col.name
|
||||||
|
: t(`${col.key?.replace('_', '').toLowerCase() + 'Text'}`))}
|
||||||
|
</Checkbox>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
type: 'divider' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'configure',
|
||||||
|
type: 'item' as const,
|
||||||
|
label: (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<SettingOutlined />}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfigModalOpen(true);
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', textAlign: 'left' }}
|
||||||
|
>
|
||||||
|
Configure Fields
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<>
|
||||||
menu={{
|
<Dropdown
|
||||||
items: menuItems,
|
menu={{
|
||||||
style: { maxHeight: '400px', overflowY: 'auto' },
|
items: menuItems,
|
||||||
}}
|
style: { maxHeight: '400px', overflowY: 'auto' },
|
||||||
trigger={['click']}
|
}}
|
||||||
>
|
trigger={['click']}
|
||||||
<Button icon={<MoreOutlined />}>{t('showFieldsText')}</Button>
|
>
|
||||||
</Dropdown>
|
<Button icon={<MoreOutlined />}>{t('showFieldsText')}</Button>
|
||||||
|
</Dropdown>
|
||||||
|
<ColumnConfigurationModal
|
||||||
|
open={configModalOpen}
|
||||||
|
onClose={() => setConfigModalOpen(false)}
|
||||||
|
projectId={projectId || undefined}
|
||||||
|
onSave={handleConfigSave}
|
||||||
|
currentConfig={columnConfig}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
import { createSelector } from '@reduxjs/toolkit';
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
FilterOutlined,
|
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
@@ -28,6 +27,8 @@ import { SocketEvents } from '@/shared/socket-events';
|
|||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
import SingleAvatar from '@components/common/single-avatar/single-avatar';
|
import SingleAvatar from '@components/common/single-avatar/single-avatar';
|
||||||
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
|
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
|
||||||
|
import { Dropdown, Checkbox, Button, Space } from 'antd';
|
||||||
|
import { toggleField, TaskListField } from '@/features/task-management/taskListFields.slice';
|
||||||
|
|
||||||
// Import Redux actions
|
// Import Redux actions
|
||||||
import { fetchTasksV3, setSelectedPriorities } from '@/features/task-management/task-management.slice';
|
import { fetchTasksV3, setSelectedPriorities } from '@/features/task-management/task-management.slice';
|
||||||
@@ -508,6 +509,113 @@ const SearchFilter: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({ themeClasses, isDarkMode }) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const fieldsRaw = useSelector((state: RootState) => state.taskManagementFields);
|
||||||
|
const fields = Array.isArray(fieldsRaw) ? fieldsRaw : [];
|
||||||
|
const sortedFields = [...fields].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const visibleCount = sortedFields.filter(field => field.visible).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
{/* Trigger Button - matching FilterDropdown style */}
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md
|
||||||
|
border transition-all duration-200 ease-in-out
|
||||||
|
${visibleCount > 0
|
||||||
|
? (isDarkMode ? 'bg-blue-600 text-white border-blue-500' : 'bg-blue-50 text-blue-800 border-blue-300 font-semibold')
|
||||||
|
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
|
||||||
|
}
|
||||||
|
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
|
||||||
|
${themeClasses.containerBg === 'bg-gray-800' ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
|
||||||
|
`}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<EyeOutlined className="w-3.5 h-3.5" />
|
||||||
|
<span>Fields</span>
|
||||||
|
{visibleCount > 0 && (
|
||||||
|
<span className="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-blue-500 rounded-full">
|
||||||
|
{visibleCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<DownOutlined
|
||||||
|
className={`w-3.5 h-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Panel - matching FilterDropdown style */}
|
||||||
|
{open && (
|
||||||
|
<div className={`absolute top-full left-0 z-50 mt-1 w-64 ${themeClasses.dropdownBg} rounded-md shadow-lg border ${themeClasses.dropdownBorder}`}>
|
||||||
|
{/* Options List */}
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{sortedFields.length === 0 ? (
|
||||||
|
<div className={`p-2 text-xs text-center ${themeClasses.secondaryText}`}>
|
||||||
|
No fields available
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-0.5">
|
||||||
|
{sortedFields.map((field) => {
|
||||||
|
const isSelected = field.visible;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={field.key}
|
||||||
|
onClick={() => dispatch(toggleField(field.key))}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded
|
||||||
|
transition-colors duration-150 text-left
|
||||||
|
${isSelected
|
||||||
|
? (isDarkMode ? 'bg-blue-600 text-white' : 'bg-blue-50 text-blue-800 font-semibold')
|
||||||
|
: `${themeClasses.optionText} ${themeClasses.optionHover}`
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Checkbox indicator - matching FilterDropdown style */}
|
||||||
|
<div className={`
|
||||||
|
flex items-center justify-center w-3.5 h-3.5 border rounded
|
||||||
|
${isSelected
|
||||||
|
? 'bg-blue-500 border-blue-500 text-white'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
{isSelected && <CheckOutlined className="w-2.5 h-2.5" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label and Count */}
|
||||||
|
<div className="flex-1 flex items-center justify-between">
|
||||||
|
<span className="truncate">{field.label}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Main Component
|
// Main Component
|
||||||
const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||||
position,
|
position,
|
||||||
@@ -673,7 +781,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
|||||||
// TODO: Implement column visibility change
|
// TODO: Implement column visibility change
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${themeClasses.containerBg} border ${themeClasses.containerBorder} rounded-md p-3 shadow-sm ${className}`}>
|
<div className={`${themeClasses.containerBg} border ${themeClasses.containerBorder} rounded-md p-3 shadow-sm ${className}`}>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{/* Left Section - Main Filters */}
|
{/* Left Section - Main Filters */}
|
||||||
@@ -748,14 +856,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show Fields Button (for list view) */}
|
{/* Show Fields Button (for list view) */}
|
||||||
{position === 'list' && (
|
{position === 'list' && <FieldsDropdown themeClasses={themeClasses} isDarkMode={isDarkMode} />}
|
||||||
<button className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText} ${
|
|
||||||
isDarkMode ? 'focus:ring-offset-gray-800' : 'focus:ring-offset-white'
|
|
||||||
}`}>
|
|
||||||
<EyeOutlined className="w-3.5 h-3.5" />
|
|
||||||
<span>Fields</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -779,7 +880,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
|||||||
|
|
||||||
{filterSectionsData
|
{filterSectionsData
|
||||||
.filter(section => section.id !== 'groupBy') // <-- skip groupBy
|
.filter(section => section.id !== 'groupBy') // <-- skip groupBy
|
||||||
.map((section) =>
|
.flatMap((section) =>
|
||||||
section.selectedValues.map((value) => {
|
section.selectedValues.map((value) => {
|
||||||
const option = section.options.find(opt => opt.value === value);
|
const option = section.options.find(opt => opt.value === value);
|
||||||
if (!option) return null;
|
if (!option) return null;
|
||||||
@@ -809,7 +910,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
}).filter(Boolean)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { taskManagementSelectors } from '@/features/task-management/task-managem
|
|||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import TaskRow from './task-row';
|
import TaskRow from './task-row';
|
||||||
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
|
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
|
||||||
|
import { TaskListField } from '@/features/task-management/taskListFields.slice';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -39,22 +40,7 @@ const GROUP_COLORS = {
|
|||||||
default: '#d9d9d9',
|
default: '#d9d9d9',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Column configurations for consistent layout
|
|
||||||
const FIXED_COLUMNS = [
|
|
||||||
{ key: 'drag', label: '', width: 40, fixed: true },
|
|
||||||
{ key: 'select', label: '', width: 40, fixed: true },
|
|
||||||
{ key: 'key', label: 'Key', width: 80, fixed: true },
|
|
||||||
{ key: 'task', label: 'Task', width: 475, fixed: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SCROLLABLE_COLUMNS = [
|
|
||||||
{ key: 'progress', label: 'Progress', width: 90 },
|
|
||||||
{ key: 'members', label: 'Members', width: 150 },
|
|
||||||
{ key: 'labels', label: 'Labels', width: 200 },
|
|
||||||
{ key: 'status', label: 'Status', width: 100 },
|
|
||||||
{ key: 'priority', label: 'Priority', width: 100 },
|
|
||||||
{ key: 'timeTracking', label: 'Time Tracking', width: 120 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
||||||
group,
|
group,
|
||||||
@@ -82,6 +68,54 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
|||||||
// Get theme from Redux store
|
// Get theme from Redux store
|
||||||
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
||||||
|
|
||||||
|
// Get field visibility from taskListFields slice
|
||||||
|
const taskListFields = useSelector((state: RootState) => state.taskManagementFields) as TaskListField[];
|
||||||
|
|
||||||
|
// Define all possible columns
|
||||||
|
const allFixedColumns = [
|
||||||
|
{ key: 'drag', label: '', width: 40, alwaysVisible: true },
|
||||||
|
{ key: 'select', label: '', width: 40, alwaysVisible: true },
|
||||||
|
{ key: 'key', label: 'KEY', width: 80, fieldKey: 'KEY' },
|
||||||
|
{ key: 'task', label: 'TASK', width: 220, alwaysVisible: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const allScrollableColumns = [
|
||||||
|
{ key: 'progress', label: 'Progress', width: 90, fieldKey: 'PROGRESS' },
|
||||||
|
{ key: 'members', label: 'Members', width: 150, fieldKey: 'ASSIGNEES' },
|
||||||
|
{ key: 'labels', label: 'Labels', width: 200, fieldKey: 'LABELS' },
|
||||||
|
{ key: 'status', label: 'Status', width: 100, fieldKey: 'STATUS' },
|
||||||
|
{ key: 'priority', label: 'Priority', width: 100, fieldKey: 'PRIORITY' },
|
||||||
|
{ key: 'timeTracking', label: 'Time Tracking', width: 120, fieldKey: 'TIME_TRACKING' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter columns based on field visibility
|
||||||
|
const visibleFixedColumns = useMemo(() => {
|
||||||
|
return allFixedColumns.filter(col => {
|
||||||
|
// Always show columns marked as alwaysVisible
|
||||||
|
if (col.alwaysVisible) return true;
|
||||||
|
|
||||||
|
// For other columns, check field visibility
|
||||||
|
if (col.fieldKey) {
|
||||||
|
const field = taskListFields.find(f => f.key === col.fieldKey);
|
||||||
|
return field?.visible ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}, [taskListFields, allFixedColumns]);
|
||||||
|
|
||||||
|
const visibleScrollableColumns = useMemo(() => {
|
||||||
|
return allScrollableColumns.filter(col => {
|
||||||
|
// For scrollable columns, check field visibility
|
||||||
|
if (col.fieldKey) {
|
||||||
|
const field = taskListFields.find(f => f.key === col.fieldKey);
|
||||||
|
return field?.visible ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}, [taskListFields, allScrollableColumns]);
|
||||||
|
|
||||||
// Get tasks for this group using memoization for performance
|
// Get tasks for this group using memoization for performance
|
||||||
const groupTasks = useMemo(() => {
|
const groupTasks = useMemo(() => {
|
||||||
return group.taskIds
|
return group.taskIds
|
||||||
@@ -142,7 +176,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
|||||||
style={{ ...containerStyle, overflowX: 'unset' }}
|
style={{ ...containerStyle, overflowX: 'unset' }}
|
||||||
>
|
>
|
||||||
<div className="task-group-scroll-wrapper" style={{ overflowX: 'auto', width: '100%' }}>
|
<div className="task-group-scroll-wrapper" style={{ overflowX: 'auto', width: '100%' }}>
|
||||||
<div style={{ minWidth: FIXED_COLUMNS.reduce((sum, col) => sum + col.width, 0) + SCROLLABLE_COLUMNS.reduce((sum, col) => sum + col.width, 0) }}>
|
<div style={{ minWidth: visibleFixedColumns.reduce((sum, col) => sum + col.width, 0) + visibleScrollableColumns.reduce((sum, col) => sum + col.width, 0) }}>
|
||||||
{/* Group Header Row */}
|
{/* Group Header Row */}
|
||||||
<div className="task-group-header">
|
<div className="task-group-header">
|
||||||
<div className="task-group-header-row">
|
<div className="task-group-header-row">
|
||||||
@@ -172,7 +206,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
|||||||
>
|
>
|
||||||
<div className="task-group-column-headers-row">
|
<div className="task-group-column-headers-row">
|
||||||
<div className="task-table-fixed-columns">
|
<div className="task-table-fixed-columns">
|
||||||
{FIXED_COLUMNS.map(col => (
|
{visibleFixedColumns.map(col => (
|
||||||
<div
|
<div
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className="task-table-cell task-table-header-cell"
|
className="task-table-cell task-table-header-cell"
|
||||||
@@ -183,7 +217,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="task-table-scrollable-columns">
|
<div className="task-table-scrollable-columns">
|
||||||
{SCROLLABLE_COLUMNS.map(col => (
|
{visibleScrollableColumns.map(col => (
|
||||||
<div
|
<div
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className="task-table-cell task-table-header-cell"
|
className="task-table-cell task-table-header-cell"
|
||||||
@@ -236,8 +270,8 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
|||||||
index={index}
|
index={index}
|
||||||
onSelect={onSelectTask}
|
onSelect={onSelectTask}
|
||||||
onToggleSubtasks={onToggleSubtasks}
|
onToggleSubtasks={onToggleSubtasks}
|
||||||
fixedColumns={FIXED_COLUMNS}
|
fixedColumns={visibleFixedColumns}
|
||||||
scrollableColumns={SCROLLABLE_COLUMNS}
|
scrollableColumns={visibleScrollableColumns}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { RootState } from '@/app/store';
|
|||||||
import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tag, Tooltip } from '@/components';
|
import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tag, Tooltip } from '@/components';
|
||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import TaskStatusDropdown from './task-status-dropdown';
|
||||||
|
|
||||||
interface TaskRowProps {
|
interface TaskRowProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
@@ -324,7 +325,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{/* Scrollable Columns */}
|
{/* Scrollable Columns */}
|
||||||
<div className="scrollable-columns-row" style={{ display: 'flex', minWidth: scrollableColumns?.reduce((sum, col) => sum + col.width, 0) || 0 }}>
|
<div className="scrollable-columns-row overflow-visible" style={{ display: 'flex', minWidth: scrollableColumns?.reduce((sum, col) => sum + col.width, 0) || 0 }}>
|
||||||
{scrollableColumns?.map(col => {
|
{scrollableColumns?.map(col => {
|
||||||
switch (col.key) {
|
switch (col.key) {
|
||||||
case 'progress':
|
case 'progress':
|
||||||
@@ -392,14 +393,12 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
);
|
);
|
||||||
case 'status':
|
case 'status':
|
||||||
return (
|
return (
|
||||||
<div key={col.key} className="flex items-center px-2 border-r" style={{ width: col.width }}>
|
<div key={col.key} className="flex items-center px-2 border-r overflow-visible" style={{ width: col.width }}>
|
||||||
<Tag
|
<TaskStatusDropdown
|
||||||
backgroundColor={getStatusColor(task.status)}
|
task={task}
|
||||||
color="white"
|
projectId={projectId}
|
||||||
className="text-xs font-medium uppercase"
|
isDarkMode={isDarkMode}
|
||||||
>
|
/>
|
||||||
{task.status}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'priority':
|
case 'priority':
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { Task } from '@/types/task-management.types';
|
||||||
|
|
||||||
|
interface TaskStatusDropdownProps {
|
||||||
|
task: Task;
|
||||||
|
projectId: string;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||||
|
task,
|
||||||
|
projectId,
|
||||||
|
isDarkMode = false
|
||||||
|
}) => {
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||||
|
|
||||||
|
// Find current status details
|
||||||
|
const currentStatus = useMemo(() => {
|
||||||
|
return statusList.find(status =>
|
||||||
|
status.name?.toLowerCase() === task.status?.toLowerCase() ||
|
||||||
|
status.id === task.status
|
||||||
|
);
|
||||||
|
}, [statusList, task.status]);
|
||||||
|
|
||||||
|
// Handle status change
|
||||||
|
const handleStatusChange = useCallback((statusId: string, statusName: string) => {
|
||||||
|
if (!task.id || !statusId || !connected) return;
|
||||||
|
|
||||||
|
socket?.emit(
|
||||||
|
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||||
|
JSON.stringify({
|
||||||
|
task_id: task.id,
|
||||||
|
status_id: statusId,
|
||||||
|
parent_task: null, // Assuming top-level tasks for now
|
||||||
|
team_id: projectId, // Using projectId as teamId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||||
|
setIsOpen(false);
|
||||||
|
}, [task.id, connected, socket, projectId]);
|
||||||
|
|
||||||
|
// Calculate dropdown position and handle outside clicks
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (buttonRef.current && buttonRef.current.contains(event.target as Node)) {
|
||||||
|
return; // Don't close if clicking the button
|
||||||
|
}
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen && buttonRef.current) {
|
||||||
|
// Calculate position
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + window.scrollY + 4,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Get status color
|
||||||
|
const getStatusColor = useCallback((status: any) => {
|
||||||
|
if (isDarkMode) {
|
||||||
|
return status?.color_code_dark || status?.color_code || '#6b7280';
|
||||||
|
}
|
||||||
|
return status?.color_code || '#6b7280';
|
||||||
|
}, [isDarkMode]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Status display name
|
||||||
|
const getStatusDisplayName = useCallback((status: string) => {
|
||||||
|
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!task.status) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Status Button */}
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
console.log('Status dropdown clicked, current isOpen:', isOpen);
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
}}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium
|
||||||
|
transition-all duration-200 hover:opacity-80 border-0 min-w-[70px] justify-center
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: currentStatus ? getStatusColor(currentStatus) : (isDarkMode ? '#6b7280' : '#9ca3af'),
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{currentStatus?.name || getStatusDisplayName(task.status)}</span>
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu - Rendered in Portal */}
|
||||||
|
{isOpen && createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className={`
|
||||||
|
fixed min-w-[120px] max-w-[180px]
|
||||||
|
rounded-lg shadow-xl border z-[9999]
|
||||||
|
${isDarkMode
|
||||||
|
? 'bg-gray-800 border-gray-600'
|
||||||
|
: 'bg-white border-gray-200'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
top: dropdownPosition.top,
|
||||||
|
left: dropdownPosition.left,
|
||||||
|
zIndex: 9999
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="py-1">
|
||||||
|
{statusList.map((status) => (
|
||||||
|
<button
|
||||||
|
key={status.id}
|
||||||
|
onClick={() => handleStatusChange(status.id!, status.name!)}
|
||||||
|
className={`
|
||||||
|
w-full px-3 py-2 text-left text-xs font-medium flex items-center gap-2
|
||||||
|
transition-colors duration-150 rounded-md mx-1
|
||||||
|
${isDarkMode
|
||||||
|
? 'hover:bg-gray-700 text-gray-200'
|
||||||
|
: 'hover:bg-gray-50 text-gray-900'
|
||||||
|
}
|
||||||
|
${(status.name?.toLowerCase() === task.status?.toLowerCase() || status.id === task.status)
|
||||||
|
? (isDarkMode ? 'bg-gray-700' : 'bg-gray-50')
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Status Pill Preview */}
|
||||||
|
<div
|
||||||
|
className="px-2 py-0.5 rounded-full text-white text-xs min-w-[50px] text-center"
|
||||||
|
style={{ backgroundColor: getStatusColor(status) }}
|
||||||
|
>
|
||||||
|
{status.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Status Indicator */}
|
||||||
|
{(status.name?.toLowerCase() === task.status?.toLowerCase() || status.id === task.status) && (
|
||||||
|
<div className="ml-auto">
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full ${isDarkMode ? 'bg-blue-400' : 'bg-blue-500'}`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskStatusDropdown;
|
||||||
@@ -6,6 +6,8 @@ import { taskManagementSelectors } from '@/features/task-management/task-managem
|
|||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import TaskRow from './task-row';
|
import TaskRow from './task-row';
|
||||||
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
|
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
|
||||||
|
import { RootState } from '@/app/store';
|
||||||
|
import { TaskListField } from '@/features/task-management/taskListFields.slice';
|
||||||
|
|
||||||
interface VirtualizedTaskListProps {
|
interface VirtualizedTaskListProps {
|
||||||
group: any;
|
group: any;
|
||||||
@@ -30,6 +32,18 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
|||||||
}) => {
|
}) => {
|
||||||
const allTasks = useSelector(taskManagementSelectors.selectAll);
|
const allTasks = useSelector(taskManagementSelectors.selectAll);
|
||||||
|
|
||||||
|
// Get field visibility from taskListFields slice
|
||||||
|
const taskListFields = useSelector((state: RootState) => state.taskManagementFields) as TaskListField[];
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('VirtualizedTaskList Debug:', {
|
||||||
|
taskListFields,
|
||||||
|
fieldsLength: taskListFields?.length,
|
||||||
|
fieldsState: taskListFields?.map(f => ({ key: f.key, visible: f.visible }))
|
||||||
|
});
|
||||||
|
}, [taskListFields]);
|
||||||
|
|
||||||
// Get tasks for this group using memoization for performance
|
// Get tasks for this group using memoization for performance
|
||||||
const groupTasks = useMemo(() => {
|
const groupTasks = useMemo(() => {
|
||||||
return group.taskIds
|
return group.taskIds
|
||||||
@@ -85,21 +99,43 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Define columns array for alignment
|
// Define all possible columns
|
||||||
const columns = [
|
const allColumns = [
|
||||||
{ key: 'drag', label: '', width: 40, fixed: true },
|
{ key: 'drag', label: '', width: 40, fixed: true, alwaysVisible: true },
|
||||||
{ key: 'select', label: '', width: 40, fixed: true },
|
{ key: 'select', label: '', width: 40, fixed: true, alwaysVisible: true },
|
||||||
{ key: 'key', label: 'KEY', width: 80, fixed: true },
|
{ key: 'key', label: 'KEY', width: 80, fixed: true, fieldKey: 'KEY' },
|
||||||
{ key: 'task', label: 'TASK', width: 475, fixed: true },
|
{ key: 'task', label: 'TASK', width: 475, fixed: true, alwaysVisible: true },
|
||||||
{ key: 'progress', label: 'PROGRESS', width: 90 },
|
{ key: 'progress', label: 'PROGRESS', width: 90, fieldKey: 'PROGRESS' },
|
||||||
{ key: 'members', label: 'MEMBERS', width: 150 },
|
{ key: 'members', label: 'MEMBERS', width: 150, fieldKey: 'ASSIGNEES' },
|
||||||
{ key: 'labels', label: 'LABELS', width: 200 },
|
{ key: 'labels', label: 'LABELS', width: 200, fieldKey: 'LABELS' },
|
||||||
{ key: 'status', label: 'STATUS', width: 100 },
|
{ key: 'status', label: 'STATUS', width: 100, fieldKey: 'STATUS' },
|
||||||
{ key: 'priority', label: 'PRIORITY', width: 100 },
|
{ key: 'priority', label: 'PRIORITY', width: 100, fieldKey: 'PRIORITY' },
|
||||||
{ key: 'timeTracking', label: 'TIME TRACKING', width: 120 },
|
{ key: 'timeTracking', label: 'TIME TRACKING', width: 120, fieldKey: 'TIME_TRACKING' },
|
||||||
];
|
];
|
||||||
const fixedColumns = columns.filter(col => col.fixed);
|
|
||||||
const scrollableColumns = columns.filter(col => !col.fixed);
|
// Filter columns based on field visibility
|
||||||
|
const visibleColumns = useMemo(() => {
|
||||||
|
const filtered = allColumns.filter(col => {
|
||||||
|
// Always show columns marked as alwaysVisible
|
||||||
|
if (col.alwaysVisible) return true;
|
||||||
|
|
||||||
|
// For other columns, check field visibility
|
||||||
|
if (col.fieldKey) {
|
||||||
|
const field = taskListFields.find(f => f.key === col.fieldKey);
|
||||||
|
const isVisible = field?.visible ?? false;
|
||||||
|
console.log(`Column ${col.key} (fieldKey: ${col.fieldKey}):`, { field, isVisible });
|
||||||
|
return isVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Visible columns after filtering:', filtered.map(c => ({ key: c.key, fieldKey: c.fieldKey })));
|
||||||
|
return filtered;
|
||||||
|
}, [taskListFields, allColumns]);
|
||||||
|
|
||||||
|
const fixedColumns = visibleColumns.filter(col => col.fixed);
|
||||||
|
const scrollableColumns = visibleColumns.filter(col => !col.fixed);
|
||||||
const fixedWidth = fixedColumns.reduce((sum, col) => sum + col.width, 0);
|
const fixedWidth = fixedColumns.reduce((sum, col) => sum + col.width, 0);
|
||||||
const scrollableWidth = scrollableColumns.reduce((sum, col) => sum + col.width, 0);
|
const scrollableWidth = scrollableColumns.reduce((sum, col) => sum + col.width, 0);
|
||||||
const totalTableWidth = fixedWidth + scrollableWidth;
|
const totalTableWidth = fixedWidth + scrollableWidth;
|
||||||
@@ -130,7 +166,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]);
|
}, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks, fixedColumns, scrollableColumns]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="virtualized-task-list" style={{ height: height }}>
|
<div className="virtualized-task-list" style={{ height: height }}>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export interface TaskListField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
visible: boolean;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FIELDS: TaskListField[] = [
|
||||||
|
{ key: 'KEY', label: 'Key', visible: false, order: 1 },
|
||||||
|
{ key: 'DESCRIPTION', label: 'Description', visible: false, order: 2 },
|
||||||
|
{ key: 'PROGRESS', label: 'Progress', visible: true, order: 3 },
|
||||||
|
{ key: 'ASSIGNEES', label: 'Assignees', visible: true, order: 4 },
|
||||||
|
{ key: 'LABELS', label: 'Labels', visible: true, order: 5 },
|
||||||
|
{ key: 'PHASE', label: 'Phase', visible: true, order: 6 },
|
||||||
|
{ key: 'STATUS', label: 'Status', visible: true, order: 7 },
|
||||||
|
{ key: 'PRIORITY', label: 'Priority', visible: true, order: 8 },
|
||||||
|
{ key: 'TIME_TRACKING', label: 'Time Tracking', visible: true, order: 9 },
|
||||||
|
{ key: 'ESTIMATION', label: 'Estimation', visible: false, order: 10 },
|
||||||
|
{ key: 'START_DATE', label: 'Start Date', visible: false, order: 11 },
|
||||||
|
{ key: 'DUE_DATE', label: 'Due Date', visible: true, order: 12 },
|
||||||
|
{ key: 'DUE_TIME', label: 'Due Time', visible: false, order: 13 },
|
||||||
|
{ key: 'COMPLETED_DATE', label: 'Completed Date', visible: false, order: 14 },
|
||||||
|
{ key: 'CREATED_DATE', label: 'Created Date', visible: false, order: 15 },
|
||||||
|
{ key: 'LAST_UPDATED', label: 'Last Updated', visible: false, order: 16 },
|
||||||
|
{ key: 'REPORTER', label: 'Reporter', visible: false, order: 17 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOCAL_STORAGE_KEY = 'worklenz.taskManagement.fields';
|
||||||
|
|
||||||
|
function loadFields(): TaskListField[] {
|
||||||
|
const stored = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||||
|
console.log('Loading fields from localStorage:', stored);
|
||||||
|
|
||||||
|
// Temporarily force defaults to debug
|
||||||
|
console.log('FORCING DEFAULT FIELDS FOR DEBUGGING');
|
||||||
|
return DEFAULT_FIELDS;
|
||||||
|
|
||||||
|
/* Commented out for debugging
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
console.log('Parsed fields from localStorage:', parsed);
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse stored fields, using defaults:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Using default fields:', DEFAULT_FIELDS);
|
||||||
|
return DEFAULT_FIELDS;
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFields(fields: TaskListField[]) {
|
||||||
|
console.log('Saving fields to localStorage:', fields);
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(fields));
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: TaskListField[] = loadFields();
|
||||||
|
console.log('TaskListFields slice initial state:', initialState);
|
||||||
|
|
||||||
|
const taskListFieldsSlice = createSlice({
|
||||||
|
name: 'taskManagementFields',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
toggleField(state, action: PayloadAction<string>) {
|
||||||
|
const field = state.find(f => f.key === action.payload);
|
||||||
|
if (field) {
|
||||||
|
field.visible = !field.visible;
|
||||||
|
saveFields(state);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setFields(state, action: PayloadAction<TaskListField[]>) {
|
||||||
|
saveFields(action.payload);
|
||||||
|
return action.payload;
|
||||||
|
},
|
||||||
|
resetFields() {
|
||||||
|
saveFields(DEFAULT_FIELDS);
|
||||||
|
return DEFAULT_FIELDS;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { toggleField, setFields, resetFields } = taskListFieldsSlice.actions;
|
||||||
|
|
||||||
|
// Utility function to force reset fields (can be called from browser console)
|
||||||
|
export const forceResetFields = () => {
|
||||||
|
localStorage.removeItem(LOCAL_STORAGE_KEY);
|
||||||
|
console.log('Cleared localStorage and reset fields to defaults');
|
||||||
|
return DEFAULT_FIELDS;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make it available globally for debugging
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
(window as any).forceResetTaskFields = forceResetFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default taskListFieldsSlice.reducer;
|
||||||
Reference in New Issue
Block a user