Merge pull request #238 from Worklenz/fix/task-drag-and-drop-improvement
Fix/task drag and drop improvement
This commit is contained in:
@@ -55,5 +55,18 @@
|
|||||||
"selectCategory": "Zgjidh një kategori",
|
"selectCategory": "Zgjidh një kategori",
|
||||||
"pleaseEnterAName": "Ju lutemi vendosni një emër",
|
"pleaseEnterAName": "Ju lutemi vendosni një emër",
|
||||||
"pleaseSelectACategory": "Ju lutemi zgjidhni një kategori",
|
"pleaseSelectACategory": "Ju lutemi zgjidhni një kategori",
|
||||||
"create": "Krijo"
|
"create": "Krijo",
|
||||||
|
|
||||||
|
"searchTasks": "Kërko detyrat...",
|
||||||
|
"searchPlaceholder": "Kërko...",
|
||||||
|
"fieldsText": "Fushat",
|
||||||
|
"loadingFilters": "Duke ngarkuar filtrat...",
|
||||||
|
"noOptionsFound": "Nuk u gjetën opsione",
|
||||||
|
"filtersActive": "filtra aktiv",
|
||||||
|
"filterActive": "filtër aktiv",
|
||||||
|
"clearAll": "Pastro të gjitha",
|
||||||
|
"clearing": "Duke pastruar...",
|
||||||
|
"cancel": "Anulo",
|
||||||
|
"search": "Kërko",
|
||||||
|
"groupedBy": "Grupuar sipas"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,19 @@
|
|||||||
"key": "Çelësi",
|
"key": "Çelësi",
|
||||||
"formula": "Formula"
|
"formula": "Formula"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"indicators": {
|
||||||
|
"tooltips": {
|
||||||
|
"subtasks": "{{count}} nën-detyrë",
|
||||||
|
"subtasks_plural": "{{count}} nën-detyra",
|
||||||
|
"comments": "{{count}} koment",
|
||||||
|
"comments_plural": "{{count}} komente",
|
||||||
|
"attachments": "{{count}} bashkëngjitje",
|
||||||
|
"attachments_plural": "{{count}} bashkëngjitje",
|
||||||
|
"subscribers": "Detyra ka pajtues",
|
||||||
|
"dependencies": "Detyra ka varësi",
|
||||||
|
"recurring": "Detyrë përsëritëse"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,5 +55,18 @@
|
|||||||
"selectCategory": "Kategorie auswählen",
|
"selectCategory": "Kategorie auswählen",
|
||||||
"pleaseEnterAName": "Bitte geben Sie einen Namen ein",
|
"pleaseEnterAName": "Bitte geben Sie einen Namen ein",
|
||||||
"pleaseSelectACategory": "Bitte wählen Sie eine Kategorie aus",
|
"pleaseSelectACategory": "Bitte wählen Sie eine Kategorie aus",
|
||||||
"create": "Erstellen"
|
"create": "Erstellen",
|
||||||
|
|
||||||
|
"searchTasks": "Aufgaben suchen...",
|
||||||
|
"searchPlaceholder": "Suchen...",
|
||||||
|
"fieldsText": "Felder",
|
||||||
|
"loadingFilters": "Filter werden geladen...",
|
||||||
|
"noOptionsFound": "Keine Optionen gefunden",
|
||||||
|
"filtersActive": "Filter aktiv",
|
||||||
|
"filterActive": "Filter aktiv",
|
||||||
|
"clearAll": "Alle löschen",
|
||||||
|
"clearing": "Löschen...",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"search": "Suchen",
|
||||||
|
"groupedBy": "Gruppiert nach"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,19 @@
|
|||||||
"key": "Schlüssel",
|
"key": "Schlüssel",
|
||||||
"formula": "Formel"
|
"formula": "Formel"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"indicators": {
|
||||||
|
"tooltips": {
|
||||||
|
"subtasks": "{{count}} Unteraufgabe",
|
||||||
|
"subtasks_plural": "{{count}} Unteraufgaben",
|
||||||
|
"comments": "{{count}} Kommentar",
|
||||||
|
"comments_plural": "{{count}} Kommentare",
|
||||||
|
"attachments": "{{count}} Anhang",
|
||||||
|
"attachments_plural": "{{count}} Anhänge",
|
||||||
|
"subscribers": "Aufgabe hat Abonnenten",
|
||||||
|
"dependencies": "Aufgabe hat Abhängigkeiten",
|
||||||
|
"recurring": "Wiederkehrende Aufgabe"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,5 +55,18 @@
|
|||||||
"selectCategory": "Select a category",
|
"selectCategory": "Select a category",
|
||||||
"pleaseEnterAName": "Please enter a name",
|
"pleaseEnterAName": "Please enter a name",
|
||||||
"pleaseSelectACategory": "Please select a category",
|
"pleaseSelectACategory": "Please select a category",
|
||||||
"create": "Create"
|
"create": "Create",
|
||||||
|
|
||||||
|
"searchTasks": "Search tasks...",
|
||||||
|
"searchPlaceholder": "Search...",
|
||||||
|
"fieldsText": "Fields",
|
||||||
|
"loadingFilters": "Loading filters...",
|
||||||
|
"noOptionsFound": "No options found",
|
||||||
|
"filtersActive": "filters active",
|
||||||
|
"filterActive": "filter active",
|
||||||
|
"clearAll": "Clear all",
|
||||||
|
"clearing": "Clearing...",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"search": "Search",
|
||||||
|
"groupedBy": "Grouped by"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,19 @@
|
|||||||
"key": "Key",
|
"key": "Key",
|
||||||
"formula": "Formula"
|
"formula": "Formula"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"indicators": {
|
||||||
|
"tooltips": {
|
||||||
|
"subtasks": "{{count}} subtask",
|
||||||
|
"subtasks_plural": "{{count}} subtasks",
|
||||||
|
"comments": "{{count}} comment",
|
||||||
|
"comments_plural": "{{count}} comments",
|
||||||
|
"attachments": "{{count}} attachment",
|
||||||
|
"attachments_plural": "{{count}} attachments",
|
||||||
|
"subscribers": "Task has subscribers",
|
||||||
|
"dependencies": "Task has dependencies",
|
||||||
|
"recurring": "Recurring task"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,19 @@
|
|||||||
"renamePhase": "Rename Phase",
|
"renamePhase": "Rename Phase",
|
||||||
"changeCategory": "Change Category",
|
"changeCategory": "Change Category",
|
||||||
"clickToEditGroupName": "Click to edit group name",
|
"clickToEditGroupName": "Click to edit group name",
|
||||||
"enterGroupName": "Enter group name"
|
"enterGroupName": "Enter group name",
|
||||||
|
|
||||||
|
"indicators": {
|
||||||
|
"tooltips": {
|
||||||
|
"subtasks": "{{count}} subtask",
|
||||||
|
"subtasks_plural": "{{count}} subtasks",
|
||||||
|
"comments": "{{count}} comment",
|
||||||
|
"comments_plural": "{{count}} comments",
|
||||||
|
"attachments": "{{count}} attachment",
|
||||||
|
"attachments_plural": "{{count}} attachments",
|
||||||
|
"subscribers": "Task has subscribers",
|
||||||
|
"dependencies": "Task has dependencies",
|
||||||
|
"recurring": "Recurring task"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,5 +51,18 @@
|
|||||||
"selectCategory": "Seleccionar una categoría",
|
"selectCategory": "Seleccionar una categoría",
|
||||||
"pleaseEnterAName": "Por favor, ingrese un nombre",
|
"pleaseEnterAName": "Por favor, ingrese un nombre",
|
||||||
"pleaseSelectACategory": "Por favor, seleccione una categoría",
|
"pleaseSelectACategory": "Por favor, seleccione una categoría",
|
||||||
"create": "Crear"
|
"create": "Crear",
|
||||||
|
|
||||||
|
"searchTasks": "Buscar tareas...",
|
||||||
|
"searchPlaceholder": "Buscar...",
|
||||||
|
"fieldsText": "Campos",
|
||||||
|
"loadingFilters": "Cargando filtros...",
|
||||||
|
"noOptionsFound": "No se encontraron opciones",
|
||||||
|
"filtersActive": "filtros activos",
|
||||||
|
"filterActive": "filtro activo",
|
||||||
|
"clearAll": "Limpiar todo",
|
||||||
|
"clearing": "Limpiando...",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"search": "Buscar",
|
||||||
|
"groupedBy": "Agrupado por"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,19 @@
|
|||||||
"key": "Clave",
|
"key": "Clave",
|
||||||
"formula": "Fórmula"
|
"formula": "Fórmula"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"indicators": {
|
||||||
|
"tooltips": {
|
||||||
|
"subtasks": "{{count}} subtarea",
|
||||||
|
"subtasks_plural": "{{count}} subtareas",
|
||||||
|
"comments": "{{count}} comentario",
|
||||||
|
"comments_plural": "{{count}} comentarios",
|
||||||
|
"attachments": "{{count}} archivo adjunto",
|
||||||
|
"attachments_plural": "{{count}} archivos adjuntos",
|
||||||
|
"subscribers": "La tarea tiene suscriptores",
|
||||||
|
"dependencies": "La tarea tiene dependencias",
|
||||||
|
"recurring": "Tarea recurrente"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,5 +52,18 @@
|
|||||||
"selectCategory": "Selecionar uma categoria",
|
"selectCategory": "Selecionar uma categoria",
|
||||||
"pleaseEnterAName": "Por favor, insira um nome",
|
"pleaseEnterAName": "Por favor, insira um nome",
|
||||||
"pleaseSelectACategory": "Por favor, selecione uma categoria",
|
"pleaseSelectACategory": "Por favor, selecione uma categoria",
|
||||||
"create": "Criar"
|
"create": "Criar",
|
||||||
|
|
||||||
|
"searchTasks": "Pesquisar tarefas...",
|
||||||
|
"searchPlaceholder": "Pesquisar...",
|
||||||
|
"fieldsText": "Campos",
|
||||||
|
"loadingFilters": "Carregando filtros...",
|
||||||
|
"noOptionsFound": "Nenhuma opção encontrada",
|
||||||
|
"filtersActive": "filtros ativos",
|
||||||
|
"filterActive": "filtro ativo",
|
||||||
|
"clearAll": "Limpar tudo",
|
||||||
|
"clearing": "Limpando...",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"search": "Pesquisar",
|
||||||
|
"groupedBy": "Agrupado por"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,5 +108,19 @@
|
|||||||
"key": "Chave",
|
"key": "Chave",
|
||||||
"formula": "Fórmula"
|
"formula": "Fórmula"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"indicators": {
|
||||||
|
"tooltips": {
|
||||||
|
"subtasks": "{{count}} subtarefa",
|
||||||
|
"subtasks_plural": "{{count}} subtarefas",
|
||||||
|
"comments": "{{count}} comentário",
|
||||||
|
"comments_plural": "{{count}} comentários",
|
||||||
|
"attachments": "{{count}} anexo",
|
||||||
|
"attachments_plural": "{{count}} anexos",
|
||||||
|
"subscribers": "A tarefa tem assinantes",
|
||||||
|
"dependencies": "A tarefa tem dependências",
|
||||||
|
"recurring": "Tarefa recorrente"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ const TaskCard: React.FC<taskProps> = ({ task }) => {
|
|||||||
format={value => formatDate(value)}
|
format={value => formatDate(value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{task.sub_tasks_count && task.sub_tasks_count > 0 && (
|
{task.sub_tasks_count && task.sub_tasks_count > 1 && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsSubTaskShow(!isSubTaskShow)}
|
onClick={() => setIsSubTaskShow(!isSubTaskShow)}
|
||||||
size="small"
|
size="small"
|
||||||
@@ -306,7 +306,7 @@ const TaskCard: React.FC<taskProps> = ({ task }) => {
|
|||||||
|
|
||||||
{isSubTaskShow &&
|
{isSubTaskShow &&
|
||||||
task.sub_tasks_count &&
|
task.sub_tasks_count &&
|
||||||
task.sub_tasks_count > 0 &&
|
task.sub_tasks_count > 1 &&
|
||||||
task.sub_tasks?.map(subtask => <SubTaskCard subtask={subtask} />)}
|
task.sub_tasks?.map(subtask => <SubTaskCard subtask={subtask} />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -187,29 +187,33 @@ const EnhancedKanbanTaskCard: React.FC<EnhancedKanbanTaskCardProps> = React.memo
|
|||||||
<Flex gap={4} align="center">
|
<Flex gap={4} align="center">
|
||||||
<CustomDueDatePicker task={task} onDateChange={setDueDate} />
|
<CustomDueDatePicker task={task} onDateChange={setDueDate} />
|
||||||
|
|
||||||
{/* Subtask Section */}
|
{/* Subtask Section - only show if count > 1 */}
|
||||||
<Button
|
{task.sub_tasks_count != null && Number(task.sub_tasks_count) > 1 && (
|
||||||
onClick={handleSubtaskButtonClick}
|
<Tooltip title={t(`indicators.tooltips.subtasks${Number(task.sub_tasks_count) === 1 ? '' : '_plural'}`, { count: Number(task.sub_tasks_count) })}>
|
||||||
size="small"
|
<Button
|
||||||
style={{
|
onClick={handleSubtaskButtonClick}
|
||||||
padding: 0,
|
size="small"
|
||||||
}}
|
style={{
|
||||||
type="text"
|
padding: 0,
|
||||||
>
|
}}
|
||||||
<Tag
|
type="text"
|
||||||
bordered={false}
|
>
|
||||||
style={{
|
<Tag
|
||||||
display: 'flex',
|
bordered={false}
|
||||||
alignItems: 'center',
|
style={{
|
||||||
margin: 0,
|
display: 'flex',
|
||||||
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
alignItems: 'center',
|
||||||
}}
|
margin: 0,
|
||||||
>
|
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||||
<ForkOutlined rotate={90} />
|
}}
|
||||||
<span>{task.sub_tasks_count}</span>
|
>
|
||||||
{task.show_sub_tasks ? <CaretDownFilled /> : <CaretRightFilled />}
|
<ForkOutlined rotate={90} />
|
||||||
</Tag>
|
<span>{task.sub_tasks_count || 0}</span>
|
||||||
</Button>
|
{task.show_sub_tasks ? <CaretDownFilled /> : <CaretRightFilled />}
|
||||||
|
</Tag>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex vertical gap={8}>
|
<Flex vertical gap={8}>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import { IGroupBy } from '@/features/tasks/tasks.slice';
|
import { IGroupBy } from '@/features/tasks/tasks.slice';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onToggleSubtasks,
|
onToggleSubtasks,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: task.id!,
|
id: task.id!,
|
||||||
data: {
|
data: {
|
||||||
@@ -195,15 +197,19 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
|
|||||||
<ClockCircleOutlined /> {task.time_spent_string}
|
<ClockCircleOutlined /> {task.time_spent_string}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.comments_count && task.comments_count > 0 && (
|
{task.comments_count && task.comments_count > 1 && (
|
||||||
<span className="kanban-task-indicator">
|
<Tooltip title={t(`indicators.tooltips.comments${task.comments_count === 1 ? '' : '_plural'}`, { count: task.comments_count })}>
|
||||||
<MessageOutlined /> {task.comments_count}
|
<span className="kanban-task-indicator">
|
||||||
</span>
|
<MessageOutlined /> {task.comments_count}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{task.attachments_count && task.attachments_count > 0 && (
|
{task.attachments_count && task.attachments_count > 1 && (
|
||||||
<span className="kanban-task-indicator">
|
<Tooltip title={t(`indicators.tooltips.attachments${task.attachments_count === 1 ? '' : '_plural'}`, { count: task.attachments_count })}>
|
||||||
<PaperClipOutlined /> {task.attachments_count}
|
<span className="kanban-task-indicator">
|
||||||
</span>
|
<PaperClipOutlined /> {task.attachments_count}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const CreateStatusButton = () => {
|
|||||||
onClick={() => dispatch(toggleDrawer())}
|
onClick={() => dispatch(toggleDrawer())}
|
||||||
icon={
|
icon={
|
||||||
<SettingOutlined
|
<SettingOutlined
|
||||||
style={{ color: themeMode === 'dark' ? colors.white : colors.midBlue }}
|
style={{ color: themeMode === 'dark' ? colors.white : 'black' }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import taskAttachmentsApiService from '@/api/tasks/task-attachments.api.service'
|
|||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import taskCommentsApiService from '@/api/tasks/task-comments.api.service';
|
import taskCommentsApiService from '@/api/tasks/task-comments.api.service';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { updateTaskCounts } from '@/features/task-management/task-management.slice';
|
||||||
|
|
||||||
interface AttachmentsPreviewProps {
|
interface AttachmentsPreviewProps {
|
||||||
attachment: ITaskAttachmentViewModel;
|
attachment: ITaskAttachmentViewModel;
|
||||||
@@ -28,6 +30,7 @@ const AttachmentsPreview = ({
|
|||||||
isCommentAttachment = false,
|
isCommentAttachment = false,
|
||||||
}: AttachmentsPreviewProps) => {
|
}: AttachmentsPreviewProps) => {
|
||||||
const { selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
const { selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const [currentFileUrl, setCurrentFileUrl] = useState<string | null>(null);
|
const [currentFileUrl, setCurrentFileUrl] = useState<string | null>(null);
|
||||||
@@ -88,6 +91,7 @@ const AttachmentsPreview = ({
|
|||||||
if (isCommentAttachment) {
|
if (isCommentAttachment) {
|
||||||
const res = await taskCommentsApiService.deleteAttachment(id, selectedTaskId);
|
const res = await taskCommentsApiService.deleteAttachment(id, selectedTaskId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
|
// Let the parent component handle the refetch and Redux update
|
||||||
document.dispatchEvent(new Event('task-comment-update'));
|
document.dispatchEvent(new Event('task-comment-update'));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -96,6 +100,7 @@ const AttachmentsPreview = ({
|
|||||||
if (onDelete) {
|
if (onDelete) {
|
||||||
onDelete(id);
|
onDelete(id);
|
||||||
}
|
}
|
||||||
|
// Parent component will handle the refetch and Redux update
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -13,12 +13,16 @@ import logger from '@/utils/errorLogger';
|
|||||||
import TaskViewCommentEdit from './task-view-comment-edit';
|
import TaskViewCommentEdit from './task-view-comment-edit';
|
||||||
import './task-comments.css';
|
import './task-comments.css';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { updateTaskCounts } from '@/features/task-management/task-management.slice';
|
||||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
import AttachmentsGrid from '../attachments/attachments-grid';
|
import AttachmentsGrid from '../attachments/attachments-grid';
|
||||||
import { TFunction } from 'i18next';
|
import { TFunction } from 'i18next';
|
||||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||||
import { sanitizeHtml } from '@/utils/sanitizeInput';
|
import { sanitizeHtml } from '@/utils/sanitizeInput';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
|
||||||
// Helper function to format date for time separators
|
// Helper function to format date for time separators
|
||||||
const formatDateForSeparator = (date: string) => {
|
const formatDateForSeparator = (date: string) => {
|
||||||
@@ -90,6 +94,8 @@ const TaskComments = ({ taskId, t }: { taskId?: string; t: TFunction }) => {
|
|||||||
const auth = useAuthService();
|
const auth = useAuthService();
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
const currentUserId = auth.getCurrentSession()?.id;
|
const currentUserId = auth.getCurrentSession()?.id;
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const getComments = useCallback(
|
const getComments = useCallback(
|
||||||
async (showLoading = true) => {
|
async (showLoading = true) => {
|
||||||
@@ -115,6 +121,14 @@ const TaskComments = ({ taskId, t }: { taskId?: string; t: TFunction }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setComments(sortedComments);
|
setComments(sortedComments);
|
||||||
|
|
||||||
|
// Update Redux state with the current comment count
|
||||||
|
dispatch(updateTaskCounts({
|
||||||
|
taskId,
|
||||||
|
counts: {
|
||||||
|
comments_count: sortedComments.length
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -123,7 +137,7 @@ const TaskComments = ({ taskId, t }: { taskId?: string; t: TFunction }) => {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[taskId]
|
[taskId, dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -196,8 +210,11 @@ const TaskComments = ({ taskId, t }: { taskId?: string; t: TFunction }) => {
|
|||||||
try {
|
try {
|
||||||
const res = await taskCommentsApiService.delete(id, taskId);
|
const res = await taskCommentsApiService.delete(id, taskId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
|
// Refresh comments to get updated list
|
||||||
await getComments(false);
|
await getComments(false);
|
||||||
document.dispatchEvent(new Event('task-comment-update'));
|
|
||||||
|
// The comment count will be updated by getComments function
|
||||||
|
// No need to dispatch here as getComments already handles it
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error deleting comment', e);
|
logger.error('Error deleting comment', e);
|
||||||
@@ -227,7 +244,9 @@ const TaskComments = ({ taskId, t }: { taskId?: string; t: TFunction }) => {
|
|||||||
await getComments(false);
|
await getComments(false);
|
||||||
|
|
||||||
// Dispatch event to notify that an attachment was deleted
|
// Dispatch event to notify that an attachment was deleted
|
||||||
document.dispatchEvent(new Event('task-comment-update'));
|
document.dispatchEvent(new CustomEvent('task-comment-update', {
|
||||||
|
detail: { taskId }
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error deleting attachment', e);
|
logger.error('Error deleting attachment', e);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import logger from '@/utils/errorLogger';
|
|||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
|
||||||
interface TaskViewCommentEditProps {
|
interface TaskViewCommentEditProps {
|
||||||
commentData: ITaskCommentViewModel;
|
commentData: ITaskCommentViewModel;
|
||||||
@@ -27,6 +29,7 @@ const TaskViewCommentEdit = ({ commentData, onUpdated }: TaskViewCommentEditProp
|
|||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
|
||||||
// Initialize content when component mounts
|
// Initialize content when component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,7 +58,9 @@ const TaskViewCommentEdit = ({ commentData, onUpdated }: TaskViewCommentEditProp
|
|||||||
onUpdated(commentData);
|
onUpdated(commentData);
|
||||||
|
|
||||||
// Dispatch event to notify that a comment was updated
|
// Dispatch event to notify that a comment was updated
|
||||||
document.dispatchEvent(new Event('task-comment-update'));
|
document.dispatchEvent(new CustomEvent('task-comment-update', {
|
||||||
|
detail: { taskId: commentData.task_id }
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error updating comment', e);
|
logger.error('Error updating comment', e);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { DeleteOutlined, ExclamationCircleFilled } from '@ant-design/icons';
|
import { DeleteOutlined, ExclamationCircleFilled } from '@ant-design/icons';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { updateTaskCounts } from '@/features/task-management/task-management.slice';
|
||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
import { TFunction } from 'i18next';
|
import { TFunction } from 'i18next';
|
||||||
import { IDependencyType, ITaskDependency } from '@/types/tasks/task-dependency.types';
|
import { IDependencyType, ITaskDependency } from '@/types/tasks/task-dependency.types';
|
||||||
@@ -40,6 +42,7 @@ const DependenciesTable = ({
|
|||||||
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
||||||
const [isDependencyInputShow, setIsDependencyInputShow] = useState(false);
|
const [isDependencyInputShow, setIsDependencyInputShow] = useState(false);
|
||||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const [taskList, setTaskList] = useState<{ label: string; value: string }[]>([]);
|
const [taskList, setTaskList] = useState<{ label: string; value: string }[]>([]);
|
||||||
const [loadingTaskList, setLoadingTaskList] = useState(false);
|
const [loadingTaskList, setLoadingTaskList] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -60,6 +63,14 @@ const DependenciesTable = ({
|
|||||||
setIsDependencyInputShow(false);
|
setIsDependencyInputShow(false);
|
||||||
setTaskList([]);
|
setTaskList([]);
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
|
|
||||||
|
// Update Redux state with dependency status
|
||||||
|
dispatch(updateTaskCounts({
|
||||||
|
taskId: task.id,
|
||||||
|
counts: {
|
||||||
|
has_dependencies: true
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding dependency:', error);
|
console.error('Error adding dependency:', error);
|
||||||
@@ -89,6 +100,16 @@ const DependenciesTable = ({
|
|||||||
const res = await taskDependenciesApiService.deleteTaskDependency(dependencyId);
|
const res = await taskDependenciesApiService.deleteTaskDependency(dependencyId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
refreshTaskDependencies();
|
refreshTaskDependencies();
|
||||||
|
|
||||||
|
// Update Redux state with dependency status
|
||||||
|
// Check if there are any remaining dependencies
|
||||||
|
const remainingDependencies = taskDependencies.filter(dep => dep.id !== dependencyId);
|
||||||
|
dispatch(updateTaskCounts({
|
||||||
|
taskId: task.id,
|
||||||
|
counts: {
|
||||||
|
has_dependencies: remainingDependencies.length > 0
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting dependency:', error);
|
console.error('Error deleting dependency:', error);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { ITaskViewModel } from '@/types/tasks/task.types';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { updateRecurringChange } from '@/features/tasks/tasks.slice';
|
import { updateRecurringChange } from '@/features/tasks/tasks.slice';
|
||||||
|
import { updateTaskCounts } from '@/features/task-management/task-management.slice';
|
||||||
import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service';
|
import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice';
|
import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice';
|
||||||
@@ -100,6 +101,14 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
setTaskRecurringSchedule({ schedule_id: schedule.id as string, task_id: task.id })
|
setTaskRecurringSchedule({ schedule_id: schedule.id as string, task_id: task.id })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update Redux state with recurring task status
|
||||||
|
dispatch(updateTaskCounts({
|
||||||
|
taskId: task.id,
|
||||||
|
counts: {
|
||||||
|
schedule_id: schedule.id as string || null
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
setRecurring(checked);
|
setRecurring(checked);
|
||||||
if (!checked) setShowConfig(false);
|
if (!checked) setShowConfig(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Button, Flex, Form, Mentions, Space, Tooltip, Typography, message } fro
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { PaperClipOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
import { PaperClipOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { updateTaskCounts } from '@/features/task-management/task-management.slice';
|
||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||||
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
||||||
@@ -47,6 +49,7 @@ const InfoTabFooter = () => {
|
|||||||
|
|
||||||
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
||||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [members, setMembers] = useState<ITeamMember[]>([]);
|
const [members, setMembers] = useState<ITeamMember[]>([]);
|
||||||
const [membersLoading, setMembersLoading] = useState<boolean>(false);
|
const [membersLoading, setMembersLoading] = useState<boolean>(false);
|
||||||
@@ -156,8 +159,10 @@ const InfoTabFooter = () => {
|
|||||||
setCommentValue('');
|
setCommentValue('');
|
||||||
|
|
||||||
// Dispatch event to notify that a comment was created
|
// Dispatch event to notify that a comment was created
|
||||||
// This will trigger scrolling to the new comment
|
// This will trigger the task comments component to refresh and update Redux
|
||||||
document.dispatchEvent(new Event('task-comment-create'));
|
document.dispatchEvent(new CustomEvent('task-comment-create', {
|
||||||
|
detail: { taskId: selectedTaskId }
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to create comment:', error);
|
logger.error('Failed to create comment:', error);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { TFunction } from 'i18next';
|
import { TFunction } from 'i18next';
|
||||||
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { updateTaskCounts } from '@/features/task-management/task-management.slice';
|
||||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||||
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||||
import { teamMembersApiService } from '@/api/team-members/teamMembers.api.service';
|
import { teamMembersApiService } from '@/api/team-members/teamMembers.api.service';
|
||||||
@@ -27,7 +29,6 @@ import { useAuthService } from '@/hooks/useAuth';
|
|||||||
import Avatars from '@/components/avatars/avatars';
|
import Avatars from '@/components/avatars/avatars';
|
||||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||||
import { setTaskSubscribers } from '@/features/task-drawer/task-drawer.slice';
|
import { setTaskSubscribers } from '@/features/task-drawer/task-drawer.slice';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
||||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
@@ -100,6 +101,14 @@ const NotifyMemberSelector = ({ task, t }: NotifyMemberSelectorProps) => {
|
|||||||
socket?.emit(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), body);
|
socket?.emit(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), body);
|
||||||
socket?.once(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), (data: InlineMember[]) => {
|
socket?.once(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), (data: InlineMember[]) => {
|
||||||
dispatch(setTaskSubscribers(data));
|
dispatch(setTaskSubscribers(data));
|
||||||
|
|
||||||
|
// Update Redux state with subscriber status
|
||||||
|
dispatch(updateTaskCounts({
|
||||||
|
taskId: task.id,
|
||||||
|
counts: {
|
||||||
|
has_subscribers: data && data.length > 0
|
||||||
|
}
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error notifying member:', error);
|
logger.error('Error notifying member:', error);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
|
|||||||
import TaskDetailsForm from './task-details-form';
|
import TaskDetailsForm from './task-details-form';
|
||||||
import { fetchTask } from '@/features/tasks/tasks.slice';
|
import { fetchTask } from '@/features/tasks/tasks.slice';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { updateTaskCounts } from '@/features/task-management/task-management.slice';
|
||||||
import { TFunction } from 'i18next';
|
import { TFunction } from 'i18next';
|
||||||
import { subTasksApiService } from '@/api/tasks/subtasks.api.service';
|
import { subTasksApiService } from '@/api/tasks/subtasks.api.service';
|
||||||
import { ISubTask } from '@/types/tasks/subTask.types';
|
import { ISubTask } from '@/types/tasks/subTask.types';
|
||||||
@@ -24,6 +25,7 @@ import AttachmentsGrid from './attachments/attachments-grid';
|
|||||||
import TaskComments from './comments/task-comments';
|
import TaskComments from './comments/task-comments';
|
||||||
import { ITaskCommentViewModel } from '@/types/tasks/task-comments.types';
|
import { ITaskCommentViewModel } from '@/types/tasks/task-comments.types';
|
||||||
import taskCommentsApiService from '@/api/tasks/task-comments.api.service';
|
import taskCommentsApiService from '@/api/tasks/task-comments.api.service';
|
||||||
|
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||||
|
|
||||||
interface TaskDrawerInfoTabProps {
|
interface TaskDrawerInfoTabProps {
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
@@ -148,7 +150,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
|||||||
label: <Typography.Text strong>{t('taskInfoTab.dependencies.title')}</Typography.Text>,
|
label: <Typography.Text strong>{t('taskInfoTab.dependencies.title')}</Typography.Text>,
|
||||||
children: (
|
children: (
|
||||||
<DependenciesTable
|
<DependenciesTable
|
||||||
task={taskFormViewModel?.task || {}}
|
task={(taskFormViewModel?.task as ITaskViewModel) || {} as ITaskViewModel}
|
||||||
t={t}
|
t={t}
|
||||||
taskDependencies={taskDependencies}
|
taskDependencies={taskDependencies}
|
||||||
loadingTaskDependencies={loadingTaskDependencies}
|
loadingTaskDependencies={loadingTaskDependencies}
|
||||||
@@ -214,6 +216,14 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
|||||||
const res = await taskDependenciesApiService.getTaskDependencies(selectedTaskId);
|
const res = await taskDependenciesApiService.getTaskDependencies(selectedTaskId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
setTaskDependencies(res.body);
|
setTaskDependencies(res.body);
|
||||||
|
|
||||||
|
// Update Redux state with the current dependency status
|
||||||
|
dispatch(updateTaskCounts({
|
||||||
|
taskId: selectedTaskId,
|
||||||
|
counts: {
|
||||||
|
has_dependencies: res.body.length > 0
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching task dependencies:', error);
|
logger.error('Error fetching task dependencies:', error);
|
||||||
@@ -229,6 +239,14 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
|||||||
const res = await taskAttachmentsApiService.getTaskAttachments(selectedTaskId);
|
const res = await taskAttachmentsApiService.getTaskAttachments(selectedTaskId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
setTaskAttachments(res.body);
|
setTaskAttachments(res.body);
|
||||||
|
|
||||||
|
// Update Redux state with the current attachment count
|
||||||
|
dispatch(updateTaskCounts({
|
||||||
|
taskId: selectedTaskId,
|
||||||
|
counts: {
|
||||||
|
attachments_count: res.body.length
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching task attachments:', error);
|
logger.error('Error fetching task attachments:', error);
|
||||||
|
|||||||
@@ -26,12 +26,17 @@ const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visible
|
|||||||
case 'title':
|
case 'title':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle} className="flex items-center">
|
<div style={baseStyle} className="flex items-center">
|
||||||
{/* Subtask indentation - tighter spacing */}
|
|
||||||
<div className="w-4" />
|
<div className="w-4" />
|
||||||
<div className="w-2" />
|
<div className="w-2" />
|
||||||
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
case 'description':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center px-2">
|
||||||
|
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
case 'status':
|
case 'status':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle} className="flex items-center">
|
<div style={baseStyle} className="flex items-center">
|
||||||
@@ -60,7 +65,7 @@ const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visible
|
|||||||
case 'progress':
|
case 'progress':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle} className="flex items-center">
|
<div style={baseStyle} className="flex items-center">
|
||||||
<div className="h-2 w-16 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse" />
|
<div className="h-4 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'labels':
|
case 'labels':
|
||||||
@@ -85,7 +90,7 @@ const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visible
|
|||||||
case 'estimation':
|
case 'estimation':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle} className="flex items-center">
|
<div style={baseStyle} className="flex items-center">
|
||||||
<div className="h-4 w-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
<div className="h-4 w-8 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'startDate':
|
case 'startDate':
|
||||||
@@ -126,7 +131,7 @@ const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visible
|
|||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 dark:bg-gray-800/50 border-l-2 border-blue-200 dark:border-blue-700">
|
<div className="bg-gray-50 dark:bg-gray-800/50 border-l-2 border-blue-200 dark:border-blue-700">
|
||||||
<div className="flex items-center min-w-max px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center min-w-max px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
{visibleColumns.map((column) => (
|
{visibleColumns.map((column, index) => (
|
||||||
<div key={column.id}>
|
<div key={column.id}>
|
||||||
{renderColumn(column.id, column.width)}
|
{renderColumn(column.id, column.width)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -166,8 +166,6 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
setCategoryModalVisible(true);
|
setCategoryModalVisible(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Handle category change
|
// Handle category change
|
||||||
const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => {
|
const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => {
|
||||||
e?.stopPropagation();
|
e?.stopPropagation();
|
||||||
@@ -237,7 +235,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-b border-gray-200 dark:border-gray-700 rounded-t-md ${
|
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
|
||||||
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
@@ -292,107 +290,17 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
<div className="flex items-center flex-1 ml-1">
|
<div className="flex items-center flex-1 ml-1">
|
||||||
{/* Group name and count */}
|
{/* Group name and count */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{isEditingName && isOwnerOrAdmin ? (
|
<span
|
||||||
<Input
|
className="text-sm font-semibold pr-2"
|
||||||
value={editingName}
|
style={{ color: headerTextColor }}
|
||||||
onChange={(e) => setEditingName(e.target.value)}
|
>
|
||||||
onKeyDown={handleNameKeyDown}
|
{group.name}
|
||||||
onBlur={handleNameBlur}
|
</span>
|
||||||
className="text-sm font-semibold px-2 py-1 rounded-md transition-all duration-200 focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
|
||||||
style={{
|
|
||||||
color: headerTextColor,
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
width: `${Math.max(editingName.length * 8 + 16, 80)}px`,
|
|
||||||
minWidth: '80px',
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
|
||||||
border: `1px solid ${headerTextColor}40`,
|
|
||||||
backdropFilter: 'blur(4px)'
|
|
||||||
}}
|
|
||||||
styles={{
|
|
||||||
input: {
|
|
||||||
color: headerTextColor,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
outline: 'none',
|
|
||||||
boxShadow: 'none',
|
|
||||||
padding: '0'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
disabled={isRenaming}
|
|
||||||
placeholder={t('enterGroupName')}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className={`text-sm font-semibold ${isOwnerOrAdmin ? 'cursor-pointer hover:bg-black hover:bg-opacity-10 dark:hover:bg-white dark:hover:bg-opacity-10 rounded px-2 py-1 transition-all duration-200 hover:shadow-sm' : ''}`}
|
|
||||||
onClick={handleNameClick}
|
|
||||||
style={{ color: headerTextColor }}
|
|
||||||
title={isOwnerOrAdmin ? t('clickToEditGroupName') : ''}
|
|
||||||
>
|
|
||||||
{group.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}>
|
<span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}>
|
||||||
({group.count})
|
({group.count})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Three dots menu */}
|
|
||||||
<div className="flex items-center justify-center ml-2">
|
|
||||||
<Dropdown
|
|
||||||
menu={{ items: menuItems }}
|
|
||||||
trigger={['click']}
|
|
||||||
open={dropdownVisible}
|
|
||||||
onOpenChange={setDropdownVisible}
|
|
||||||
placement="bottomLeft"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="p-1 rounded-sm hover:bg-black hover:bg-opacity-10 transition-all duration-200 ease-out"
|
|
||||||
style={{ color: headerTextColor }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setDropdownVisible(!dropdownVisible);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EllipsisHorizontalIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Change Category Modal */}
|
|
||||||
<Modal
|
|
||||||
title="Change Category"
|
|
||||||
open={categoryModalVisible}
|
|
||||||
onCancel={() => setCategoryModalVisible(false)}
|
|
||||||
footer={null}
|
|
||||||
width={400}
|
|
||||||
>
|
|
||||||
<div className="py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{statusCategories?.map((category) => (
|
|
||||||
<div
|
|
||||||
key={category.id}
|
|
||||||
className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
|
||||||
onClick={(e) => category.id && handleCategoryChange(category.id, e)}
|
|
||||||
>
|
|
||||||
<Flex align="center" gap={12}>
|
|
||||||
<Badge color={category.color_code} />
|
|
||||||
<span className="font-medium">{category.name}</span>
|
|
||||||
</Flex>
|
|
||||||
{isChangingCategory && (
|
|
||||||
<div className="text-blue-500">
|
|
||||||
<ArrowPathIcon className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo, useEffect } from 'react';
|
import React, { useCallback, useMemo, useEffect, useState, useRef } from 'react';
|
||||||
import { GroupedVirtuoso } from 'react-virtuoso';
|
import { GroupedVirtuoso } from 'react-virtuoso';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -100,6 +100,13 @@ const TaskListV2: React.FC = () => {
|
|||||||
const customColumns = useAppSelector(selectCustomColumns);
|
const customColumns = useAppSelector(selectCustomColumns);
|
||||||
const loadingColumns = useAppSelector(selectLoadingColumns);
|
const loadingColumns = useAppSelector(selectLoadingColumns);
|
||||||
|
|
||||||
|
// Refs for scroll synchronization
|
||||||
|
const headerScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// State hooks
|
||||||
|
const [initializedFromDatabase, setInitializedFromDatabase] = useState(false);
|
||||||
|
|
||||||
// Configure sensors for drag and drop
|
// Configure sensors for drag and drop
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -156,18 +163,37 @@ const TaskListV2: React.FC = () => {
|
|||||||
const fieldType = column.custom_column_obj?.fieldType;
|
const fieldType = column.custom_column_obj?.fieldType;
|
||||||
let defaultWidth = 160;
|
let defaultWidth = 160;
|
||||||
if (fieldType === 'selection') {
|
if (fieldType === 'selection') {
|
||||||
defaultWidth = 180; // Extra width for selection dropdowns
|
defaultWidth = 150; // Reduced width for selection dropdowns
|
||||||
} else if (fieldType === 'people') {
|
} else if (fieldType === 'people') {
|
||||||
defaultWidth = 170; // Extra width for people with avatars
|
defaultWidth = 170; // Extra width for people with avatars
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map the configuration data structure to the expected format
|
||||||
|
const customColumnObj = column.custom_column_obj || (column as any).configuration;
|
||||||
|
|
||||||
|
// Transform configuration format to custom_column_obj format if needed
|
||||||
|
let transformedColumnObj = customColumnObj;
|
||||||
|
if (customColumnObj && !customColumnObj.fieldType && customColumnObj.field_type) {
|
||||||
|
transformedColumnObj = {
|
||||||
|
...customColumnObj,
|
||||||
|
fieldType: customColumnObj.field_type,
|
||||||
|
numberType: customColumnObj.number_type,
|
||||||
|
labelPosition: customColumnObj.label_position,
|
||||||
|
previewValue: customColumnObj.preview_value,
|
||||||
|
firstNumericColumn: customColumnObj.first_numeric_column_key,
|
||||||
|
secondNumericColumn: customColumnObj.second_numeric_column_key,
|
||||||
|
selectionsList: customColumnObj.selections_list || customColumnObj.selectionsList || [],
|
||||||
|
labelsList: customColumnObj.labels_list || customColumnObj.labelsList || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: column.key || column.id || 'unknown',
|
id: column.key || column.id || 'unknown',
|
||||||
label: column.name || t('customColumns.customColumnHeader'),
|
label: column.name || t('customColumns.customColumnHeader'),
|
||||||
width: `${(column as any).width || defaultWidth}px`,
|
width: `${(column as any).width || defaultWidth}px`,
|
||||||
key: column.key || column.id || 'unknown',
|
key: column.key || column.id || 'unknown',
|
||||||
custom_column: true,
|
custom_column: true,
|
||||||
custom_column_obj: column.custom_column_obj || (column as any).configuration,
|
custom_column_obj: transformedColumnObj,
|
||||||
isCustom: true,
|
isCustom: true,
|
||||||
name: column.name,
|
name: column.name,
|
||||||
uuid: column.id,
|
uuid: column.id,
|
||||||
@@ -177,36 +203,6 @@ const TaskListV2: React.FC = () => {
|
|||||||
return [...baseVisibleColumns, ...visibleCustomColumns];
|
return [...baseVisibleColumns, ...visibleCustomColumns];
|
||||||
}, [fields, columns, customColumns, t]);
|
}, [fields, columns, customColumns, t]);
|
||||||
|
|
||||||
// Sync local field changes with backend column configuration (debounced)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!urlProjectId || columns.length === 0 || fields.length === 0) return;
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
const changedFields = fields.filter(field => {
|
|
||||||
const backendColumn = columns.find(c => c.key === field.key);
|
|
||||||
if (backendColumn) {
|
|
||||||
return (backendColumn.pinned ?? false) !== field.visible;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
changedFields.forEach(field => {
|
|
||||||
const backendColumn = columns.find(c => c.key === field.key);
|
|
||||||
if (backendColumn) {
|
|
||||||
dispatch(updateColumnVisibility({
|
|
||||||
projectId: urlProjectId,
|
|
||||||
item: {
|
|
||||||
...backendColumn,
|
|
||||||
pinned: field.visible
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}, [fields, columns, urlProjectId, dispatch]);
|
|
||||||
|
|
||||||
// Effects
|
// Effects
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (urlProjectId) {
|
if (urlProjectId) {
|
||||||
@@ -215,6 +211,37 @@ const TaskListV2: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [dispatch, urlProjectId]);
|
}, [dispatch, urlProjectId]);
|
||||||
|
|
||||||
|
// Initialize field visibility from database when columns are loaded (only once)
|
||||||
|
useEffect(() => {
|
||||||
|
if (columns.length > 0 && fields.length > 0 && !initializedFromDatabase) {
|
||||||
|
// Update local fields to match database state only on initial load
|
||||||
|
import('@/features/task-management/taskListFields.slice').then(({ setFields }) => {
|
||||||
|
// Create updated fields based on database column state
|
||||||
|
const updatedFields = fields.map(field => {
|
||||||
|
const backendColumn = columns.find(c => c.key === field.key);
|
||||||
|
if (backendColumn) {
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
visible: backendColumn.pinned ?? field.visible
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only update if there are actual changes
|
||||||
|
const hasChanges = updatedFields.some((field, index) =>
|
||||||
|
field.visible !== fields[index].visible
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
dispatch(setFields(updatedFields));
|
||||||
|
}
|
||||||
|
|
||||||
|
setInitializedFromDatabase(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [columns, fields, dispatch, initializedFromDatabase]);
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
const handleTaskSelect = useCallback(
|
const handleTaskSelect = useCallback(
|
||||||
(taskId: string, event: React.MouseEvent) => {
|
(taskId: string, event: React.MouseEvent) => {
|
||||||
@@ -256,6 +283,24 @@ const TaskListV2: React.FC = () => {
|
|||||||
project_id: urlProjectId,
|
project_id: urlProjectId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Update the Redux store immediately for optimistic updates
|
||||||
|
const currentTask = allTasks.find(task => task.id === taskId);
|
||||||
|
if (currentTask) {
|
||||||
|
const updatedTask = {
|
||||||
|
...currentTask,
|
||||||
|
custom_column_values: {
|
||||||
|
...currentTask.custom_column_values,
|
||||||
|
[columnKey]: value,
|
||||||
|
},
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Import and dispatch the updateTask action
|
||||||
|
import('@/features/task-management/task-management.slice').then(({ updateTask }) => {
|
||||||
|
dispatch(updateTask(updatedTask));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (socket && connected) {
|
if (socket && connected) {
|
||||||
socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body));
|
socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body));
|
||||||
} else {
|
} else {
|
||||||
@@ -264,7 +309,7 @@ const TaskListV2: React.FC = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating custom column value:', error);
|
console.error('Error updating custom column value:', error);
|
||||||
}
|
}
|
||||||
}, [urlProjectId, socket, connected]);
|
}, [urlProjectId, socket, connected, allTasks, dispatch]);
|
||||||
|
|
||||||
// Custom column settings handler
|
// Custom column settings handler
|
||||||
const handleCustomColumnSettings = useCallback((columnKey: string) => {
|
const handleCustomColumnSettings = useCallback((columnKey: string) => {
|
||||||
@@ -272,9 +317,13 @@ const TaskListV2: React.FC = () => {
|
|||||||
|
|
||||||
const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey);
|
const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey);
|
||||||
|
|
||||||
|
// Use the UUID for API calls, not the key (nanoid)
|
||||||
|
// For custom columns, prioritize the uuid field over id field
|
||||||
|
const columnId = (columnData as any)?.uuid || columnData?.id || columnKey;
|
||||||
|
|
||||||
dispatch(setCustomColumnModalAttributes({
|
dispatch(setCustomColumnModalAttributes({
|
||||||
modalType: 'edit',
|
modalType: 'edit',
|
||||||
columnId: columnKey,
|
columnId: columnId,
|
||||||
columnData: columnData
|
columnData: columnData
|
||||||
}));
|
}));
|
||||||
dispatch(toggleCustomColumnModalOpen(true));
|
dispatch(toggleCustomColumnModalOpen(true));
|
||||||
@@ -286,6 +335,11 @@ const TaskListV2: React.FC = () => {
|
|||||||
// The global socket handler will handle the real-time update
|
// The global socket handler will handle the real-time update
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle scroll synchronization - disabled since header is now sticky inside content
|
||||||
|
const handleContentScroll = useCallback(() => {
|
||||||
|
// No longer needed since header scrolls naturally with content
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Memoized values for GroupedVirtuoso
|
// Memoized values for GroupedVirtuoso
|
||||||
const virtuosoGroups = useMemo(() => {
|
const virtuosoGroups = useMemo(() => {
|
||||||
let currentTaskIndex = 0;
|
let currentTaskIndex = 0;
|
||||||
@@ -344,6 +398,8 @@ const TaskListV2: React.FC = () => {
|
|||||||
const isGroupCollapsed = collapsedGroups.has(group.id);
|
const isGroupCollapsed = collapsedGroups.has(group.id);
|
||||||
const isGroupEmpty = group.actualCount === 0;
|
const isGroupEmpty = group.actualCount === 0;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={groupIndex > 0 ? 'mt-2' : ''}>
|
<div className={groupIndex > 0 ? 'mt-2' : ''}>
|
||||||
<TaskGroupHeader
|
<TaskGroupHeader
|
||||||
@@ -360,7 +416,7 @@ const TaskListV2: React.FC = () => {
|
|||||||
{isGroupEmpty && !isGroupCollapsed && (
|
{isGroupEmpty && !isGroupCollapsed && (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<div className="flex items-center min-w-max px-1 py-3">
|
<div className="flex items-center min-w-max px-1 py-3">
|
||||||
{visibleColumns.map((column) => (
|
{visibleColumns.map((column, index) => (
|
||||||
<div
|
<div
|
||||||
key={`empty-${column.id}`}
|
key={`empty-${column.id}`}
|
||||||
style={{ width: column.width, flexShrink: 0 }}
|
style={{ width: column.width, flexShrink: 0 }}
|
||||||
@@ -383,6 +439,8 @@ const TaskListV2: React.FC = () => {
|
|||||||
const renderTask = useCallback(
|
const renderTask = useCallback(
|
||||||
(taskIndex: number) => {
|
(taskIndex: number) => {
|
||||||
const item = virtuosoItems[taskIndex];
|
const item = virtuosoItems[taskIndex];
|
||||||
|
|
||||||
|
|
||||||
if (!item || !urlProjectId) return null;
|
if (!item || !urlProjectId) return null;
|
||||||
|
|
||||||
if ('isAddTaskRow' in item && item.isAddTaskRow) {
|
if ('isAddTaskRow' in item && item.isAddTaskRow) {
|
||||||
@@ -412,53 +470,77 @@ const TaskListV2: React.FC = () => {
|
|||||||
|
|
||||||
// Render column headers
|
// Render column headers
|
||||||
const renderColumnHeaders = useCallback(() => (
|
const renderColumnHeaders = useCallback(() => (
|
||||||
<div className="sticky top-0 z-30 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
<div className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700" style={{ width: '100%', minWidth: 'max-content' }}>
|
||||||
<div className="flex items-center px-1 py-3 w-full" style={{ minWidth: 'max-content', height: '44px' }}>
|
<div className="flex items-center px-3 py-3 w-full" style={{ minWidth: 'max-content', height: '44px' }}>
|
||||||
{visibleColumns.map(column => {
|
{visibleColumns.map((column, index) => {
|
||||||
const columnStyle: ColumnStyle = {
|
const columnStyle: ColumnStyle = {
|
||||||
width: column.width,
|
width: column.width,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
...(column.id === 'labels' && column.width === 'auto'
|
...(column.id === 'labels' && column.width === 'auto'
|
||||||
? {
|
? {
|
||||||
minWidth: '200px',
|
minWidth: '200px',
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...((column as any).minWidth && { minWidth: (column as any).minWidth }),
|
...((column as any).minWidth && { minWidth: (column as any).minWidth }),
|
||||||
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
|
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={column.id}
|
key={column.id}
|
||||||
className={`text-sm font-semibold text-gray-600 dark:text-gray-300 ${
|
className={`text-sm font-semibold text-gray-600 dark:text-gray-300 ${
|
||||||
column.id === 'taskKey' ? 'pl-3' : ''
|
column.id === 'taskKey' ? 'pl-3' : ''
|
||||||
}`}
|
}`}
|
||||||
style={columnStyle}
|
style={columnStyle}
|
||||||
>
|
>
|
||||||
{column.id === 'dragHandle' || column.id === 'checkbox' ? (
|
{column.id === 'dragHandle' || column.id === 'checkbox' ? (
|
||||||
<span></span>
|
<span></span>
|
||||||
) : (column as any).isCustom ? (
|
) : (column as any).isCustom ? (
|
||||||
<CustomColumnHeader
|
<CustomColumnHeader
|
||||||
column={column}
|
column={column}
|
||||||
onSettingsClick={handleCustomColumnSettings}
|
onSettingsClick={handleCustomColumnSettings}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
t(column.label || '')
|
t(column.label || '')
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="flex items-center justify-center" style={{ width: '60px', flexShrink: 0 }}>
|
|
||||||
<AddCustomColumnButton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Add Custom Column Button - positioned at the end and scrolls with content */}
|
||||||
|
<div className="flex items-center justify-center ml-2" style={{ width: '50px', flexShrink: 0 }}>
|
||||||
|
<AddCustomColumnButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
), [visibleColumns, t, handleCustomColumnSettings]);
|
), [visibleColumns, t, handleCustomColumnSettings]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Loading and error states
|
// Loading and error states
|
||||||
if (loading || loadingColumns) return <Skeleton active />;
|
if (loading || loadingColumns) return <Skeleton active />;
|
||||||
if (error) return <div>Error: {error}</div>;
|
if (error) return <div>Error: {error}</div>;
|
||||||
|
|
||||||
|
// Show message when no data
|
||||||
|
if (groups.length === 0 && !loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col bg-white dark:bg-gray-900 h-full">
|
||||||
|
<div className="flex-none px-6 py-4" style={{ height: '74px', flexShrink: 0 }}>
|
||||||
|
<ImprovedTaskFilters position="list" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
No task groups found
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Tasks will appear here when they are created or when filters are applied.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
@@ -468,51 +550,65 @@ const TaskListV2: React.FC = () => {
|
|||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col bg-white dark:bg-gray-900" style={{ height: '100vh', overflow: 'hidden' }}>
|
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
|
||||||
{/* Task Filters */}
|
{/* Task Filters */}
|
||||||
<div className="flex-none px-4 py-3" style={{ height: '66px', flexShrink: 0 }}>
|
<div className="flex-none px-6 py-4" style={{ height: '74px', flexShrink: 0 }}>
|
||||||
<ImprovedTaskFilters position="list" />
|
<ImprovedTaskFilters position="list" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Spacing between filters and table */}
|
||||||
|
<div className="flex-none h-4" style={{ flexShrink: 0 }}></div>
|
||||||
|
|
||||||
{/* Table Container */}
|
{/* Table Container */}
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-auto border border-gray-200 dark:border-gray-700"
|
className="border border-gray-200 dark:border-gray-700 mx-6 rounded-lg"
|
||||||
style={{
|
style={{
|
||||||
height: '600px',
|
height: 'calc(100vh - 140px)',
|
||||||
maxHeight: '600px'
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 'max-content' }}>
|
{/* Task List Content with Sticky Header */}
|
||||||
{/* Column Headers */}
|
<div
|
||||||
{renderColumnHeaders()}
|
ref={contentScrollRef}
|
||||||
|
className="flex-1 bg-white dark:bg-gray-900 relative"
|
||||||
{/* Task List Content */}
|
style={{ overflowX: 'auto', overflowY: 'auto', minHeight: 0 }}
|
||||||
<div className="bg-white dark:bg-gray-900">
|
>
|
||||||
<SortableContext
|
{/* Sticky Column Headers */}
|
||||||
items={virtuosoItems
|
<div className="sticky top-0 z-30 bg-gray-50 dark:bg-gray-800" style={{ width: '100%', minWidth: 'max-content' }}>
|
||||||
.filter(item => !('isAddTaskRow' in item) && !item.parent_task_id)
|
{renderColumnHeaders()}
|
||||||
.map(item => item.id)
|
|
||||||
.filter((id): id is string => id !== undefined)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<GroupedVirtuoso
|
|
||||||
style={{ height: '550px' }}
|
|
||||||
groupCounts={virtuosoGroupCounts}
|
|
||||||
groupContent={renderGroup}
|
|
||||||
itemContent={renderTask}
|
|
||||||
components={{
|
|
||||||
List: React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
{ style?: React.CSSProperties; children?: React.ReactNode }
|
|
||||||
>(({ style, children }, ref) => (
|
|
||||||
<div ref={ref} style={style || {}} className="virtuoso-list-container bg-white dark:bg-gray-900">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SortableContext>
|
|
||||||
</div>
|
</div>
|
||||||
|
<SortableContext
|
||||||
|
items={virtuosoItems
|
||||||
|
.filter(item => !('isAddTaskRow' in item) && !item.parent_task_id)
|
||||||
|
.map(item => item.id)
|
||||||
|
.filter((id): id is string => id !== undefined)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 'max-content' }}>
|
||||||
|
{/* Render groups manually for debugging */}
|
||||||
|
{virtuosoGroups.map((group, groupIndex) => (
|
||||||
|
<div key={group.id}>
|
||||||
|
{/* Group Header */}
|
||||||
|
{renderGroup(groupIndex)}
|
||||||
|
|
||||||
|
{/* Group Tasks */}
|
||||||
|
{!collapsedGroups.has(group.id) && group.tasks.map((task, taskIndex) => {
|
||||||
|
const globalTaskIndex = virtuosoGroups
|
||||||
|
.slice(0, groupIndex)
|
||||||
|
.reduce((sum, g) => sum + g.count, 0) + taskIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
|
||||||
|
{renderTask(globalTaskIndex)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { memo, useMemo, useCallback, useState } from 'react';
|
import React, { memo, useMemo, useCallback, useState } from 'react';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { CheckCircleOutlined, HolderOutlined, CloseOutlined, DownOutlined, RightOutlined, DoubleRightOutlined, ArrowsAltOutlined } from '@ant-design/icons';
|
import { CheckCircleOutlined, HolderOutlined, CloseOutlined, DownOutlined, RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons';
|
||||||
import { Checkbox, DatePicker } from 'antd';
|
import { Checkbox, DatePicker, Tooltip } from 'antd';
|
||||||
import { dayjs, taskManagementAntdConfig } from '@/shared/antd-imports';
|
import { dayjs, taskManagementAntdConfig } from '@/shared/antd-imports';
|
||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
@@ -190,7 +190,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
name: label.name,
|
name: label.name,
|
||||||
color_code: label.color,
|
color_code: label.color,
|
||||||
})) || [],
|
})) || [],
|
||||||
}), [task.id, task.title, task.name, task.parent_task_id, task.labels]);
|
}), [task.id, task.title, task.name, task.parent_task_id, task.labels, task.labels?.length]);
|
||||||
|
|
||||||
// Handle checkbox change
|
// Handle checkbox change
|
||||||
const handleCheckboxChange = useCallback((e: any) => {
|
const handleCheckboxChange = useCallback((e: any) => {
|
||||||
@@ -261,7 +261,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
case 'dragHandle':
|
case 'dragHandle':
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-center ${isSubtask ? '' : 'cursor-grab active:cursor-grabbing'}`}
|
className="flex items-center justify-center"
|
||||||
style={baseStyle}
|
style={baseStyle}
|
||||||
{...(isSubtask ? {} : { ...attributes, ...listeners })}
|
{...(isSubtask ? {} : { ...attributes, ...listeners })}
|
||||||
>
|
>
|
||||||
@@ -301,7 +301,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
<button
|
<button
|
||||||
onClick={handleToggleExpansion}
|
onClick={handleToggleExpansion}
|
||||||
className={`flex h-4 w-4 items-center justify-center rounded-sm text-xs mr-1 hover:border hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:scale-110 transition-all duration-300 ease-out ${
|
className={`flex h-4 w-4 items-center justify-center rounded-sm text-xs mr-1 hover:border hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:scale-110 transition-all duration-300 ease-out ${
|
||||||
task.sub_tasks_count && Number(task.sub_tasks_count) > 0
|
task.sub_tasks_count != null && Number(task.sub_tasks_count) > 0
|
||||||
? 'opacity-100'
|
? 'opacity-100'
|
||||||
: 'opacity-0 group-hover:opacity-100'
|
: 'opacity-0 group-hover:opacity-100'
|
||||||
}`}
|
}`}
|
||||||
@@ -326,15 +326,74 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
{taskDisplayName}
|
{taskDisplayName}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Subtask count indicator */}
|
{/* Subtask count indicator - only show if count > 1 */}
|
||||||
{!isSubtask && task.sub_tasks_count && Number(task.sub_tasks_count) > 0 && (
|
{!isSubtask && task.sub_tasks_count != null && task.sub_tasks_count !== 0 && (
|
||||||
<div className="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
<Tooltip title={t(`indicators.tooltips.subtasks${task.sub_tasks_count === 1 ? '' : '_plural'}`, { count: task.sub_tasks_count })}>
|
||||||
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
|
<div className="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||||
{task.sub_tasks_count}
|
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
|
||||||
</span>
|
{task.sub_tasks_count}
|
||||||
<DoubleRightOutlined className="text-xs text-blue-600 dark:text-blue-400" />
|
</span>
|
||||||
</div>
|
<DoubleRightOutlined className="text-xs text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Task indicators */}
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
{/* Comments count indicator - only show if count > 1 */}
|
||||||
|
{task.comments_count != null && task.comments_count !== 0 && (
|
||||||
|
<Tooltip title={t(`indicators.tooltips.comments${task.comments_count === 1 ? '' : '_plural'}`, { count: task.comments_count })}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<CommentOutlined
|
||||||
|
className="text-gray-500 dark:text-gray-400"
|
||||||
|
style={{ fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subscribers indicator */}
|
||||||
|
{task.has_subscribers && (
|
||||||
|
<Tooltip title={t('indicators.tooltips.subscribers')}>
|
||||||
|
<EyeOutlined
|
||||||
|
className="text-gray-500 dark:text-gray-400"
|
||||||
|
style={{ fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attachments count indicator - only show if count > 1 */}
|
||||||
|
{task.attachments_count != null && task.attachments_count !== 0 && (
|
||||||
|
<Tooltip title={t(`indicators.tooltips.attachments${task.attachments_count === 1 ? '' : '_plural'}`, { count: task.attachments_count })}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<PaperClipOutlined
|
||||||
|
className="text-gray-500 dark:text-gray-400"
|
||||||
|
style={{ fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dependencies indicator */}
|
||||||
|
{task.has_dependencies && (
|
||||||
|
<Tooltip title={t('indicators.tooltips.dependencies')}>
|
||||||
|
<MinusCircleOutlined
|
||||||
|
className="text-gray-500 dark:text-gray-400"
|
||||||
|
style={{ fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recurring task indicator */}
|
||||||
|
{task.schedule_id && (
|
||||||
|
<Tooltip title={t('indicators.tooltips.recurring')}>
|
||||||
|
<RetweetOutlined
|
||||||
|
className="text-gray-500 dark:text-gray-400"
|
||||||
|
style={{ fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -352,6 +411,24 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'description':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="px-2">
|
||||||
|
<div
|
||||||
|
className="text-sm text-gray-600 dark:text-gray-400 truncate"
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
maxHeight: '24px',
|
||||||
|
lineHeight: '24px',
|
||||||
|
}}
|
||||||
|
title={task.description || ''}
|
||||||
|
dangerouslySetInnerHTML={{ __html: task.description || '' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
case 'status':
|
case 'status':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle}>
|
||||||
@@ -636,8 +713,15 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
isDarkMode,
|
isDarkMode,
|
||||||
projectId,
|
projectId,
|
||||||
|
|
||||||
// Task data
|
// Task data - include specific fields that might update via socket
|
||||||
task,
|
task,
|
||||||
|
task.labels, // Explicit dependency for labels updates
|
||||||
|
task.phase, // Explicit dependency for phase updates
|
||||||
|
task.comments_count, // Explicit dependency for comments count updates
|
||||||
|
task.has_subscribers, // Explicit dependency for subscribers updates
|
||||||
|
task.attachments_count, // Explicit dependency for attachments count updates
|
||||||
|
task.has_dependencies, // Explicit dependency for dependencies updates
|
||||||
|
task.schedule_id, // Explicit dependency for recurring task updates
|
||||||
taskDisplayName,
|
taskDisplayName,
|
||||||
convertedTask,
|
convertedTask,
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
|||||||
return <div style={baseStyle} />;
|
return <div style={baseStyle} />;
|
||||||
case 'taskKey':
|
case 'taskKey':
|
||||||
return <div style={baseStyle} />;
|
return <div style={baseStyle} />;
|
||||||
|
case 'description':
|
||||||
|
return <div style={baseStyle} />;
|
||||||
case 'title':
|
case 'title':
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-full" style={baseStyle}>
|
<div className="flex items-center h-full" style={baseStyle}>
|
||||||
@@ -119,7 +121,7 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
|||||||
className="w-full h-full border-none shadow-none bg-transparent"
|
className="w-full h-full border-none shadow-none bg-transparent"
|
||||||
style={{
|
style={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
minHeight: '42px',
|
minHeight: '32px',
|
||||||
padding: '0',
|
padding: '0',
|
||||||
fontSize: '14px'
|
fontSize: '14px'
|
||||||
}}
|
}}
|
||||||
@@ -135,10 +137,12 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
|||||||
}, [isAdding, subtaskName, handleAddSubtask, handleCancel, t]);
|
}, [isAdding, subtaskName, handleAddSubtask, handleCancel, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center min-w-max px-1 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[42px]">
|
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px] border-b border-gray-200 dark:border-gray-700">
|
||||||
{visibleColumns.map((column) =>
|
{visibleColumns.map((column, index) => (
|
||||||
renderColumn(column.id, column.width)
|
<React.Fragment key={column.id}>
|
||||||
)}
|
{renderColumn(column.id, column.width)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
|||||||
case 'dragHandle':
|
case 'dragHandle':
|
||||||
case 'checkbox':
|
case 'checkbox':
|
||||||
case 'taskKey':
|
case 'taskKey':
|
||||||
|
case 'description':
|
||||||
return <div style={baseStyle} />;
|
return <div style={baseStyle} />;
|
||||||
case 'title':
|
case 'title':
|
||||||
return (
|
return (
|
||||||
@@ -134,9 +135,11 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px] border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px] border-b border-gray-200 dark:border-gray-700">
|
||||||
{visibleColumns.map((column) =>
|
{visibleColumns.map((column, index) => (
|
||||||
renderColumn(column.id, column.width)
|
<React.Fragment key={column.id}>
|
||||||
)}
|
{renderColumn(column.id, column.width)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const AddCustomColumnButton: React.FC = memo(() => {
|
|||||||
<button
|
<button
|
||||||
onClick={handleModalOpen}
|
onClick={handleModalOpen}
|
||||||
className={`
|
className={`
|
||||||
group relative w-8 h-8 rounded-lg border-2 border-dashed transition-all duration-200
|
group relative w-9 h-9 rounded-lg border-2 border-dashed transition-all duration-200
|
||||||
flex items-center justify-center
|
flex items-center justify-center
|
||||||
${isDarkMode
|
${isDarkMode
|
||||||
? 'border-gray-600 hover:border-blue-500 hover:bg-blue-500/10 text-gray-500 hover:text-blue-400'
|
? 'border-gray-600 hover:border-blue-500 hover:bg-blue-500/10 text-gray-500 hover:text-blue-400'
|
||||||
@@ -37,7 +37,7 @@ export const AddCustomColumnButton: React.FC = memo(() => {
|
|||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<PlusOutlined className="text-xs transition-transform duration-200 group-hover:scale-110" />
|
<PlusOutlined className="text-sm transition-transform duration-200 group-hover:scale-110" />
|
||||||
|
|
||||||
{/* Subtle glow effect on hover */}
|
{/* Subtle glow effect on hover */}
|
||||||
<div className={`
|
<div className={`
|
||||||
@@ -60,6 +60,7 @@ export const CustomColumnHeader: React.FC<{
|
|||||||
onSettingsClick: (columnId: string) => void;
|
onSettingsClick: (columnId: string) => void;
|
||||||
}> = ({ column, onSettingsClick }) => {
|
}> = ({ column, onSettingsClick }) => {
|
||||||
const { t } = useTranslation('task-list-table');
|
const { t } = useTranslation('task-list-table');
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
const displayName = column.name ||
|
const displayName = column.name ||
|
||||||
column.label ||
|
column.label ||
|
||||||
@@ -68,15 +69,20 @@ export const CustomColumnHeader: React.FC<{
|
|||||||
t('customColumns.customColumnHeader');
|
t('customColumns.customColumnHeader');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex align="center" justify="space-between" className="w-full px-2">
|
<Flex
|
||||||
<span title={displayName}>{displayName}</span>
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
className="w-full px-2 group cursor-pointer"
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
onClick={() => onSettingsClick(column.key || column.id)}
|
||||||
|
>
|
||||||
|
<span title={displayName} className="truncate flex-1 mr-2">{displayName}</span>
|
||||||
<Tooltip title={t('customColumns.customColumnSettings')}>
|
<Tooltip title={t('customColumns.customColumnSettings')}>
|
||||||
<SettingOutlined
|
<SettingOutlined
|
||||||
className="cursor-pointer hover:text-primary"
|
className={`hover:text-blue-600 dark:hover:text-blue-400 transition-all duration-200 flex-shrink-0 ${
|
||||||
onClick={e => {
|
isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
|
||||||
e.stopPropagation();
|
}`}
|
||||||
onSettingsClick(column.key || column.id);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -278,14 +284,14 @@ export const DateCustomColumnCell: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`px-2 relative custom-column-cell ${isOpen ? 'focused' : ''}`}>
|
<div className={`px-2 relative custom-column-cell ${isOpen ? 'custom-column-focused' : ''}`}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={setIsOpen}
|
onOpenChange={setIsOpen}
|
||||||
value={dateValue}
|
value={dateValue}
|
||||||
onChange={handleDateChange}
|
onChange={handleDateChange}
|
||||||
placeholder={dateValue ? "" : "Click to set date"}
|
placeholder={dateValue ? "" : "Set date"}
|
||||||
format="MMM DD, YYYY"
|
format="MMM DD, YYYY"
|
||||||
suffixIcon={null}
|
suffixIcon={null}
|
||||||
size="small"
|
size="small"
|
||||||
@@ -440,15 +446,22 @@ export const SelectionCustomColumnCell: React.FC<{
|
|||||||
const selectedOption = selectionsList.find((option: any) => option.selection_name === customValue);
|
const selectedOption = selectionsList.find((option: any) => option.selection_name === customValue);
|
||||||
|
|
||||||
const handleOptionSelect = async (option: any) => {
|
const handleOptionSelect = async (option: any) => {
|
||||||
|
if (!task.id) return;
|
||||||
|
|
||||||
|
setIsDropdownOpen(false);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (task.id) {
|
// Send the update to the server - Redux store will be updated immediately
|
||||||
updateTaskCustomColumnValue(task.id, columnKey, option.selection_name);
|
updateTaskCustomColumnValue(task.id, columnKey, option.selection_name);
|
||||||
}
|
|
||||||
setIsDropdownOpen(false);
|
// Short loading state for visual feedback
|
||||||
} finally {
|
setTimeout(() => {
|
||||||
// Small delay to show loading state
|
setIsLoading(false);
|
||||||
setTimeout(() => setIsLoading(false), 200);
|
}, 200);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating selection:', error);
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -468,7 +481,7 @@ export const SelectionCustomColumnCell: React.FC<{
|
|||||||
: 'border-gray-200 text-gray-600 bg-gray-50'
|
: 'border-gray-200 text-gray-600 bg-gray-50'
|
||||||
}
|
}
|
||||||
`}>
|
`}>
|
||||||
Select an option
|
Select option
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Options */}
|
{/* Options */}
|
||||||
@@ -521,7 +534,7 @@ export const SelectionCustomColumnCell: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`px-2 relative custom-column-cell ${isDropdownOpen ? 'focused' : ''}`}>
|
<div className={`px-2 relative custom-column-cell ${isDropdownOpen ? 'custom-column-focused' : ''}`}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
open={isDropdownOpen}
|
open={isDropdownOpen}
|
||||||
onOpenChange={setIsDropdownOpen}
|
onOpenChange={setIsDropdownOpen}
|
||||||
@@ -569,7 +582,7 @@ export const SelectionCustomColumnCell: React.FC<{
|
|||||||
<>
|
<>
|
||||||
<div className={`w-3 h-3 rounded-full border-2 border-dashed ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`} />
|
<div className={`w-3 h-3 rounded-full border-2 border-dashed ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`} />
|
||||||
<span className={`text-sm ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`}>
|
<span className={`text-sm ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`}>
|
||||||
Select option
|
Select
|
||||||
</span>
|
</span>
|
||||||
<svg className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const BASE_COLUMNS = [
|
|||||||
{ id: 'checkbox', label: '', width: '28px', isSticky: true, key: 'checkbox' },
|
{ id: 'checkbox', label: '', width: '28px', isSticky: true, key: 'checkbox' },
|
||||||
{ id: 'taskKey', label: 'keyColumn', width: '100px', key: COLUMN_KEYS.KEY, minWidth: '100px', maxWidth: '150px' },
|
{ id: 'taskKey', label: 'keyColumn', width: '100px', key: COLUMN_KEYS.KEY, minWidth: '100px', maxWidth: '150px' },
|
||||||
{ id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
|
{ id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
|
||||||
|
{ id: 'description', label: 'descriptionColumn', width: '260px', key: COLUMN_KEYS.DESCRIPTION },
|
||||||
{ id: 'status', label: 'statusColumn', width: '120px', key: COLUMN_KEYS.STATUS },
|
{ id: 'status', label: 'statusColumn', width: '120px', key: COLUMN_KEYS.STATUS },
|
||||||
{ id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
|
{ id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
|
||||||
{ id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY },
|
{ id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY },
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import { useAppSelector } from '@/hooks/useAppSelector';
|
|||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||||
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
|
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
|
||||||
import { toggleField } from '@/features/task-management/taskListFields.slice';
|
import { toggleField, syncFieldWithDatabase } from '@/features/task-management/taskListFields.slice';
|
||||||
|
import { selectColumns } from '@/features/task-management/task-management.slice';
|
||||||
|
|
||||||
// Import Redux actions
|
// Import Redux actions
|
||||||
import {
|
import {
|
||||||
@@ -372,6 +373,7 @@ const FilterDropdown: React.FC<{
|
|||||||
isDarkMode,
|
isDarkMode,
|
||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation('task-list-filters');
|
||||||
// Add permission checks for groupBy section
|
// Add permission checks for groupBy section
|
||||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||||
const isProjectManager = useIsProjectManager();
|
const isProjectManager = useIsProjectManager();
|
||||||
@@ -440,14 +442,14 @@ const FilterDropdown: React.FC<{
|
|||||||
{/* Trigger Button */}
|
{/* Trigger Button */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className={`
|
className={`
|
||||||
inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md
|
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
|
border transition-all duration-200 ease-in-out
|
||||||
${
|
${
|
||||||
selectedCount > 0
|
selectedCount > 0
|
||||||
? isDarkMode
|
? isDarkMode
|
||||||
? 'bg-gray-600 text-white border-gray-500'
|
? 'bg-gray-600 text-white border-gray-500'
|
||||||
: 'bg-blue-50 text-blue-800 border-blue-300 font-semibold'
|
: 'bg-gray-200 text-gray-800 border-gray-300 font-semibold'
|
||||||
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
|
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
|
||||||
}
|
}
|
||||||
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2
|
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2
|
||||||
@@ -458,7 +460,14 @@ const FilterDropdown: React.FC<{
|
|||||||
>
|
>
|
||||||
<IconComponent className="w-3.5 h-3.5" />
|
<IconComponent className="w-3.5 h-3.5" />
|
||||||
<span>{section.label}</span>
|
<span>{section.label}</span>
|
||||||
{selectedCount > 0 && (
|
{/* Show selected option for single-select (group by) */}
|
||||||
|
{section.id === 'groupBy' && selectedCount > 0 && (
|
||||||
|
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||||
|
{section.options.find(opt => opt.value === section.selectedValues[0])?.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Show count for multi-select filters */}
|
||||||
|
{section.id !== 'groupBy' && selectedCount > 0 && (
|
||||||
<span className="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-gray-500 rounded-full">
|
<span className="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-gray-500 rounded-full">
|
||||||
{selectedCount}
|
{selectedCount}
|
||||||
</span>
|
</span>
|
||||||
@@ -489,7 +498,7 @@ const FilterDropdown: React.FC<{
|
|||||||
<input
|
<input
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
placeholder={`Search ${section.label.toLowerCase()}...`}
|
placeholder={`${t('searchPlaceholder')} ${section.label.toLowerCase()}...`}
|
||||||
className={`w-full pl-8 pr-2 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-150 ${
|
className={`w-full pl-8 pr-2 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-150 ${
|
||||||
isDarkMode
|
isDarkMode
|
||||||
? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600'
|
? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600'
|
||||||
@@ -504,7 +513,7 @@ const FilterDropdown: React.FC<{
|
|||||||
<div className="max-h-48 overflow-y-auto">
|
<div className="max-h-48 overflow-y-auto">
|
||||||
{filteredOptions.length === 0 ? (
|
{filteredOptions.length === 0 ? (
|
||||||
<div className={`p-2 text-xs text-center ${themeClasses.secondaryText}`}>
|
<div className={`p-2 text-xs text-center ${themeClasses.secondaryText}`}>
|
||||||
No options found
|
{t('noOptionsFound')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-0.5">
|
<div className="p-0.5">
|
||||||
@@ -522,24 +531,26 @@ const FilterDropdown: React.FC<{
|
|||||||
isSelected
|
isSelected
|
||||||
? isDarkMode
|
? isDarkMode
|
||||||
? 'bg-gray-600 text-white'
|
? 'bg-gray-600 text-white'
|
||||||
: 'bg-blue-50 text-blue-800 font-semibold'
|
: 'bg-gray-200 text-gray-800 font-semibold'
|
||||||
: `${themeClasses.optionText} ${themeClasses.optionHover}`
|
: `${themeClasses.optionText} ${themeClasses.optionHover}`
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Checkbox/Radio indicator */}
|
{/* Checkbox/Radio indicator - hide for group by */}
|
||||||
<div
|
{section.id !== 'groupBy' && (
|
||||||
className={`
|
<div
|
||||||
flex items-center justify-center w-3.5 h-3.5 border rounded
|
className={`
|
||||||
${
|
flex items-center justify-center w-3.5 h-3.5 border rounded
|
||||||
isSelected
|
${
|
||||||
? 'bg-gray-600 border-gray-800 text-white'
|
isSelected
|
||||||
: 'border-gray-300 dark:border-gray-600'
|
? 'bg-gray-600 border-gray-800 text-white'
|
||||||
}
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
`}
|
}
|
||||||
>
|
`}
|
||||||
{isSelected && <CheckOutlined className="w-2.5 h-2.5" />}
|
>
|
||||||
</div>
|
{isSelected && <CheckOutlined className="w-2.5 h-2.5" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Color indicator */}
|
{/* Color indicator */}
|
||||||
{option.color && (
|
{option.color && (
|
||||||
@@ -588,11 +599,21 @@ const SearchFilter: React.FC<{
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
themeClasses: any;
|
themeClasses: any;
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ value, onChange, placeholder = 'Search tasks...', themeClasses, className = '' }) => {
|
}> = ({ value, onChange, placeholder, themeClasses, className = '' }) => {
|
||||||
|
const { t } = useTranslation('task-list-filters');
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [localValue, setLocalValue] = useState(value);
|
const [localValue, setLocalValue] = useState(value);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Sync local value with external value prop
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalValue(value);
|
||||||
|
// Keep expanded if there's a search value
|
||||||
|
if (value) {
|
||||||
|
setIsExpanded(true);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
setIsExpanded(!isExpanded);
|
setIsExpanded(!isExpanded);
|
||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
@@ -611,7 +632,6 @@ const SearchFilter: React.FC<{
|
|||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
setLocalValue('');
|
setLocalValue('');
|
||||||
onChange('');
|
onChange('');
|
||||||
setIsExpanded(false);
|
|
||||||
}, [onChange]);
|
}, [onChange]);
|
||||||
|
|
||||||
// Redux selectors for theme and other state
|
// Redux selectors for theme and other state
|
||||||
@@ -619,17 +639,17 @@ const SearchFilter: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className}`}>
|
<div className={`relative ${className}`}>
|
||||||
{!isExpanded ? (
|
{!isExpanded && !value ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
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-2 ${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText} ${
|
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-gray-500 focus:ring-offset-2 ${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText} ${
|
||||||
themeClasses.containerBg === 'bg-gray-800'
|
themeClasses.containerBg === 'bg-gray-800'
|
||||||
? 'focus:ring-offset-gray-900'
|
? 'focus:ring-offset-gray-900'
|
||||||
: 'focus:ring-offset-white'
|
: 'focus:ring-offset-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<SearchOutlined className="w-3.5 h-3.5" />
|
<SearchOutlined className="w-3.5 h-3.5" />
|
||||||
<span>Search</span>
|
<span>{t('search')}</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
|
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
|
||||||
@@ -640,18 +660,22 @@ const SearchFilter: React.FC<{
|
|||||||
type="text"
|
type="text"
|
||||||
value={localValue}
|
value={localValue}
|
||||||
onChange={e => setLocalValue(e.target.value)}
|
onChange={e => setLocalValue(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder || t('searchTasks')}
|
||||||
className={`w-full pr-4 pl-8 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-150 ${
|
className={`w-full pr-4 pl-8 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors duration-150 ${
|
||||||
isDarkMode
|
isDarkMode
|
||||||
? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600'
|
? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600'
|
||||||
: 'bg-white text-gray-900 placeholder-gray-400 border-gray-300'
|
: 'bg-white text-gray-900 placeholder-gray-400 border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{localValue && (
|
{localValue && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
className={`absolute right-1.5 top-1/2 transform -translate-y-1/2 ${themeClasses.secondaryText} hover:${themeClasses.optionText} transition-colors duration-150`}
|
className={`absolute right-1.5 top-1/2 transform -translate-y-1/2 transition-colors duration-150 ${
|
||||||
|
isDarkMode
|
||||||
|
? 'text-gray-400 hover:text-gray-200'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<CloseOutlined className="w-3.5 h-3.5" />
|
<CloseOutlined className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -659,16 +683,28 @@ const SearchFilter: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-2.5 py-1.5 text-xs font-medium text-white bg-blue-500 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors duration-200"
|
className={`px-2.5 py-1.5 text-xs font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors duration-200 ${
|
||||||
>
|
isDarkMode
|
||||||
Search
|
? 'text-white bg-gray-600 hover:bg-gray-700'
|
||||||
</button>
|
: 'text-gray-800 bg-gray-200 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('search')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => {
|
||||||
className={`px-2.5 py-1.5 text-xs font-medium transition-colors duration-200 ${themeClasses.secondaryText} hover:${themeClasses.optionText}`}
|
setLocalValue('');
|
||||||
|
onChange('');
|
||||||
|
setIsExpanded(false);
|
||||||
|
}}
|
||||||
|
className={`px-2.5 py-1.5 text-xs font-medium transition-colors duration-200 ${
|
||||||
|
isDarkMode
|
||||||
|
? 'text-gray-400 hover:text-gray-200'
|
||||||
|
: 'text-gray-600 hover:text-gray-800'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('cancel')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -682,8 +718,11 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
|||||||
themeClasses,
|
themeClasses,
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
}) => {
|
}) => {
|
||||||
const dispatch = useDispatch();
|
const { t } = useTranslation('task-list-filters');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const fieldsRaw = useSelector((state: RootState) => state.taskManagementFields);
|
const fieldsRaw = useSelector((state: RootState) => state.taskManagementFields);
|
||||||
|
const columns = useSelector(selectColumns);
|
||||||
|
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||||
const fields = Array.isArray(fieldsRaw) ? fieldsRaw : [];
|
const fields = Array.isArray(fieldsRaw) ? fieldsRaw : [];
|
||||||
const sortedFields = useMemo(() => [...fields].sort((a, b) => a.order - b.order), [fields]);
|
const sortedFields = useMemo(() => [...fields].sort((a, b) => a.order - b.order), [fields]);
|
||||||
|
|
||||||
@@ -734,17 +773,17 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
|||||||
visibleCount > 0
|
visibleCount > 0
|
||||||
? isDarkMode
|
? isDarkMode
|
||||||
? 'bg-gray-600 text-white border-gray-500'
|
? 'bg-gray-600 text-white border-gray-500'
|
||||||
: 'bg-blue-50 text-blue-800 border-blue-300 font-semibold'
|
: 'bg-gray-200 text-gray-800 border-gray-300 font-semibold'
|
||||||
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
|
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
|
||||||
}
|
}
|
||||||
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2
|
||||||
${isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
|
${isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
|
||||||
`}
|
`}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
<EyeOutlined className="w-3.5 h-3.5" />
|
<EyeOutlined className="w-3.5 h-3.5" />
|
||||||
<span>Fields</span>
|
<span>{t('fieldsText')}</span>
|
||||||
{visibleCount > 0 && (
|
{visibleCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center justify-center w-4 h-4 text-xs font-bold ${isDarkMode ? 'text-white bg-gray-500' : 'text-gray-800 bg-gray-300'} rounded-full`}
|
className={`inline-flex items-center justify-center w-4 h-4 text-xs font-bold ${isDarkMode ? 'text-white bg-gray-500' : 'text-gray-800 bg-gray-300'} rounded-full`}
|
||||||
@@ -765,9 +804,9 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
|||||||
{/* Options List */}
|
{/* Options List */}
|
||||||
<div className="max-h-48 overflow-y-auto">
|
<div className="max-h-48 overflow-y-auto">
|
||||||
{sortedFields.length === 0 ? (
|
{sortedFields.length === 0 ? (
|
||||||
<div className={`p-2 text-xs text-center ${themeClasses.secondaryText}`}>
|
<div className={`p-2 text-xs text-center ${themeClasses.secondaryText}`}>
|
||||||
No fields available
|
{t('noOptionsFound')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-0.5">
|
<div className="p-0.5">
|
||||||
{sortedFields.map(field => {
|
{sortedFields.map(field => {
|
||||||
@@ -776,7 +815,20 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={field.key}
|
key={field.key}
|
||||||
onClick={() => dispatch(toggleField(field.key))}
|
onClick={() => {
|
||||||
|
// Toggle field locally first
|
||||||
|
dispatch(toggleField(field.key));
|
||||||
|
|
||||||
|
// Sync with database if projectId is available
|
||||||
|
if (projectId) {
|
||||||
|
dispatch(syncFieldWithDatabase({
|
||||||
|
projectId,
|
||||||
|
fieldKey: field.key,
|
||||||
|
visible: !field.visible,
|
||||||
|
columns
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={`
|
className={`
|
||||||
w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded
|
w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded
|
||||||
transition-colors duration-150 text-left
|
transition-colors duration-150 text-left
|
||||||
@@ -839,9 +891,14 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
// Use the filter data loader hook
|
// Use the filter data loader hook
|
||||||
useFilterDataLoader();
|
useFilterDataLoader();
|
||||||
|
|
||||||
|
// Get search value from Redux based on position
|
||||||
|
const taskReducerSearch = useAppSelector(state => state.taskReducer?.search || '');
|
||||||
|
const kanbanSearch = useAppSelector(state => state.enhancedKanbanReducer?.search || '');
|
||||||
|
|
||||||
|
const searchValue = position === 'board' ? kanbanSearch : taskReducerSearch;
|
||||||
|
|
||||||
// Local state for filter sections
|
// Local state for filter sections
|
||||||
const [filterSections, setFilterSections] = useState<FilterSection[]>([]);
|
const [filterSections, setFilterSections] = useState<FilterSection[]>([]);
|
||||||
const [searchValue, setSearchValue] = useState('');
|
|
||||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
||||||
const [activeFiltersCount, setActiveFiltersCount] = useState(0);
|
const [activeFiltersCount, setActiveFiltersCount] = useState(0);
|
||||||
const [clearingFilters, setClearingFilters] = useState(false);
|
const [clearingFilters, setClearingFilters] = useState(false);
|
||||||
@@ -858,8 +915,9 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
const filterSectionsData = useFilterData(position);
|
const filterSectionsData = useFilterData(position);
|
||||||
|
|
||||||
// Check if data is loaded - memoize this computation
|
// Check if data is loaded - memoize this computation
|
||||||
|
// Keep filters visible even during refetch if we have any filter sections
|
||||||
const isDataLoaded = useMemo(() => {
|
const isDataLoaded = useMemo(() => {
|
||||||
return filterSectionsData.some(section => section.options.length > 0);
|
return filterSectionsData.length > 0;
|
||||||
}, [filterSectionsData]);
|
}, [filterSectionsData]);
|
||||||
|
|
||||||
// Initialize filter sections from data - memoize this to prevent unnecessary updates
|
// Initialize filter sections from data - memoize this to prevent unnecessary updates
|
||||||
@@ -881,7 +939,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
const { projectView } = useTabSearchParam();
|
const { projectView } = useTabSearchParam();
|
||||||
|
|
||||||
// Theme-aware class names - memoize to prevent unnecessary re-renders
|
// Theme-aware class names - memoize to prevent unnecessary re-renders
|
||||||
// Using task list row colors for consistency: --task-bg-primary: #1f1f1f, --task-bg-secondary: #141414
|
// Using greyish colors for both dark and light modes
|
||||||
const themeClasses = useMemo(
|
const themeClasses = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
containerBg: isDarkMode ? 'bg-[#1f1f1f]' : 'bg-white',
|
containerBg: isDarkMode ? 'bg-[#1f1f1f]' : 'bg-white',
|
||||||
@@ -897,8 +955,8 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
dividerBorder: isDarkMode ? 'border-[#404040]' : 'border-gray-200',
|
dividerBorder: isDarkMode ? 'border-[#404040]' : 'border-gray-200',
|
||||||
pillBg: isDarkMode ? 'bg-[#141414]' : 'bg-gray-100',
|
pillBg: isDarkMode ? 'bg-[#141414]' : 'bg-gray-100',
|
||||||
pillText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-700',
|
pillText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-700',
|
||||||
pillActiveBg: isDarkMode ? 'bg-blue-600' : 'bg-blue-100',
|
pillActiveBg: isDarkMode ? 'bg-gray-600' : 'bg-gray-200',
|
||||||
pillActiveText: isDarkMode ? 'text-white' : 'text-blue-800',
|
pillActiveText: isDarkMode ? 'text-white' : 'text-gray-800',
|
||||||
searchBg: isDarkMode ? 'bg-[#141414]' : 'bg-gray-50',
|
searchBg: isDarkMode ? 'bg-[#141414]' : 'bg-gray-50',
|
||||||
searchBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-300',
|
searchBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-300',
|
||||||
searchText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-900',
|
searchText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-900',
|
||||||
@@ -916,12 +974,8 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
// Debounced search change function
|
// Debounced search change function
|
||||||
debouncedSearchChangeRef.current = createDebouncedFunction(
|
debouncedSearchChangeRef.current = createDebouncedFunction(
|
||||||
(projectId: string, value: string) => {
|
(projectId: string, value: string) => {
|
||||||
// Dispatch search action based on current view
|
// Always use taskReducer search for list view since that's what we read from
|
||||||
if (projectView === 'list') {
|
dispatch(setSearch(value));
|
||||||
dispatch(setSearch(value));
|
|
||||||
} else {
|
|
||||||
dispatch(setKanbanSearch(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger task refetch with new search value
|
// Trigger task refetch with new search value
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
@@ -1052,8 +1106,6 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
|
|
||||||
const handleSearchChange = useCallback(
|
const handleSearchChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
setSearchValue(value);
|
|
||||||
|
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
if (position === 'board') {
|
if (position === 'board') {
|
||||||
@@ -1062,7 +1114,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
dispatch(fetchEnhancedKanbanGroups(projectId));
|
dispatch(fetchEnhancedKanbanGroups(projectId));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use debounced search
|
// Use debounced search for list view
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
debouncedSearchChangeRef.current?.(projectId, value);
|
debouncedSearchChangeRef.current?.(projectId, value);
|
||||||
}
|
}
|
||||||
@@ -1084,9 +1136,6 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
|
|
||||||
// Batch all state updates together to prevent multiple re-renders
|
// Batch all state updates together to prevent multiple re-renders
|
||||||
const batchUpdates = () => {
|
const batchUpdates = () => {
|
||||||
// Clear local state immediately for UI feedback
|
|
||||||
setSearchValue('');
|
|
||||||
|
|
||||||
// Update local filter sections state immediately
|
// Update local filter sections state immediately
|
||||||
setFilterSections(prev =>
|
setFilterSections(prev =>
|
||||||
prev.map(section => ({
|
prev.map(section => ({
|
||||||
@@ -1101,12 +1150,8 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
|
|
||||||
// Prepare all Redux actions to be dispatched together
|
// Prepare all Redux actions to be dispatched together
|
||||||
const reduxUpdates = () => {
|
const reduxUpdates = () => {
|
||||||
// Clear search based on view
|
// Clear search - always use taskReducer for list view
|
||||||
if (projectView === 'list') {
|
dispatch(setSearch(''));
|
||||||
dispatch(setSearch(''));
|
|
||||||
} else {
|
|
||||||
dispatch(setKanbanBoardSearch(''));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear label filters
|
// Clear label filters
|
||||||
const clearedLabels = currentTaskLabels.map(label => ({
|
const clearedLabels = currentTaskLabels.map(label => ({
|
||||||
@@ -1196,12 +1241,12 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
// Loading state
|
// Loading state
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${themeClasses.secondaryText}`}
|
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${themeClasses.secondaryText}`}
|
||||||
>
|
>
|
||||||
<div className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-blue-500"></div>
|
<div className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-gray-500"></div>
|
||||||
<span>Loading filters...</span>
|
<span>{t('loadingFilters')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1211,20 +1256,20 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
{activeFiltersCount > 0 && (
|
{activeFiltersCount > 0 && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className={`text-xs ${themeClasses.secondaryText}`}>
|
<span className={`text-xs ${themeClasses.secondaryText}`}>
|
||||||
{activeFiltersCount} filter{activeFiltersCount !== 1 ? 's' : ''} active
|
{activeFiltersCount} {activeFiltersCount !== 1 ? t('filtersActive') : t('filterActive')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={clearAllFilters}
|
onClick={clearAllFilters}
|
||||||
disabled={clearingFilters}
|
disabled={clearingFilters}
|
||||||
className={`text-xs font-medium transition-colors duration-150 ${
|
className={`text-xs font-medium transition-colors duration-150 ${
|
||||||
clearingFilters
|
clearingFilters
|
||||||
? 'text-gray-400 cursor-not-allowed'
|
? 'text-gray-400 cursor-not-allowed'
|
||||||
: isDarkMode
|
: isDarkMode
|
||||||
? 'text-blue-400 hover:text-blue-300'
|
? 'text-gray-400 hover:text-gray-300'
|
||||||
: 'text-blue-600 hover:text-blue-700'
|
: 'text-gray-600 hover:text-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{clearingFilters ? 'Clearing...' : 'Clear all'}
|
{clearingFilters ? t('clearing') : t('clearAll')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1236,14 +1281,13 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showArchived}
|
checked={showArchived}
|
||||||
onChange={toggleArchived}
|
onChange={toggleArchived}
|
||||||
className={`w-3.5 h-3.5 text-blue-600 rounded focus:ring-blue-500 transition-colors duration-150 ${
|
className={`w-3.5 h-3.5 text-gray-600 rounded focus:ring-gray-500 transition-colors duration-150 ${
|
||||||
isDarkMode
|
isDarkMode
|
||||||
? 'border-[#303030] bg-[#141414] focus:ring-offset-gray-800'
|
? 'border-[#303030] bg-[#141414] focus:ring-offset-gray-800'
|
||||||
: 'border-gray-300 bg-white focus:ring-offset-white'
|
: 'border-gray-300 bg-white focus:ring-offset-white'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span className={`text-xs ${themeClasses.optionText}`}>Show archived</span>
|
<span className={`text-xs ${themeClasses.optionText}`}>{t('showArchivedText')}</span>
|
||||||
<InboxOutlined className={`w-3.5 h-3.5 ${themeClasses.secondaryText}`} />
|
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1253,97 +1297,6 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Filters Pills */}
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<div
|
|
||||||
className={`flex flex-wrap items-center gap-1.5 mt-2 pt-2 border-t ${themeClasses.dividerBorder}`}
|
|
||||||
>
|
|
||||||
{searchValue && (
|
|
||||||
<div
|
|
||||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium rounded-full ${themeClasses.pillActiveBg} ${themeClasses.pillActiveText}`}
|
|
||||||
>
|
|
||||||
<SearchOutlined className="w-2.5 h-2.5" />
|
|
||||||
<span>"{searchValue}"</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSearchValue('');
|
|
||||||
if (projectId) {
|
|
||||||
// Cancel pending search and immediately clear
|
|
||||||
debouncedSearchChangeRef.current?.cancel();
|
|
||||||
if (position === 'board') {
|
|
||||||
dispatch(setKanbanSearch(''));
|
|
||||||
dispatch(fetchEnhancedKanbanGroups(projectId));
|
|
||||||
} else {
|
|
||||||
if (projectView === 'list') {
|
|
||||||
dispatch(setSearch(''));
|
|
||||||
} else {
|
|
||||||
dispatch(setKanbanBoardSearch(''));
|
|
||||||
}
|
|
||||||
dispatch(fetchTasksV3(projectId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`ml-1 rounded-full p-0.5 transition-colors duration-150 ${
|
|
||||||
isDarkMode ? 'hover:bg-blue-800' : 'hover:bg-blue-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<CloseOutlined className="w-2.5 h-2.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filterSectionsData
|
|
||||||
.filter(section => section.id !== 'groupBy') // <-- skip groupBy
|
|
||||||
.flatMap(section =>
|
|
||||||
section.selectedValues
|
|
||||||
.map(value => {
|
|
||||||
const option = section.options.find(opt => opt.value === value);
|
|
||||||
if (!option) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${section.id}-${value}`}
|
|
||||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium rounded-full ${themeClasses.pillBg} ${themeClasses.pillText}`}
|
|
||||||
>
|
|
||||||
{option.color && (
|
|
||||||
<div
|
|
||||||
className="w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: option.color }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span>{option.label}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
// Update local state immediately for UI feedback
|
|
||||||
setFilterSections(prev =>
|
|
||||||
prev.map(s =>
|
|
||||||
s.id === section.id
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
selectedValues: s.selectedValues.filter(v => v !== value),
|
|
||||||
}
|
|
||||||
: s
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use debounced API call
|
|
||||||
const newValues = section.selectedValues.filter(v => v !== value);
|
|
||||||
handleSelectionChange(section.id, newValues);
|
|
||||||
}}
|
|
||||||
className={`ml-1 rounded-full p-0.5 transition-colors duration-150 ${
|
|
||||||
isDarkMode ? 'hover:bg-gray-600' : 'hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<CloseOutlined className="w-2.5 h-2.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1017,7 +1017,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
<div className="task-indicators flex items-center gap-2">
|
<div className="task-indicators flex items-center gap-2">
|
||||||
{/* Comments indicator */}
|
{/* Comments indicator */}
|
||||||
{(task as any).comments_count > 0 && (
|
{(task as any).comments_count > 0 && (
|
||||||
<Tooltip title={t('taskManagement.comments', 'Comments')}>
|
<Tooltip title={t(`task-management:indicators.tooltips.comments${(task as any).comments_count === 1 ? '' : '_plural'}`, { count: (task as any).comments_count })}>
|
||||||
<MessageOutlined
|
<MessageOutlined
|
||||||
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
||||||
/>
|
/>
|
||||||
@@ -1025,7 +1025,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
)}
|
)}
|
||||||
{/* Attachments indicator */}
|
{/* Attachments indicator */}
|
||||||
{(task as any).attachments_count > 0 && (
|
{(task as any).attachments_count > 0 && (
|
||||||
<Tooltip title={t('taskManagement.attachments', 'Attachments')}>
|
<Tooltip title={t(`task-management:indicators.tooltips.attachments${(task as any).attachments_count === 1 ? '' : '_plural'}`, { count: (task as any).attachments_count })}>
|
||||||
<PaperClipOutlined
|
<PaperClipOutlined
|
||||||
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
||||||
/>
|
/>
|
||||||
@@ -1033,7 +1033,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
)}
|
)}
|
||||||
{/* Dependencies indicator */}
|
{/* Dependencies indicator */}
|
||||||
{(task as any).has_dependencies && (
|
{(task as any).has_dependencies && (
|
||||||
<Tooltip title={t('taskManagement.dependencies', 'Dependencies')}>
|
<Tooltip title={t('task-management:indicators.tooltips.dependencies')}>
|
||||||
<MinusCircleOutlined
|
<MinusCircleOutlined
|
||||||
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
||||||
/>
|
/>
|
||||||
@@ -1041,7 +1041,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
)}
|
)}
|
||||||
{/* Subscribers indicator */}
|
{/* Subscribers indicator */}
|
||||||
{(task as any).has_subscribers && (
|
{(task as any).has_subscribers && (
|
||||||
<Tooltip title={t('taskManagement.subscribers', 'Subscribers')}>
|
<Tooltip title={t('task-management:indicators.tooltips.subscribers')}>
|
||||||
<EyeOutlined
|
<EyeOutlined
|
||||||
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
||||||
/>
|
/>
|
||||||
@@ -1049,7 +1049,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
)}
|
)}
|
||||||
{/* Recurring indicator */}
|
{/* Recurring indicator */}
|
||||||
{(task as any).schedule_id && (
|
{(task as any).schedule_id && (
|
||||||
<Tooltip title={t('taskManagement.recurringTask', 'Recurring Task')}>
|
<Tooltip title={t('task-management:indicators.tooltips.recurring')}>
|
||||||
<RetweetOutlined
|
<RetweetOutlined
|
||||||
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
|
|||||||
import { updatePhaseLabel } from '@/features/project/project.slice';
|
import { updatePhaseLabel } from '@/features/project/project.slice';
|
||||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||||
import { fetchBoardTaskGroups } from '@/features/board/board-slice';
|
import { fetchBoardTaskGroups } from '@/features/board/board-slice';
|
||||||
|
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
|
||||||
|
|
||||||
interface UpdateSortOrderBody {
|
interface UpdateSortOrderBody {
|
||||||
from_index: number;
|
from_index: number;
|
||||||
@@ -67,6 +68,7 @@ const PhaseDrawer = () => {
|
|||||||
|
|
||||||
const refreshTasks = async () => {
|
const refreshTasks = async () => {
|
||||||
if (tab === 'tasks-list') {
|
if (tab === 'tasks-list') {
|
||||||
|
await dispatch(fetchTasksV3(projectId || ''));
|
||||||
await dispatch(fetchTaskGroups(projectId || ''));
|
await dispatch(fetchTaskGroups(projectId || ''));
|
||||||
} else if (tab === 'board') {
|
} else if (tab === 'board') {
|
||||||
await dispatch(fetchBoardTaskGroups(projectId || ''));
|
await dispatch(fetchBoardTaskGroups(projectId || ''));
|
||||||
@@ -131,6 +133,8 @@ const PhaseDrawer = () => {
|
|||||||
if (res.done) {
|
if (res.done) {
|
||||||
dispatch(updatePhaseLabel(phaseName));
|
dispatch(updatePhaseLabel(phaseName));
|
||||||
setInitialPhaseName(phaseName);
|
setInitialPhaseName(phaseName);
|
||||||
|
// Refresh tasks to update phase label in task list
|
||||||
|
await refreshTasks();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error updating phase name', error);
|
logger.error('Error updating phase name', error);
|
||||||
|
|||||||
@@ -264,10 +264,10 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0,
|
progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0,
|
||||||
assignees: task.assignees?.map((a: { team_member_id: string }) => a.team_member_id) || [],
|
assignees: task.assignees?.map((a: { team_member_id: string }) => a.team_member_id) || [],
|
||||||
assignee_names: task.assignee_names || task.names || [],
|
assignee_names: task.assignee_names || task.names || [],
|
||||||
labels: task.labels?.map((l: { id: string; label_id: string; name: string; color_code: string; end: boolean; names: string[] }) => ({
|
labels: task.labels?.map((l: { id: string; label_id: string; name: string; color: string; end: boolean; names: string[] }) => ({
|
||||||
id: l.id || l.label_id,
|
id: l.id || l.label_id,
|
||||||
name: l.name,
|
name: l.name,
|
||||||
color: l.color_code || '#1890ff',
|
color: l.color || '#1890ff',
|
||||||
end: l.end,
|
end: l.end,
|
||||||
names: l.names,
|
names: l.names,
|
||||||
})) || [],
|
})) || [],
|
||||||
@@ -908,6 +908,38 @@ const taskManagementSlice = createSlice({
|
|||||||
return column;
|
return column;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
// Add action to update task counts (comments, attachments, etc.)
|
||||||
|
updateTaskCounts: (state, action: PayloadAction<{
|
||||||
|
taskId: string;
|
||||||
|
counts: {
|
||||||
|
comments_count?: number;
|
||||||
|
attachments_count?: number;
|
||||||
|
has_subscribers?: boolean;
|
||||||
|
has_dependencies?: boolean;
|
||||||
|
schedule_id?: string | null; // Add schedule_id for recurring tasks
|
||||||
|
};
|
||||||
|
}>) => {
|
||||||
|
const { taskId, counts } = action.payload;
|
||||||
|
const task = state.entities[taskId];
|
||||||
|
if (task) {
|
||||||
|
// Update only the provided count fields
|
||||||
|
if (counts.comments_count !== undefined) {
|
||||||
|
task.comments_count = counts.comments_count;
|
||||||
|
}
|
||||||
|
if (counts.attachments_count !== undefined) {
|
||||||
|
task.attachments_count = counts.attachments_count;
|
||||||
|
}
|
||||||
|
if (counts.has_subscribers !== undefined) {
|
||||||
|
task.has_subscribers = counts.has_subscribers;
|
||||||
|
}
|
||||||
|
if (counts.has_dependencies !== undefined) {
|
||||||
|
task.has_dependencies = counts.has_dependencies;
|
||||||
|
}
|
||||||
|
if (counts.schedule_id !== undefined) {
|
||||||
|
task.schedule_id = counts.schedule_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder
|
builder
|
||||||
@@ -1100,6 +1132,7 @@ export const {
|
|||||||
updateCustomColumn,
|
updateCustomColumn,
|
||||||
deleteCustomColumn,
|
deleteCustomColumn,
|
||||||
syncColumnsWithFields,
|
syncColumnsWithFields,
|
||||||
|
updateTaskCounts,
|
||||||
} = taskManagementSlice.actions;
|
} = taskManagementSlice.actions;
|
||||||
|
|
||||||
// Export the selectors
|
// Export the selectors
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { updateColumnVisibility } from './task-management.slice';
|
||||||
|
import { ITaskListColumn } from '@/types/tasks/taskList.types';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
|
||||||
export interface TaskListField {
|
export interface TaskListField {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -37,7 +40,7 @@ function loadFields(): TaskListField[] {
|
|||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to parse stored fields, using defaults:', error);
|
logger.error('Failed to parse stored fields, using defaults:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +48,74 @@ function loadFields(): TaskListField[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveFields(fields: TaskListField[]) {
|
function saveFields(fields: TaskListField[]) {
|
||||||
console.log('Saving fields to localStorage:', fields);
|
|
||||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(fields));
|
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(fields));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Async thunk to sync field visibility with database
|
||||||
|
export const syncFieldWithDatabase = createAsyncThunk(
|
||||||
|
'taskManagementFields/syncFieldWithDatabase',
|
||||||
|
async (
|
||||||
|
{ projectId, fieldKey, visible, columns }: {
|
||||||
|
projectId: string;
|
||||||
|
fieldKey: string;
|
||||||
|
visible: boolean;
|
||||||
|
columns: ITaskListColumn[]
|
||||||
|
},
|
||||||
|
{ dispatch }
|
||||||
|
) => {
|
||||||
|
// Find the corresponding backend column
|
||||||
|
const backendColumn = columns.find(c => c.key === fieldKey);
|
||||||
|
if (backendColumn) {
|
||||||
|
// Update the column visibility in the database
|
||||||
|
await dispatch(updateColumnVisibility({
|
||||||
|
projectId,
|
||||||
|
item: {
|
||||||
|
...backendColumn,
|
||||||
|
pinned: visible
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return { fieldKey, visible };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Async thunk to sync all fields with database
|
||||||
|
export const syncAllFieldsWithDatabase = createAsyncThunk(
|
||||||
|
'taskManagementFields/syncAllFieldsWithDatabase',
|
||||||
|
async (
|
||||||
|
{ projectId, fields, columns }: {
|
||||||
|
projectId: string;
|
||||||
|
fields: TaskListField[];
|
||||||
|
columns: ITaskListColumn[]
|
||||||
|
},
|
||||||
|
{ dispatch }
|
||||||
|
) => {
|
||||||
|
// Find fields that need to be synced
|
||||||
|
const fieldsToSync = fields.filter(field => {
|
||||||
|
const backendColumn = columns.find(c => c.key === field.key);
|
||||||
|
return backendColumn && (backendColumn.pinned ?? false) !== field.visible;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync each field
|
||||||
|
const syncPromises = fieldsToSync.map(field => {
|
||||||
|
const backendColumn = columns.find(c => c.key === field.key);
|
||||||
|
if (backendColumn) {
|
||||||
|
return dispatch(updateColumnVisibility({
|
||||||
|
projectId,
|
||||||
|
item: {
|
||||||
|
...backendColumn,
|
||||||
|
pinned: field.visible
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(syncPromises);
|
||||||
|
return fieldsToSync.map(f => ({ fieldKey: f.key, visible: f.visible }));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const initialState: TaskListField[] = loadFields();
|
const initialState: TaskListField[] = loadFields();
|
||||||
|
|
||||||
const taskListFieldsSlice = createSlice({
|
const taskListFieldsSlice = createSlice({
|
||||||
@@ -75,10 +142,42 @@ const taskListFieldsSlice = createSlice({
|
|||||||
saveFields(defaultFields);
|
saveFields(defaultFields);
|
||||||
return defaultFields;
|
return defaultFields;
|
||||||
},
|
},
|
||||||
|
// New action to update field visibility from database
|
||||||
|
updateFieldVisibilityFromDatabase(state, action: PayloadAction<{ fieldKey: string; visible: boolean }>) {
|
||||||
|
const { fieldKey, visible } = action.payload;
|
||||||
|
const field = state.find(f => f.key === fieldKey);
|
||||||
|
if (field) {
|
||||||
|
field.visible = visible;
|
||||||
|
// Save to localStorage
|
||||||
|
saveFields(state);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(syncFieldWithDatabase.fulfilled, (state, action) => {
|
||||||
|
// Field visibility has been synced with database
|
||||||
|
const { fieldKey, visible } = action.payload;
|
||||||
|
const field = state.find(f => f.key === fieldKey);
|
||||||
|
if (field) {
|
||||||
|
field.visible = visible;
|
||||||
|
saveFields(state);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(syncAllFieldsWithDatabase.fulfilled, (state, action) => {
|
||||||
|
// All fields have been synced with database
|
||||||
|
action.payload.forEach(({ fieldKey, visible }) => {
|
||||||
|
const field = state.find(f => f.key === fieldKey);
|
||||||
|
if (field) {
|
||||||
|
field.visible = visible;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
saveFields(state);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { toggleField, setFields, resetFields } = taskListFieldsSlice.actions;
|
export const { toggleField, setFields, resetFields, updateFieldVisibilityFromDatabase } = taskListFieldsSlice.actions;
|
||||||
|
|
||||||
// Utility function to force reset fields (can be called from browser console)
|
// Utility function to force reset fields (can be called from browser console)
|
||||||
export const forceResetFields = () => {
|
export const forceResetFields = () => {
|
||||||
|
|||||||
@@ -91,16 +91,17 @@ export const useTaskSocketHandlers = () => {
|
|||||||
|
|
||||||
// REAL-TIME UPDATES: Update the task-management slice for immediate UI updates
|
// REAL-TIME UPDATES: Update the task-management slice for immediate UI updates
|
||||||
if (data.id) {
|
if (data.id) {
|
||||||
dispatch(
|
const currentTask = store.getState().taskManagement.entities[data.id];
|
||||||
updateTask({
|
if (currentTask) {
|
||||||
id: data.id,
|
const updatedTask: Task = {
|
||||||
changes: {
|
...currentTask,
|
||||||
assignees: data.assignees?.map(a => a.team_member_id) || [],
|
assignees: data.assignees?.map(a => a.team_member_id) || [],
|
||||||
assignee_names: data.names || [],
|
assignee_names: data.names || [],
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
},
|
updated_at: new Date().toISOString(),
|
||||||
})
|
};
|
||||||
);
|
dispatch(updateTask(updatedTask));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the old task slice (for backward compatibility)
|
// Update the old task slice (for backward compatibility)
|
||||||
@@ -147,22 +148,23 @@ export const useTaskSocketHandlers = () => {
|
|||||||
|
|
||||||
// REAL-TIME UPDATES: Update the task-management slice for immediate UI updates
|
// REAL-TIME UPDATES: Update the task-management slice for immediate UI updates
|
||||||
if (labels.id) {
|
if (labels.id) {
|
||||||
dispatch(
|
const currentTask = store.getState().taskManagement.entities[labels.id];
|
||||||
updateTask({
|
if (currentTask) {
|
||||||
id: labels.id,
|
const updatedTask: Task = {
|
||||||
changes: {
|
...currentTask,
|
||||||
labels:
|
labels:
|
||||||
labels.labels?.map(l => ({
|
labels.all_labels?.map(l => ({
|
||||||
id: l.id || '',
|
id: l.id || '',
|
||||||
name: l.name || '',
|
name: l.name || '',
|
||||||
color: l.color_code || '#1890ff',
|
color: l.color_code || '#1890ff',
|
||||||
end: l.end,
|
end: l.end,
|
||||||
names: l.names,
|
names: l.names,
|
||||||
})) || [],
|
})) || [],
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
},
|
updated_at: new Date().toISOString(),
|
||||||
})
|
};
|
||||||
);
|
dispatch(updateTask(updatedTask));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the old task slice and other related slices (for backward compatibility)
|
// Update the old task slice and other related slices (for backward compatibility)
|
||||||
@@ -281,15 +283,16 @@ export const useTaskSocketHandlers = () => {
|
|||||||
// For the task management slice, update task progress
|
// For the task management slice, update task progress
|
||||||
const taskId = data.parent_task || data.id;
|
const taskId = data.parent_task || data.id;
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
dispatch(
|
const currentTask = store.getState().taskManagement.entities[taskId];
|
||||||
updateTask({
|
if (currentTask) {
|
||||||
id: taskId,
|
const updatedTask: Task = {
|
||||||
changes: {
|
...currentTask,
|
||||||
progress: data.complete_ratio,
|
progress: data.complete_ratio,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
},
|
updated_at: new Date().toISOString(),
|
||||||
})
|
};
|
||||||
);
|
dispatch(updateTask(updatedTask));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update enhanced kanban slice
|
// Update enhanced kanban slice
|
||||||
@@ -326,14 +329,19 @@ export const useTaskSocketHandlers = () => {
|
|||||||
if (currentTask) {
|
if (currentTask) {
|
||||||
// Get priority list to map priority_id to priority name
|
// Get priority list to map priority_id to priority name
|
||||||
const priorityList = state.priorityReducer?.priorities || [];
|
const priorityList = state.priorityReducer?.priorities || [];
|
||||||
const priority = priorityList.find(p => p.id === response.priority_id);
|
|
||||||
|
|
||||||
let newPriorityValue: 'critical' | 'high' | 'medium' | 'low' = 'medium';
|
let newPriorityValue: 'critical' | 'high' | 'medium' | 'low' = 'medium';
|
||||||
if (priority?.name) {
|
|
||||||
const priorityName = priority.name.toLowerCase();
|
if (response.priority_id) {
|
||||||
if (['critical', 'high', 'medium', 'low'].includes(priorityName)) {
|
const priority = priorityList.find(p => p.id === response.priority_id);
|
||||||
newPriorityValue = priorityName as 'critical' | 'high' | 'medium' | 'low';
|
if (priority?.name) {
|
||||||
|
const priorityName = priority.name.toLowerCase();
|
||||||
|
if (['critical', 'high', 'medium', 'low'].includes(priorityName)) {
|
||||||
|
newPriorityValue = priorityName as 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No priority selected (cleared) - default to medium or find unmapped
|
||||||
|
newPriorityValue = 'medium';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the task entity first
|
// Update the task entity first
|
||||||
@@ -353,11 +361,33 @@ export const useTaskSocketHandlers = () => {
|
|||||||
const currentGroup = groups.find(group => group.taskIds.includes(response.id));
|
const currentGroup = groups.find(group => group.taskIds.includes(response.id));
|
||||||
|
|
||||||
// Find target group based on new priority value
|
// Find target group based on new priority value
|
||||||
const targetGroup = groups.find(
|
let targetGroup: any = null;
|
||||||
group => group.groupValue?.toLowerCase() === newPriorityValue.toLowerCase()
|
|
||||||
);
|
if (response.priority_id) {
|
||||||
|
// Find group by priority name (groupValue should match the priority name)
|
||||||
|
targetGroup = groups.find(
|
||||||
|
group => group.groupValue?.toLowerCase() === newPriorityValue.toLowerCase() ||
|
||||||
|
group.title?.toLowerCase() === newPriorityValue.toLowerCase()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Find "Unmapped" group for tasks without a priority
|
||||||
|
targetGroup = groups.find(
|
||||||
|
group =>
|
||||||
|
group.groupValue === 'Unmapped' ||
|
||||||
|
group.title === 'Unmapped' ||
|
||||||
|
group.groupValue === '' ||
|
||||||
|
group.title?.toLowerCase().includes('unmapped') ||
|
||||||
|
group.groupValue?.toLowerCase().includes('unmapped')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
|
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
|
||||||
|
console.log('🔄 Moving task between priority groups:', {
|
||||||
|
taskId: response.id,
|
||||||
|
from: currentGroup.title,
|
||||||
|
to: targetGroup.title,
|
||||||
|
newPriorityValue
|
||||||
|
});
|
||||||
dispatch(
|
dispatch(
|
||||||
moveTaskBetweenGroups({
|
moveTaskBetweenGroups({
|
||||||
taskId: response.id,
|
taskId: response.id,
|
||||||
@@ -365,6 +395,8 @@ export const useTaskSocketHandlers = () => {
|
|||||||
targetGroupId: targetGroup.id,
|
targetGroupId: targetGroup.id,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
} else if (!targetGroup && response.priority_id) {
|
||||||
|
console.log('🔧 Target priority group not found for priority:', newPriorityValue);
|
||||||
} else {
|
} else {
|
||||||
console.log('🔧 No group movement needed for priority change');
|
console.log('🔧 No group movement needed for priority change');
|
||||||
}
|
}
|
||||||
@@ -413,19 +445,24 @@ export const useTaskSocketHandlers = () => {
|
|||||||
|
|
||||||
// For the task management slice, update task name
|
// For the task management slice, update task name
|
||||||
if (data.id) {
|
if (data.id) {
|
||||||
dispatch(
|
const currentTask = store.getState().taskManagement.entities[data.id];
|
||||||
updateTask({
|
if (currentTask) {
|
||||||
id: data.id,
|
const updatedTask: Task = {
|
||||||
changes: {
|
...currentTask,
|
||||||
title: data.name,
|
title: data.name,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
},
|
updated_at: new Date().toISOString(),
|
||||||
})
|
};
|
||||||
);
|
dispatch(updateTask(updatedTask));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update enhanced kanban slice
|
// Update enhanced kanban slice (add manual_progress property for compatibility)
|
||||||
dispatch(updateEnhancedKanbanTaskName({ task: data }));
|
const taskWithProgress = {
|
||||||
|
...data,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask;
|
||||||
|
dispatch(updateEnhancedKanbanTaskName({ task: taskWithProgress }));
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
@@ -460,15 +497,13 @@ export const useTaskSocketHandlers = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update the task entity
|
// Update the task entity
|
||||||
dispatch(
|
const updatedTask: Task = {
|
||||||
updateTask({
|
...currentTask,
|
||||||
id: taskId,
|
phase: newPhaseValue,
|
||||||
changes: {
|
updatedAt: new Date().toISOString(),
|
||||||
phase: newPhaseValue,
|
updated_at: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
};
|
||||||
},
|
dispatch(updateTask(updatedTask));
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle group movement ONLY if grouping by phase
|
// Handle group movement ONLY if grouping by phase
|
||||||
const groups = state.taskManagement.groups;
|
const groups = state.taskManagement.groups;
|
||||||
@@ -482,31 +517,41 @@ export const useTaskSocketHandlers = () => {
|
|||||||
let targetGroup: any = null;
|
let targetGroup: any = null;
|
||||||
|
|
||||||
if (newPhaseValue && newPhaseValue.trim() !== '') {
|
if (newPhaseValue && newPhaseValue.trim() !== '') {
|
||||||
// Find group by phase name
|
// Find group by phase name (groupValue should match the phase name)
|
||||||
targetGroup = groups.find(
|
targetGroup = groups.find(
|
||||||
group => group.groupValue === newPhaseValue || group.title === newPhaseValue
|
group => group.groupValue === newPhaseValue ||
|
||||||
|
group.title === newPhaseValue ||
|
||||||
|
group.groupValue?.toLowerCase() === newPhaseValue.toLowerCase() ||
|
||||||
|
group.title?.toLowerCase() === newPhaseValue.toLowerCase()
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Find "Unmapped" group for tasks without a phase or with default phase
|
// Find "Unmapped" group for tasks without a phase
|
||||||
targetGroup = groups.find(
|
targetGroup = groups.find(
|
||||||
group =>
|
group =>
|
||||||
group.groupValue === 'Unmapped' ||
|
group.groupValue === 'Unmapped' ||
|
||||||
group.title === 'Unmapped' ||
|
group.title === 'Unmapped' ||
|
||||||
group.title.toLowerCase().includes('unmapped')
|
group.groupValue === '' ||
|
||||||
|
group.title?.toLowerCase().includes('unmapped') ||
|
||||||
|
group.groupValue?.toLowerCase().includes('unmapped')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
|
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
|
||||||
|
console.log('🔄 Moving task between phase groups:', {
|
||||||
|
taskId,
|
||||||
|
from: currentGroup.title,
|
||||||
|
to: targetGroup.title,
|
||||||
|
newPhaseValue
|
||||||
|
});
|
||||||
dispatch(
|
dispatch(
|
||||||
moveTaskBetweenGroups({
|
moveTaskBetweenGroups({
|
||||||
taskId: taskId,
|
taskId: taskId,
|
||||||
fromGroupId: currentGroup.id,
|
sourceGroupId: currentGroup.id,
|
||||||
toGroupId: targetGroup.id,
|
targetGroupId: targetGroup.id,
|
||||||
taskUpdate: {
|
|
||||||
phase: newPhaseValue,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
} else if (!targetGroup && newPhaseValue) {
|
||||||
|
console.log('🔧 Target phase group not found for phase:', newPhaseValue);
|
||||||
} else {
|
} else {
|
||||||
console.log('🔧 No group movement needed for phase change');
|
console.log('🔧 No group movement needed for phase change');
|
||||||
}
|
}
|
||||||
@@ -534,24 +579,31 @@ export const useTaskSocketHandlers = () => {
|
|||||||
// Update task-management slice for task-list-v2 components
|
// Update task-management slice for task-list-v2 components
|
||||||
const currentTask = store.getState().taskManagement.entities[task.id];
|
const currentTask = store.getState().taskManagement.entities[task.id];
|
||||||
if (currentTask) {
|
if (currentTask) {
|
||||||
dispatch(updateTask({
|
const updatedTask: Task = {
|
||||||
...currentTask,
|
...currentTask,
|
||||||
startDate: task.start_date,
|
startDate: task.start_date,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}));
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
dispatch(updateTask(updatedTask));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTaskSubscribersChange = useCallback(
|
const handleTaskSubscribersChange = useCallback(
|
||||||
(data: InlineMember[]) => {
|
(subscribers: InlineMember[]) => {
|
||||||
if (!data) return;
|
if (!subscribers) return;
|
||||||
dispatch(setTaskSubscribers(data));
|
dispatch(setTaskSubscribers(subscribers));
|
||||||
|
|
||||||
|
// Note: We don't have task_id in this event, so we can't update the task-management slice
|
||||||
|
// The has_subscribers field will be updated when the task is refetched
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleEstimationChange = useCallback(
|
const handleEstimationChange = useCallback(
|
||||||
(task: { id: string; parent_task: string | null; estimation: number }) => {
|
(task: { id: string; parent_task: string | null; estimation: number }) => {
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
@@ -801,6 +853,7 @@ export const useTaskSocketHandlers = () => {
|
|||||||
{ event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived },
|
{ event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived },
|
||||||
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated },
|
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated },
|
||||||
{ event: SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), handler: handleCustomColumnUpdate },
|
{ event: SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), handler: handleCustomColumnUpdate },
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Register all event listeners
|
// Register all event listeners
|
||||||
@@ -832,5 +885,6 @@ export const useTaskSocketHandlers = () => {
|
|||||||
handleNewTaskReceived,
|
handleNewTaskReceived,
|
||||||
handleTaskProgressUpdated,
|
handleTaskProgressUpdated,
|
||||||
handleCustomColumnUpdate,
|
handleCustomColumnUpdate,
|
||||||
|
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ tr:hover .action-buttons {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-column-cell.focused {
|
.custom-column-cell.custom-column-focused {
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Typography,
|
Typography,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
} from 'antd';
|
} from '@/shared/antd-imports';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import SelectionTypeColumn from './selection-type-column/selection-type-column';
|
import SelectionTypeColumn from './selection-type-column/selection-type-column';
|
||||||
import NumberTypeColumn from './number-type-column/number-type-column';
|
import NumberTypeColumn from './number-type-column/number-type-column';
|
||||||
@@ -91,15 +91,7 @@ const CustomColumnModal = () => {
|
|||||||
// Use the column data passed from TaskListV2
|
// Use the column data passed from TaskListV2
|
||||||
const openedColumn = currentColumnData;
|
const openedColumn = currentColumnData;
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
console.log('Modal Debug Info:', {
|
|
||||||
customColumnId,
|
|
||||||
customColumnModalType,
|
|
||||||
currentColumnData,
|
|
||||||
openedColumn,
|
|
||||||
openedColumnFound: !!openedColumn,
|
|
||||||
openedColumnId: openedColumn?.id
|
|
||||||
});
|
|
||||||
|
|
||||||
// Function to reset all form and Redux state
|
// Function to reset all form and Redux state
|
||||||
const resetModalData = () => {
|
const resetModalData = () => {
|
||||||
@@ -110,40 +102,20 @@ const CustomColumnModal = () => {
|
|||||||
|
|
||||||
// Function to handle deleting a custom column
|
// Function to handle deleting a custom column
|
||||||
const handleDeleteColumn = async () => {
|
const handleDeleteColumn = async () => {
|
||||||
console.log('Delete function called with:', {
|
// The customColumnId should now be the UUID passed from TaskListV2
|
||||||
customColumnId,
|
// But also check the column data as a fallback, prioritizing uuid over id
|
||||||
openedColumn,
|
const columnUUID = customColumnId ||
|
||||||
openedColumnId: openedColumn?.id,
|
|
||||||
openedColumnKey: openedColumn?.key,
|
|
||||||
fullColumnData: openedColumn
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to get UUID from different possible locations in the column data
|
|
||||||
const columnUUID = openedColumn?.id ||
|
|
||||||
openedColumn?.uuid ||
|
openedColumn?.uuid ||
|
||||||
openedColumn?.custom_column_obj?.id ||
|
openedColumn?.id ||
|
||||||
openedColumn?.custom_column_obj?.uuid;
|
openedColumn?.custom_column_obj?.uuid ||
|
||||||
|
openedColumn?.custom_column_obj?.id;
|
||||||
console.log('Extracted UUID candidates:', {
|
|
||||||
'openedColumn?.id': openedColumn?.id,
|
|
||||||
'openedColumn?.uuid': openedColumn?.uuid,
|
|
||||||
'openedColumn?.custom_column_obj?.id': openedColumn?.custom_column_obj?.id,
|
|
||||||
'openedColumn?.custom_column_obj?.uuid': openedColumn?.custom_column_obj?.uuid,
|
|
||||||
'finalColumnUUID': columnUUID
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!customColumnId || !columnUUID) {
|
if (!customColumnId || !columnUUID) {
|
||||||
console.error('Missing required data for deletion:', {
|
|
||||||
customColumnId,
|
|
||||||
columnUUID,
|
|
||||||
openedColumn
|
|
||||||
});
|
|
||||||
message.error('Cannot delete column: Missing UUID');
|
message.error('Cannot delete column: Missing UUID');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Attempting to delete column with UUID:', columnUUID);
|
|
||||||
// Make API request to delete the custom column using the service
|
// Make API request to delete the custom column using the service
|
||||||
await tasksCustomColumnsService.deleteCustomColumn(columnUUID);
|
await tasksCustomColumnsService.deleteCustomColumn(columnUUID);
|
||||||
|
|
||||||
@@ -328,7 +300,14 @@ const CustomColumnModal = () => {
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (updatedColumn && openedColumn?.id) {
|
// Get the correct UUID for the update operation, prioritizing uuid over id
|
||||||
|
const updateColumnUUID = customColumnId ||
|
||||||
|
openedColumn?.uuid ||
|
||||||
|
openedColumn?.id ||
|
||||||
|
openedColumn?.custom_column_obj?.uuid ||
|
||||||
|
openedColumn?.custom_column_obj?.id;
|
||||||
|
|
||||||
|
if (updatedColumn && updateColumnUUID) {
|
||||||
try {
|
try {
|
||||||
// Prepare the configuration object
|
// Prepare the configuration object
|
||||||
const configuration = {
|
const configuration = {
|
||||||
@@ -363,7 +342,7 @@ const CustomColumnModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Make API request to update custom column using the service
|
// Make API request to update custom column using the service
|
||||||
await tasksCustomColumnsService.updateCustomColumn(openedColumn.id, {
|
await tasksCustomColumnsService.updateCustomColumn(updateColumnUUID, {
|
||||||
name: value.fieldTitle,
|
name: value.fieldTitle,
|
||||||
field_type: value.fieldType,
|
field_type: value.fieldType,
|
||||||
width: 150,
|
width: 150,
|
||||||
@@ -433,16 +412,11 @@ const CustomColumnModal = () => {
|
|||||||
} else if (openedColumn.custom_column_obj?.fieldType === 'selection') {
|
} else if (openedColumn.custom_column_obj?.fieldType === 'selection') {
|
||||||
// Directly set the selections list in the Redux store
|
// Directly set the selections list in the Redux store
|
||||||
if (Array.isArray(openedColumn.custom_column_obj?.selectionsList)) {
|
if (Array.isArray(openedColumn.custom_column_obj?.selectionsList)) {
|
||||||
console.log(
|
|
||||||
'Setting selections list:',
|
|
||||||
openedColumn.custom_column_obj.selectionsList
|
|
||||||
);
|
|
||||||
dispatch(setSelectionsList(openedColumn.custom_column_obj.selectionsList));
|
dispatch(setSelectionsList(openedColumn.custom_column_obj.selectionsList));
|
||||||
}
|
}
|
||||||
} else if (openedColumn.custom_column_obj?.fieldType === 'labels') {
|
} else if (openedColumn.custom_column_obj?.fieldType === 'labels') {
|
||||||
// Directly set the labels list in the Redux store
|
// Directly set the labels list in the Redux store
|
||||||
if (Array.isArray(openedColumn.custom_column_obj?.labelsList)) {
|
if (Array.isArray(openedColumn.custom_column_obj?.labelsList)) {
|
||||||
console.log('Setting labels list:', openedColumn.custom_column_obj.labelsList);
|
|
||||||
dispatch(setLabelsList(openedColumn.custom_column_obj.labelsList));
|
dispatch(setLabelsList(openedColumn.custom_column_obj.labelsList));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,28 +28,19 @@ const SelectionTypeColumn = () => {
|
|||||||
const {
|
const {
|
||||||
customColumnModalType,
|
customColumnModalType,
|
||||||
customColumnId,
|
customColumnId,
|
||||||
|
currentColumnData,
|
||||||
selectionsList: storeSelectionsList,
|
selectionsList: storeSelectionsList,
|
||||||
} = useAppSelector(state => state.taskListCustomColumnsReducer);
|
} = useAppSelector(state => state.taskListCustomColumnsReducer);
|
||||||
|
|
||||||
// Get the opened column data if in edit mode
|
// Use the current column data passed from TaskListV2
|
||||||
const openedColumn = useAppSelector(state =>
|
const openedColumn = currentColumnData;
|
||||||
state.taskReducer.customColumns.find(col => col.key === customColumnId)
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('SelectionTypeColumn render:', {
|
|
||||||
customColumnModalType,
|
|
||||||
customColumnId,
|
|
||||||
openedColumn,
|
|
||||||
storeSelectionsList,
|
|
||||||
'openedColumn?.custom_column_obj?.selectionsList':
|
|
||||||
openedColumn?.custom_column_obj?.selectionsList,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load existing selections when in edit mode
|
// Load existing selections when in edit mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (customColumnModalType === 'edit' && openedColumn?.custom_column_obj?.selectionsList) {
|
if (customColumnModalType === 'edit' && openedColumn?.custom_column_obj?.selectionsList) {
|
||||||
const existingSelections = openedColumn.custom_column_obj.selectionsList;
|
const existingSelections = openedColumn.custom_column_obj.selectionsList;
|
||||||
console.log('Loading existing selections:', existingSelections);
|
|
||||||
|
|
||||||
if (Array.isArray(existingSelections) && existingSelections.length > 0) {
|
if (Array.isArray(existingSelections) && existingSelections.length > 0) {
|
||||||
setSelections(existingSelections);
|
setSelections(existingSelections);
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ const TaskListTaskCell = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderSubtasksCountLabel = (taskId: string, isSubTask: boolean, subTasksCount: number) => {
|
const renderSubtasksCountLabel = (taskId: string, isSubTask: boolean, subTasksCount: number) => {
|
||||||
if (!taskId) return null;
|
if (!taskId || subTasksCount <= 1) return null;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleToggleExpansion(taskId)}
|
onClick={() => handleToggleExpansion(taskId)}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface Task {
|
|||||||
comments_count?: number;
|
comments_count?: number;
|
||||||
attachments_count?: number;
|
attachments_count?: number;
|
||||||
has_dependencies?: boolean;
|
has_dependencies?: boolean;
|
||||||
|
has_subscribers?: boolean;
|
||||||
schedule_id?: string | null;
|
schedule_id?: string | null;
|
||||||
order?: number;
|
order?: number;
|
||||||
reporter?: string; // Reporter field
|
reporter?: string; // Reporter field
|
||||||
|
|||||||
Reference in New Issue
Block a user