Merge branch 'release/v2.0.4' of https://github.com/Worklenz/worklenz into test/row-kanban-board-v1.1.2
This commit is contained in:
@@ -1031,6 +1031,8 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Transform tasks with all necessary data preprocessing
|
// Transform tasks with all necessary data preprocessing
|
||||||
const transformStartTime = performance.now();
|
const transformStartTime = performance.now();
|
||||||
const transformedTasks = tasks.map((task, index) => {
|
const transformedTasks = tasks.map((task, index) => {
|
||||||
@@ -1076,7 +1078,8 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
end: l.end,
|
end: l.end,
|
||||||
names: l.names
|
names: l.names
|
||||||
})) || [],
|
})) || [],
|
||||||
dueDate: task.end_date,
|
dueDate: task.end_date || task.END_DATE,
|
||||||
|
startDate: task.start_date,
|
||||||
timeTracking: {
|
timeTracking: {
|
||||||
estimated: convertTimeValue(task.total_time),
|
estimated: convertTimeValue(task.total_time),
|
||||||
logged: convertTimeValue(task.time_spent),
|
logged: convertTimeValue(task.time_spent),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"selectText": "Zgjidh",
|
"selectText": "Zgjidh",
|
||||||
"labelsSelectorInputTip": "Shtyp Enter për të krijuar!",
|
"labelsSelectorInputTip": "Shtyp Enter për të krijuar!",
|
||||||
|
|
||||||
"addTaskText": "+ Shto Detyrë",
|
"addTaskText": "Shto Detyrë",
|
||||||
"addSubTaskText": "+ Shto Nën-Detyrë",
|
"addSubTaskText": "+ Shto Nën-Detyrë",
|
||||||
"addTaskInputPlaceholder": "Shkruaj detyrën dhe shtyp Enter",
|
"addTaskInputPlaceholder": "Shkruaj detyrën dhe shtyp Enter",
|
||||||
|
|
||||||
@@ -59,5 +59,11 @@
|
|||||||
"convertToTask": "Shndërro në Detyrë",
|
"convertToTask": "Shndërro në Detyrë",
|
||||||
"delete": "Fshi",
|
"delete": "Fshi",
|
||||||
"searchByNameInputPlaceholder": "Kërko sipas emrit"
|
"searchByNameInputPlaceholder": "Kërko sipas emrit"
|
||||||
}
|
},
|
||||||
|
"setDueDate": "Cakto datën e afatit",
|
||||||
|
"setStartDate": "Cakto datën e fillimit",
|
||||||
|
"clearDueDate": "Pastro datën e afatit",
|
||||||
|
"clearStartDate": "Pastro datën e fillimit",
|
||||||
|
"dueDatePlaceholder": "Data e afatit",
|
||||||
|
"startDatePlaceholder": "Data e fillimit"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"selectText": "Auswählen",
|
"selectText": "Auswählen",
|
||||||
"labelsSelectorInputTip": "Enter drücken zum Erstellen!",
|
"labelsSelectorInputTip": "Enter drücken zum Erstellen!",
|
||||||
|
|
||||||
"addTaskText": "+ Aufgabe hinzufügen",
|
"addTaskText": "Aufgabe hinzufügen",
|
||||||
"addSubTaskText": "+ Unteraufgabe hinzufügen",
|
"addSubTaskText": "+ Unteraufgabe hinzufügen",
|
||||||
"addTaskInputPlaceholder": "Aufgabe eingeben und Enter drücken",
|
"addTaskInputPlaceholder": "Aufgabe eingeben und Enter drücken",
|
||||||
|
|
||||||
@@ -59,5 +59,11 @@
|
|||||||
"convertToTask": "In Aufgabe umwandeln",
|
"convertToTask": "In Aufgabe umwandeln",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"searchByNameInputPlaceholder": "Nach Namen suchen"
|
"searchByNameInputPlaceholder": "Nach Namen suchen"
|
||||||
}
|
},
|
||||||
|
"setDueDate": "Fälligkeitsdatum festlegen",
|
||||||
|
"setStartDate": "Startdatum festlegen",
|
||||||
|
"clearDueDate": "Fälligkeitsdatum löschen",
|
||||||
|
"clearStartDate": "Startdatum löschen",
|
||||||
|
"dueDatePlaceholder": "Fälligkeitsdatum",
|
||||||
|
"startDatePlaceholder": "Startdatum"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
},
|
},
|
||||||
"subTasks": {
|
"subTasks": {
|
||||||
"title": "Sub Tasks",
|
"title": "Sub Tasks",
|
||||||
"add-sub-task": "+ Add Sub Task",
|
"add-sub-task": "Add Sub Task",
|
||||||
"refresh-sub-tasks": "Refresh Sub Tasks"
|
"refresh-sub-tasks": "Refresh Sub Tasks"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
},
|
},
|
||||||
"subTasks": {
|
"subTasks": {
|
||||||
"title": "Sub Tasks",
|
"title": "Sub Tasks",
|
||||||
"addSubTask": "+ Add Sub Task",
|
"addSubTask": "Add Sub Task",
|
||||||
"addSubTaskInputPlaceholder": "Type your task and hit enter",
|
"addSubTaskInputPlaceholder": "Type your task and hit enter",
|
||||||
"refreshSubTasks": "Refresh Sub Tasks",
|
"refreshSubTasks": "Refresh Sub Tasks",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
"selectText": "Select",
|
"selectText": "Select",
|
||||||
"labelsSelectorInputTip": "Hit enter to create!",
|
"labelsSelectorInputTip": "Hit enter to create!",
|
||||||
|
|
||||||
"addTaskText": "+ Add Task",
|
"addTaskText": "Add Task",
|
||||||
"addSubTaskText": "+ Add Sub Task",
|
"addSubTaskText": "Add Sub Task",
|
||||||
"addTaskInputPlaceholder": "Type your task and hit enter",
|
"addTaskInputPlaceholder": "Type your task and hit enter",
|
||||||
|
|
||||||
"openButton": "Open",
|
"openButton": "Open",
|
||||||
@@ -59,5 +59,11 @@
|
|||||||
"convertToTask": "Convert to Task",
|
"convertToTask": "Convert to Task",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"searchByNameInputPlaceholder": "Search by name"
|
"searchByNameInputPlaceholder": "Search by name"
|
||||||
}
|
},
|
||||||
|
"setDueDate": "Set due date",
|
||||||
|
"setStartDate": "Set start date",
|
||||||
|
"clearDueDate": "Clear due date",
|
||||||
|
"clearStartDate": "Clear start date",
|
||||||
|
"dueDatePlaceholder": "Due Date",
|
||||||
|
"startDatePlaceholder": "Start Date"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
"selectText": "Seleccionar",
|
"selectText": "Seleccionar",
|
||||||
"labelsSelectorInputTip": "¡Presiona enter para crear!",
|
"labelsSelectorInputTip": "¡Presiona enter para crear!",
|
||||||
|
|
||||||
"addTaskText": "+ Agregar tarea",
|
"addTaskText": "Agregar tarea",
|
||||||
"addSubTaskText": "+ Agregar subtarea",
|
"addSubTaskText": "Agregar subtarea",
|
||||||
"addTaskInputPlaceholder": "Escribe tu tarea y presiona enter",
|
"addTaskInputPlaceholder": "Escribe tu tarea y presiona enter",
|
||||||
|
|
||||||
"openButton": "Abrir",
|
"openButton": "Abrir",
|
||||||
@@ -59,5 +59,11 @@
|
|||||||
"convertToTask": "Convertir en tarea",
|
"convertToTask": "Convertir en tarea",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"searchByNameInputPlaceholder": "Buscar por nombre"
|
"searchByNameInputPlaceholder": "Buscar por nombre"
|
||||||
}
|
},
|
||||||
|
"setDueDate": "Establecer fecha de vencimiento",
|
||||||
|
"setStartDate": "Establecer fecha de inicio",
|
||||||
|
"clearDueDate": "Limpiar fecha de vencimiento",
|
||||||
|
"clearStartDate": "Limpiar fecha de inicio",
|
||||||
|
"dueDatePlaceholder": "Fecha de vencimiento",
|
||||||
|
"startDatePlaceholder": "Fecha de inicio"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"selectText": "Selecionar",
|
"selectText": "Selecionar",
|
||||||
"labelsSelectorInputTip": "Pressione enter para criar!",
|
"labelsSelectorInputTip": "Pressione enter para criar!",
|
||||||
|
|
||||||
"addTaskText": "+ Adicionar Tarefa",
|
"addTaskText": "Adicionar Tarefa",
|
||||||
"addSubTaskText": "+ Adicionar Subtarefa",
|
"addSubTaskText": "+ Adicionar Subtarefa",
|
||||||
"addTaskInputPlaceholder": "Digite sua tarefa e pressione enter",
|
"addTaskInputPlaceholder": "Digite sua tarefa e pressione enter",
|
||||||
|
|
||||||
@@ -59,5 +59,11 @@
|
|||||||
"convertToTask": "Converter em Tarefa",
|
"convertToTask": "Converter em Tarefa",
|
||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"searchByNameInputPlaceholder": "Buscar por nome"
|
"searchByNameInputPlaceholder": "Buscar por nome"
|
||||||
}
|
},
|
||||||
|
"setDueDate": "Definir data de vencimento",
|
||||||
|
"setStartDate": "Definir data de início",
|
||||||
|
"clearDueDate": "Limpar data de vencimento",
|
||||||
|
"clearStartDate": "Limpar data de início",
|
||||||
|
"dueDatePlaceholder": "Data de vencimento",
|
||||||
|
"startDatePlaceholder": "Data de início"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,9 +61,16 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
const updateDropdownPosition = useCallback(() => {
|
const updateDropdownPosition = useCallback(() => {
|
||||||
if (buttonRef.current) {
|
if (buttonRef.current) {
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const dropdownHeight = 280; // More accurate height: header(40) + max-height(192) + footer(40) + padding
|
||||||
|
|
||||||
|
// Check if dropdown would go below viewport
|
||||||
|
const spaceBelow = viewportHeight - rect.bottom;
|
||||||
|
const shouldShowAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
|
||||||
|
|
||||||
setDropdownPosition({
|
setDropdownPosition({
|
||||||
top: rect.bottom + window.scrollY + 2,
|
top: shouldShowAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
|
||||||
left: rect.left + window.scrollX,
|
left: rect.left,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -81,23 +88,11 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = (event: Event) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
// Check if the button is still visible in the viewport
|
// Only close dropdown if scrolling happens outside the dropdown
|
||||||
if (buttonRef.current) {
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
setIsOpen(false);
|
||||||
const isVisible =
|
|
||||||
rect.top >= 0 &&
|
|
||||||
rect.left >= 0 &&
|
|
||||||
rect.bottom <= window.innerHeight &&
|
|
||||||
rect.right <= window.innerWidth;
|
|
||||||
|
|
||||||
if (isVisible) {
|
|
||||||
updateDropdownPosition();
|
|
||||||
} else {
|
|
||||||
// Hide dropdown if button is not visible
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tooltip } from 'antd';
|
import { Tooltip } from 'antd';
|
||||||
import { Label } from '@/types/task-management.types';
|
import { Label } from '@/types/task-management.types';
|
||||||
|
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||||
|
|
||||||
interface CustomColordLabelProps {
|
interface CustomColordLabelProps {
|
||||||
label: Label;
|
label: Label | ITaskLabel;
|
||||||
isDarkMode?: boolean;
|
isDarkMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,11 +12,37 @@ const CustomColordLabel: React.FC<CustomColordLabelProps> = ({ label, isDarkMode
|
|||||||
const truncatedName =
|
const truncatedName =
|
||||||
label.name && label.name.length > 10 ? `${label.name.substring(0, 10)}...` : label.name;
|
label.name && label.name.length > 10 ? `${label.name.substring(0, 10)}...` : label.name;
|
||||||
|
|
||||||
|
// Ensure we have a valid color, fallback to a default if not
|
||||||
|
const backgroundColor = label.color || '#6b7280'; // Default to gray-500 if no color
|
||||||
|
|
||||||
|
// Function to determine if we should use white or black text based on background color
|
||||||
|
const getTextColor = (bgColor: string): string => {
|
||||||
|
// Remove # if present
|
||||||
|
const color = bgColor.replace('#', '');
|
||||||
|
|
||||||
|
// Convert to RGB
|
||||||
|
const r = parseInt(color.substr(0, 2), 16);
|
||||||
|
const g = parseInt(color.substr(2, 2), 16);
|
||||||
|
const b = parseInt(color.substr(4, 2), 16);
|
||||||
|
|
||||||
|
// Calculate luminance
|
||||||
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
|
||||||
|
// Return white for dark backgrounds, black for light backgrounds
|
||||||
|
return luminance > 0.5 ? '#000000' : '#ffffff';
|
||||||
|
};
|
||||||
|
|
||||||
|
const textColor = getTextColor(backgroundColor);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={label.name}>
|
<Tooltip title={label.name}>
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium text-white shrink-0 max-w-[120px]"
|
className="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium shrink-0 max-w-[120px]"
|
||||||
style={{ backgroundColor: label.color }}
|
style={{
|
||||||
|
backgroundColor,
|
||||||
|
color: textColor,
|
||||||
|
border: `1px solid ${backgroundColor}`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="truncate">{truncatedName}</span>
|
<span className="truncate">{truncatedName}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tooltip } from 'antd';
|
import { Tooltip } from 'antd';
|
||||||
|
import { NumbersColorMap } from '@/shared/constants';
|
||||||
|
|
||||||
interface CustomNumberLabelProps {
|
interface CustomNumberLabelProps {
|
||||||
labelList: string[];
|
labelList: string[];
|
||||||
namesString: string;
|
namesString: string;
|
||||||
isDarkMode?: boolean;
|
isDarkMode?: boolean;
|
||||||
|
color?: string; // Add color prop for label color
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomNumberLabel: React.FC<CustomNumberLabelProps> = ({
|
const CustomNumberLabel: React.FC<CustomNumberLabelProps> = ({
|
||||||
labelList,
|
labelList,
|
||||||
namesString,
|
namesString,
|
||||||
isDarkMode = false,
|
isDarkMode = false,
|
||||||
|
color,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Use provided color, or fall back to NumbersColorMap based on first digit
|
||||||
|
const backgroundColor = color || (() => {
|
||||||
|
const firstDigit = namesString.match(/\d/)?.[0] || '0';
|
||||||
|
return NumbersColorMap[firstDigit] || NumbersColorMap['0'];
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={labelList.join(', ')}>
|
<Tooltip title={labelList.join(', ')}>
|
||||||
<span
|
<span
|
||||||
className={`
|
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white cursor-help"
|
||||||
inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
style={{ backgroundColor }}
|
||||||
${isDarkMode ? 'bg-gray-600 text-gray-100' : 'bg-gray-200 text-gray-700'}
|
|
||||||
cursor-help
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{namesString}
|
{namesString}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -39,10 +39,24 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({ task, isDarkMode = fals
|
|||||||
const updateDropdownPosition = useCallback(() => {
|
const updateDropdownPosition = useCallback(() => {
|
||||||
if (buttonRef.current) {
|
if (buttonRef.current) {
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
setDropdownPosition({
|
const dropdownHeight = 300; // Approximate height of dropdown (max-height + padding)
|
||||||
top: rect.bottom + window.scrollY + 2,
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
left: rect.left + window.scrollX,
|
const spaceAbove = rect.top;
|
||||||
});
|
|
||||||
|
// Position dropdown above button if there's not enough space below
|
||||||
|
const shouldPositionAbove = spaceBelow < dropdownHeight && spaceAbove > dropdownHeight;
|
||||||
|
|
||||||
|
if (shouldPositionAbove) {
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.top + window.scrollY - dropdownHeight - 2,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + window.scrollY + 2,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -59,23 +73,11 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({ task, isDarkMode = fals
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = (event: Event) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
// Check if the button is still visible in the viewport
|
// Only close dropdown if scrolling happens outside the dropdown
|
||||||
if (buttonRef.current) {
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
setIsOpen(false);
|
||||||
const isVisible =
|
|
||||||
rect.top >= 0 &&
|
|
||||||
rect.left >= 0 &&
|
|
||||||
rect.bottom <= window.innerHeight &&
|
|
||||||
rect.right <= window.innerWidth;
|
|
||||||
|
|
||||||
if (isVisible) {
|
|
||||||
updateDropdownPosition();
|
|
||||||
} else {
|
|
||||||
// Hide dropdown if button is not visible
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SubtaskLoadingSkeletonProps {
|
||||||
|
visibleColumns: Array<{
|
||||||
|
id: string;
|
||||||
|
width: string;
|
||||||
|
isSticky?: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visibleColumns }) => {
|
||||||
|
const renderColumn = (columnId: string, width: string) => {
|
||||||
|
const baseStyle = { width };
|
||||||
|
|
||||||
|
switch (columnId) {
|
||||||
|
case 'dragHandle':
|
||||||
|
return <div style={baseStyle} />;
|
||||||
|
case 'checkbox':
|
||||||
|
return <div style={baseStyle} />;
|
||||||
|
case 'taskKey':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center">
|
||||||
|
<div className="h-4 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'title':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center">
|
||||||
|
{/* Subtask indentation */}
|
||||||
|
<div className="w-8" />
|
||||||
|
<div className="w-8" />
|
||||||
|
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'status':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center">
|
||||||
|
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'assignees':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center gap-1">
|
||||||
|
<div className="h-6 w-6 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse" />
|
||||||
|
<div className="h-6 w-6 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'priority':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center">
|
||||||
|
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'dueDate':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center">
|
||||||
|
<div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'progress':
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
case 'labels':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center gap-1">
|
||||||
|
<div className="h-5 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
<div className="h-5 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'phase':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center">
|
||||||
|
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'timeTracking':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center">
|
||||||
|
<div className="h-4 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'estimation':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center">
|
||||||
|
<div className="h-4 w-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'startDate':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center">
|
||||||
|
<div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'completedDate':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center">
|
||||||
|
<div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'createdDate':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center">
|
||||||
|
<div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'lastUpdated':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center">
|
||||||
|
<div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'reporter':
|
||||||
|
return (
|
||||||
|
<div style={baseStyle} className="flex items-center">
|
||||||
|
<div className="h-4 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <div style={baseStyle} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
{visibleColumns.map((column) => (
|
||||||
|
<div key={column.id}>
|
||||||
|
{renderColumn(column.id, column.width)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubtaskLoadingSkeleton;
|
||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
selectRange,
|
selectRange,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
} from '@/features/task-management/selection.slice';
|
} from '@/features/task-management/selection.slice';
|
||||||
import TaskRow from './TaskRow';
|
import TaskRowWithSubtasks from './TaskRowWithSubtasks';
|
||||||
import TaskGroupHeader from './TaskGroupHeader';
|
import TaskGroupHeader from './TaskGroupHeader';
|
||||||
import { Task, TaskGroup } from '@/types/task-management.types';
|
import { Task, TaskGroup } from '@/types/task-management.types';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
@@ -64,13 +64,13 @@ const BASE_COLUMNS = [
|
|||||||
{ id: 'dragHandle', label: '', width: '32px', isSticky: true, key: 'dragHandle' },
|
{ id: 'dragHandle', label: '', width: '32px', isSticky: true, key: 'dragHandle' },
|
||||||
{ id: 'checkbox', label: '', width: '40px', isSticky: true, key: 'checkbox' },
|
{ id: 'checkbox', label: '', width: '40px', isSticky: true, key: 'checkbox' },
|
||||||
{ id: 'taskKey', label: 'Key', width: '100px', key: COLUMN_KEYS.KEY },
|
{ id: 'taskKey', label: 'Key', width: '100px', key: COLUMN_KEYS.KEY },
|
||||||
{ id: 'title', label: 'Title', width: '300px', isSticky: true, key: COLUMN_KEYS.NAME },
|
{ id: 'title', label: 'Title', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
|
||||||
{ id: 'status', label: 'Status', width: '120px', key: COLUMN_KEYS.STATUS },
|
{ id: 'status', label: 'Status', width: '120px', key: COLUMN_KEYS.STATUS },
|
||||||
{ id: 'assignees', label: 'Assignees', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
|
{ id: 'assignees', label: 'Assignees', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
|
||||||
{ id: 'priority', label: 'Priority', width: '120px', key: COLUMN_KEYS.PRIORITY },
|
{ id: 'priority', label: 'Priority', width: '120px', key: COLUMN_KEYS.PRIORITY },
|
||||||
{ id: 'dueDate', label: 'Due Date', width: '120px', key: COLUMN_KEYS.DUE_DATE },
|
{ id: 'dueDate', label: 'Due Date', width: '120px', key: COLUMN_KEYS.DUE_DATE },
|
||||||
{ id: 'progress', label: 'Progress', width: '120px', key: COLUMN_KEYS.PROGRESS },
|
{ id: 'progress', label: 'Progress', width: '120px', key: COLUMN_KEYS.PROGRESS },
|
||||||
{ id: 'labels', label: 'Labels', width: '150px', key: COLUMN_KEYS.LABELS },
|
{ id: 'labels', label: 'Labels', width: 'auto', key: COLUMN_KEYS.LABELS },
|
||||||
{ id: 'phase', label: 'Phase', width: '120px', key: COLUMN_KEYS.PHASE },
|
{ id: 'phase', label: 'Phase', width: '120px', key: COLUMN_KEYS.PHASE },
|
||||||
{ id: 'timeTracking', label: 'Time Tracking', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
|
{ id: 'timeTracking', label: 'Time Tracking', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
|
||||||
{ id: 'estimation', label: 'Estimation', width: '120px', key: COLUMN_KEYS.ESTIMATION },
|
{ id: 'estimation', label: 'Estimation', width: '120px', key: COLUMN_KEYS.ESTIMATION },
|
||||||
@@ -91,11 +91,7 @@ type ColumnStyle = {
|
|||||||
flexShrink?: number;
|
flexShrink?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TaskListV2Props {
|
const TaskListV2: React.FC = () => {
|
||||||
projectId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { projectId: urlProjectId } = useParams();
|
const { projectId: urlProjectId } = useParams();
|
||||||
|
|
||||||
@@ -159,176 +155,190 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
}, [dispatch, urlProjectId]);
|
}, [dispatch, urlProjectId]);
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const handleTaskSelect = useCallback((taskId: string, event: React.MouseEvent) => {
|
const handleTaskSelect = useCallback(
|
||||||
if (event.ctrlKey || event.metaKey) {
|
(taskId: string, event: React.MouseEvent) => {
|
||||||
dispatch(toggleTaskSelection(taskId));
|
if (event.ctrlKey || event.metaKey) {
|
||||||
} else if (event.shiftKey && lastSelectedTaskId) {
|
dispatch(toggleTaskSelection(taskId));
|
||||||
const taskIds = allTasks.map(t => t.id); // Use allTasks here
|
} else if (event.shiftKey && lastSelectedTaskId) {
|
||||||
const startIdx = taskIds.indexOf(lastSelectedTaskId);
|
const taskIds = allTasks.map(t => t.id); // Use allTasks here
|
||||||
const endIdx = taskIds.indexOf(taskId);
|
const startIdx = taskIds.indexOf(lastSelectedTaskId);
|
||||||
const rangeIds = taskIds.slice(
|
const endIdx = taskIds.indexOf(taskId);
|
||||||
Math.min(startIdx, endIdx),
|
const rangeIds = taskIds.slice(Math.min(startIdx, endIdx), Math.max(startIdx, endIdx) + 1);
|
||||||
Math.max(startIdx, endIdx) + 1
|
dispatch(selectRange(rangeIds));
|
||||||
);
|
} else {
|
||||||
dispatch(selectRange(rangeIds));
|
dispatch(clearSelection());
|
||||||
} else {
|
dispatch(selectTask(taskId));
|
||||||
dispatch(clearSelection());
|
}
|
||||||
dispatch(selectTask(taskId));
|
},
|
||||||
}
|
[dispatch, lastSelectedTaskId, allTasks]
|
||||||
}, [dispatch, lastSelectedTaskId, allTasks]);
|
);
|
||||||
|
|
||||||
const handleGroupCollapse = useCallback((groupId: string) => {
|
const handleGroupCollapse = useCallback(
|
||||||
dispatch(toggleGroupCollapsed(groupId)); // Dispatch Redux action to toggle collapsed state
|
(groupId: string) => {
|
||||||
}, [dispatch]);
|
dispatch(toggleGroupCollapsed(groupId)); // Dispatch Redux action to toggle collapsed state
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
// Drag and drop handlers
|
// Drag and drop handlers
|
||||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
setActiveId(event.active.id as string);
|
setActiveId(event.active.id as string);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
const handleDragOver = useCallback(
|
||||||
const { active, over } = event;
|
(event: DragOverEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
if (!over) return;
|
if (!over) return;
|
||||||
|
|
||||||
const activeId = active.id;
|
const activeId = active.id;
|
||||||
const overId = over.id;
|
const overId = over.id;
|
||||||
|
|
||||||
// Find the active task and the item being dragged over
|
// Find the active task and the item being dragged over
|
||||||
const activeTask = allTasks.find(task => task.id === activeId);
|
const activeTask = allTasks.find(task => task.id === activeId);
|
||||||
if (!activeTask) return;
|
if (!activeTask) return;
|
||||||
|
|
||||||
// Check if we're dragging over a task or a group
|
// Check if we're dragging over a task or a group
|
||||||
const overTask = allTasks.find(task => task.id === overId);
|
const overTask = allTasks.find(task => task.id === overId);
|
||||||
const overGroup = groups.find(group => group.id === overId);
|
const overGroup = groups.find(group => group.id === overId);
|
||||||
|
|
||||||
// Find the groups
|
// Find the groups
|
||||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||||
let targetGroup = overGroup;
|
let targetGroup = overGroup;
|
||||||
|
|
||||||
if (overTask) {
|
if (overTask) {
|
||||||
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||||
}
|
|
||||||
|
|
||||||
if (!activeGroup || !targetGroup) return;
|
|
||||||
|
|
||||||
// If dragging to a different group, we need to handle cross-group movement
|
|
||||||
if (activeGroup.id !== targetGroup.id) {
|
|
||||||
console.log('Cross-group drag detected:', {
|
|
||||||
activeTask: activeTask.id,
|
|
||||||
fromGroup: activeGroup.id,
|
|
||||||
toGroup: targetGroup.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [allTasks, groups]);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
setActiveId(null);
|
|
||||||
|
|
||||||
if (!over || active.id === over.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeId = active.id;
|
|
||||||
const overId = over.id;
|
|
||||||
|
|
||||||
// Find the active task
|
|
||||||
const activeTask = allTasks.find(task => task.id === activeId);
|
|
||||||
if (!activeTask) {
|
|
||||||
console.error('Active task not found:', activeId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the groups
|
|
||||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
|
||||||
if (!activeGroup) {
|
|
||||||
console.error('Could not find active group for task:', activeId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're dropping on a task or a group
|
|
||||||
const overTask = allTasks.find(task => task.id === overId);
|
|
||||||
const overGroup = groups.find(group => group.id === overId);
|
|
||||||
|
|
||||||
let targetGroup = overGroup;
|
|
||||||
let insertIndex = 0;
|
|
||||||
|
|
||||||
if (overTask) {
|
|
||||||
// Dropping on a task
|
|
||||||
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
|
||||||
if (targetGroup) {
|
|
||||||
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
|
|
||||||
}
|
}
|
||||||
} else if (overGroup) {
|
|
||||||
// Dropping on a group (at the end)
|
|
||||||
targetGroup = overGroup;
|
|
||||||
insertIndex = targetGroup.taskIds.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetGroup) {
|
if (!activeGroup || !targetGroup) return;
|
||||||
console.error('Could not find target group');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCrossGroup = activeGroup.id !== targetGroup.id;
|
// If dragging to a different group, we need to handle cross-group movement
|
||||||
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
if (activeGroup.id !== targetGroup.id) {
|
||||||
|
console.log('Cross-group drag detected:', {
|
||||||
console.log('Drag operation:', {
|
activeTask: activeTask.id,
|
||||||
activeId,
|
fromGroup: activeGroup.id,
|
||||||
overId,
|
toGroup: targetGroup.id,
|
||||||
activeTask: activeTask.name || activeTask.title,
|
});
|
||||||
activeGroup: activeGroup.id,
|
|
||||||
targetGroup: targetGroup.id,
|
|
||||||
activeIndex,
|
|
||||||
insertIndex,
|
|
||||||
isCrossGroup,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isCrossGroup) {
|
|
||||||
// Moving task between groups
|
|
||||||
console.log('Moving task between groups:', {
|
|
||||||
task: activeTask.name || activeTask.title,
|
|
||||||
from: activeGroup.title,
|
|
||||||
to: targetGroup.title,
|
|
||||||
newPosition: insertIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Move task to the target group
|
|
||||||
dispatch(moveTaskBetweenGroups({
|
|
||||||
taskId: activeId as string,
|
|
||||||
sourceGroupId: activeGroup.id,
|
|
||||||
targetGroupId: targetGroup.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Reorder task within target group at drop position
|
|
||||||
dispatch(reorderTasksInGroup({
|
|
||||||
sourceTaskId: activeId as string,
|
|
||||||
destinationTaskId: over.id as string,
|
|
||||||
sourceGroupId: activeGroup.id,
|
|
||||||
destinationGroupId: targetGroup.id,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
// Reordering within the same group
|
|
||||||
console.log('Reordering task within same group:', {
|
|
||||||
task: activeTask.name || activeTask.title,
|
|
||||||
group: activeGroup.title,
|
|
||||||
from: activeIndex,
|
|
||||||
to: insertIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (activeIndex !== insertIndex) {
|
|
||||||
// Reorder task within same group at drop position
|
|
||||||
dispatch(reorderTasksInGroup({
|
|
||||||
sourceTaskId: activeId as string,
|
|
||||||
destinationTaskId: over.id as string,
|
|
||||||
sourceGroupId: activeGroup.id,
|
|
||||||
destinationGroupId: activeGroup.id,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[allTasks, groups]
|
||||||
|
);
|
||||||
|
|
||||||
}, [allTasks, groups]);
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
setActiveId(null);
|
||||||
|
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeId = active.id;
|
||||||
|
const overId = over.id;
|
||||||
|
|
||||||
|
// Find the active task
|
||||||
|
const activeTask = allTasks.find(task => task.id === activeId);
|
||||||
|
if (!activeTask) {
|
||||||
|
console.error('Active task not found:', activeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the groups
|
||||||
|
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||||
|
if (!activeGroup) {
|
||||||
|
console.error('Could not find active group for task:', activeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're dropping on a task or a group
|
||||||
|
const overTask = allTasks.find(task => task.id === overId);
|
||||||
|
const overGroup = groups.find(group => group.id === overId);
|
||||||
|
|
||||||
|
let targetGroup = overGroup;
|
||||||
|
let insertIndex = 0;
|
||||||
|
|
||||||
|
if (overTask) {
|
||||||
|
// Dropping on a task
|
||||||
|
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||||
|
if (targetGroup) {
|
||||||
|
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
|
||||||
|
}
|
||||||
|
} else if (overGroup) {
|
||||||
|
// Dropping on a group (at the end)
|
||||||
|
targetGroup = overGroup;
|
||||||
|
insertIndex = targetGroup.taskIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetGroup) {
|
||||||
|
console.error('Could not find target group');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCrossGroup = activeGroup.id !== targetGroup.id;
|
||||||
|
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
||||||
|
|
||||||
|
console.log('Drag operation:', {
|
||||||
|
activeId,
|
||||||
|
overId,
|
||||||
|
activeTask: activeTask.name || activeTask.title,
|
||||||
|
activeGroup: activeGroup.id,
|
||||||
|
targetGroup: targetGroup.id,
|
||||||
|
activeIndex,
|
||||||
|
insertIndex,
|
||||||
|
isCrossGroup,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isCrossGroup) {
|
||||||
|
// Moving task between groups
|
||||||
|
console.log('Moving task between groups:', {
|
||||||
|
task: activeTask.name || activeTask.title,
|
||||||
|
from: activeGroup.title,
|
||||||
|
to: targetGroup.title,
|
||||||
|
newPosition: insertIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move task to the target group
|
||||||
|
dispatch(
|
||||||
|
moveTaskBetweenGroups({
|
||||||
|
taskId: activeId as string,
|
||||||
|
sourceGroupId: activeGroup.id,
|
||||||
|
targetGroupId: targetGroup.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reorder task within target group at drop position
|
||||||
|
dispatch(
|
||||||
|
reorderTasksInGroup({
|
||||||
|
sourceTaskId: activeId as string,
|
||||||
|
destinationTaskId: over.id as string,
|
||||||
|
sourceGroupId: activeGroup.id,
|
||||||
|
destinationGroupId: targetGroup.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Reordering within the same group
|
||||||
|
console.log('Reordering task within same group:', {
|
||||||
|
task: activeTask.name || activeTask.title,
|
||||||
|
group: activeGroup.title,
|
||||||
|
from: activeIndex,
|
||||||
|
to: insertIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeIndex !== insertIndex) {
|
||||||
|
// Reorder task within same group at drop position
|
||||||
|
dispatch(
|
||||||
|
reorderTasksInGroup({
|
||||||
|
sourceTaskId: activeId as string,
|
||||||
|
destinationTaskId: over.id as string,
|
||||||
|
sourceGroupId: activeGroup.id,
|
||||||
|
destinationGroupId: activeGroup.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[allTasks, groups]
|
||||||
|
);
|
||||||
|
|
||||||
// Bulk action handlers
|
// Bulk action handlers
|
||||||
const handleClearSelection = useCallback(() => {
|
const handleClearSelection = useCallback(() => {
|
||||||
@@ -428,71 +438,87 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
}, [virtuosoGroups]);
|
}, [virtuosoGroups]);
|
||||||
|
|
||||||
// Memoize column headers to prevent unnecessary re-renders
|
// Memoize column headers to prevent unnecessary re-renders
|
||||||
const columnHeaders = useMemo(() => (
|
const columnHeaders = useMemo(
|
||||||
<div className="flex items-center px-4 py-2" style={{ minWidth: 'max-content' }}>
|
() => (
|
||||||
{visibleColumns.map((column) => {
|
<div className="flex items-center px-4 py-2" style={{ minWidth: 'max-content' }}>
|
||||||
const columnStyle: ColumnStyle = {
|
{visibleColumns.map(column => {
|
||||||
width: column.width,
|
const columnStyle: ColumnStyle = {
|
||||||
flexShrink: 0, // Prevent columns from shrinking
|
width: column.width,
|
||||||
};
|
flexShrink: 0, // Prevent columns from shrinking
|
||||||
|
// Add specific styling for labels column with auto width
|
||||||
|
...(column.id === 'labels' && column.width === 'auto'
|
||||||
|
? {
|
||||||
|
minWidth: '200px', // Ensure minimum width for labels
|
||||||
|
flexGrow: 1, // Allow it to grow
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={column.id}
|
key={column.id}
|
||||||
className="text-xs font-medium text-gray-500 dark:text-gray-400"
|
className="text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||||
style={columnStyle}
|
style={columnStyle}
|
||||||
>
|
>
|
||||||
{column.id === 'dragHandle' ? (
|
{column.id === 'dragHandle' ? (
|
||||||
<HolderOutlined className="text-gray-400" />
|
<HolderOutlined className="text-gray-400" />
|
||||||
) : column.id === 'checkbox' ? (
|
) : column.id === 'checkbox' ? (
|
||||||
<span></span> // Empty for checkbox column header
|
<span></span> // Empty for checkbox column header
|
||||||
) : (
|
) : (
|
||||||
column.label
|
column.label
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
), [visibleColumns]);
|
),
|
||||||
|
[visibleColumns]
|
||||||
|
);
|
||||||
|
|
||||||
// Render functions
|
// Render functions
|
||||||
const renderGroup = useCallback((groupIndex: number) => {
|
const renderGroup = useCallback(
|
||||||
const group = virtuosoGroups[groupIndex];
|
(groupIndex: number) => {
|
||||||
const isGroupEmpty = group.count === 0;
|
const group = virtuosoGroups[groupIndex];
|
||||||
|
const isGroupEmpty = group.count === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={groupIndex > 0 ? 'mt-2' : ''}>
|
<div className={groupIndex > 0 ? 'mt-2' : ''}>
|
||||||
<TaskGroupHeader
|
<TaskGroupHeader
|
||||||
group={{
|
group={{
|
||||||
id: group.id,
|
id: group.id,
|
||||||
name: group.title,
|
name: group.title,
|
||||||
count: group.count,
|
count: group.count,
|
||||||
color: group.color,
|
color: group.color,
|
||||||
}}
|
}}
|
||||||
isCollapsed={collapsedGroups.has(group.id)}
|
isCollapsed={collapsedGroups.has(group.id)}
|
||||||
onToggle={() => handleGroupCollapse(group.id)}
|
onToggle={() => handleGroupCollapse(group.id)}
|
||||||
|
/>
|
||||||
|
{/* Empty group drop zone */}
|
||||||
|
{isGroupEmpty && !collapsedGroups.has(group.id) && (
|
||||||
|
<div className="px-4 py-8 text-center text-gray-400 dark:text-gray-500 border-2 border-dashed border-transparent hover:border-blue-300 transition-colors">
|
||||||
|
<div className="text-sm">Drop tasks here</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[virtuosoGroups, collapsedGroups, handleGroupCollapse]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTask = useCallback(
|
||||||
|
(taskIndex: number) => {
|
||||||
|
const task = virtuosoItems[taskIndex]; // Get task from the flattened virtuosoItems
|
||||||
|
if (!task || !urlProjectId) return null; // Should not happen if logic is correct
|
||||||
|
return (
|
||||||
|
<TaskRowWithSubtasks
|
||||||
|
taskId={task.id}
|
||||||
|
projectId={urlProjectId}
|
||||||
|
visibleColumns={visibleColumns}
|
||||||
/>
|
/>
|
||||||
{/* Empty group drop zone */}
|
);
|
||||||
{isGroupEmpty && !collapsedGroups.has(group.id) && (
|
},
|
||||||
<div className="px-4 py-8 text-center text-gray-400 dark:text-gray-500 border-2 border-dashed border-transparent hover:border-blue-300 transition-colors">
|
[virtuosoItems, visibleColumns]
|
||||||
<div className="text-sm">Drop tasks here</div>
|
);
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [virtuosoGroups, collapsedGroups, handleGroupCollapse]);
|
|
||||||
|
|
||||||
const renderTask = useCallback((taskIndex: number) => {
|
|
||||||
const task = virtuosoItems[taskIndex]; // Get task from the flattened virtuosoItems
|
|
||||||
if (!task) return null; // Should not happen if logic is correct
|
|
||||||
return (
|
|
||||||
<TaskRow
|
|
||||||
taskId={task.id}
|
|
||||||
projectId={projectId}
|
|
||||||
visibleColumns={visibleColumns}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}, [virtuosoItems, visibleColumns]);
|
|
||||||
|
|
||||||
if (loading) return <div>Loading...</div>;
|
if (loading) return <div>Loading...</div>;
|
||||||
if (error) return <div>Error: {error}</div>;
|
if (error) return <div>Error: {error}</div>;
|
||||||
@@ -522,7 +548,10 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
{/* Task List - Scrollable content */}
|
{/* Task List - Scrollable content */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={virtuosoItems.map(task => task.id).filter((id): id is string => id !== undefined)}
|
items={virtuosoItems
|
||||||
|
.filter(task => !task.parent_task_id)
|
||||||
|
.map(task => task.id)
|
||||||
|
.filter((id): id is string => id !== undefined)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<GroupedVirtuoso
|
<GroupedVirtuoso
|
||||||
@@ -531,12 +560,11 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
groupContent={renderGroup}
|
groupContent={renderGroup}
|
||||||
itemContent={renderTask}
|
itemContent={renderTask}
|
||||||
components={{
|
components={{
|
||||||
List: React.forwardRef<HTMLDivElement, { style?: React.CSSProperties; children?: React.ReactNode }>(({ style, children }, ref) => (
|
List: React.forwardRef<
|
||||||
<div
|
HTMLDivElement,
|
||||||
ref={ref}
|
{ style?: React.CSSProperties; children?: React.ReactNode }
|
||||||
style={style || {}}
|
>(({ style, children }, ref) => (
|
||||||
className="virtuoso-list-container"
|
<div ref={ref} style={style || {}} className="virtuoso-list-container">
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)),
|
)),
|
||||||
@@ -557,8 +585,8 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
{allTasks.find(task => task.id === activeId)?.name ||
|
{allTasks.find(task => task.id === activeId)?.name ||
|
||||||
allTasks.find(task => task.id === activeId)?.title ||
|
allTasks.find(task => task.id === activeId)?.title ||
|
||||||
'Task'}
|
'Task'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{allTasks.find(task => task.id === activeId)?.task_key}
|
{allTasks.find(task => task.id === activeId)?.task_key}
|
||||||
@@ -571,11 +599,11 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
|
|
||||||
{/* Bulk Action Bar */}
|
{/* Bulk Action Bar */}
|
||||||
{selectedTaskIds.length > 0 && (
|
{selectedTaskIds.length > 0 && urlProjectId && (
|
||||||
<OptimizedBulkActionBar
|
<OptimizedBulkActionBar
|
||||||
selectedTaskIds={selectedTaskIds}
|
selectedTaskIds={selectedTaskIds}
|
||||||
totalSelected={selectedTaskIds.length}
|
totalSelected={selectedTaskIds.length}
|
||||||
projectId={projectId}
|
projectId={urlProjectId}
|
||||||
onClearSelection={handleClearSelection}
|
onClearSelection={handleClearSelection}
|
||||||
onBulkStatusChange={handleBulkStatusChange}
|
onBulkStatusChange={handleBulkStatusChange}
|
||||||
onBulkPriorityChange={handleBulkPriorityChange}
|
onBulkPriorityChange={handleBulkPriorityChange}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import React, { memo, useMemo, useCallback } 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 } from '@ant-design/icons';
|
import { CheckCircleOutlined, HolderOutlined, CloseOutlined, DownOutlined, RightOutlined, DoubleRightOutlined } from '@ant-design/icons';
|
||||||
import { Checkbox } from 'antd';
|
import { Checkbox, DatePicker } from 'antd';
|
||||||
|
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';
|
||||||
import Avatar from '@/components/Avatar';
|
|
||||||
import AssigneeSelector from '@/components/AssigneeSelector';
|
import AssigneeSelector from '@/components/AssigneeSelector';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Bars3Icon } from '@heroicons/react/24/outline';
|
|
||||||
import { ClockIcon } from '@heroicons/react/24/outline';
|
|
||||||
import AvatarGroup from '../AvatarGroup';
|
import AvatarGroup from '../AvatarGroup';
|
||||||
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||||
import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress';
|
import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress';
|
||||||
@@ -17,8 +15,16 @@ import TaskStatusDropdown from '@/components/task-management/task-status-dropdow
|
|||||||
import TaskPriorityDropdown from '@/components/task-management/task-priority-dropdown';
|
import TaskPriorityDropdown from '@/components/task-management/task-priority-dropdown';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { selectTaskById } from '@/features/task-management/task-management.slice';
|
import { selectTaskById, toggleTaskExpansion, fetchSubTasks } from '@/features/task-management/task-management.slice';
|
||||||
import { selectIsTaskSelected, toggleTaskSelection } from '@/features/task-management/selection.slice';
|
import { selectIsTaskSelected, toggleTaskSelection } from '@/features/task-management/selection.slice';
|
||||||
|
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import TaskTimeTracking from './TaskTimeTracking';
|
||||||
|
import { CustomNumberLabel, CustomColordLabel } from '@/components';
|
||||||
|
import LabelsSelector from '@/components/LabelsSelector';
|
||||||
|
import TaskPhaseDropdown from '@/components/task-management/task-phase-dropdown';
|
||||||
|
|
||||||
interface TaskRowProps {
|
interface TaskRowProps {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -28,8 +34,45 @@ interface TaskRowProps {
|
|||||||
width: string;
|
width: string;
|
||||||
isSticky?: boolean;
|
isSticky?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
isSubtask?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskLabelsCellProps {
|
||||||
|
labels: Task['labels'];
|
||||||
|
isDarkMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskLabelsCell: React.FC<TaskLabelsCellProps> = memo(({ labels, isDarkMode }) => {
|
||||||
|
if (!labels) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{labels.map((label, index) => {
|
||||||
|
const extendedLabel = label as any;
|
||||||
|
return extendedLabel.end && extendedLabel.names && extendedLabel.name ? (
|
||||||
|
<CustomNumberLabel
|
||||||
|
key={`${label.id}-${index}`}
|
||||||
|
labelList={extendedLabel.names}
|
||||||
|
namesString={extendedLabel.name}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
color={label.color}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CustomColordLabel
|
||||||
|
key={`${label.id}-${index}`}
|
||||||
|
label={label}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TaskLabelsCell.displayName = 'TaskLabelsCell';
|
||||||
|
|
||||||
// Utility function to get task display name with fallbacks
|
// Utility function to get task display name with fallbacks
|
||||||
const getTaskDisplayName = (task: Task): string => {
|
const getTaskDisplayName = (task: Task): string => {
|
||||||
// Check each field and only use if it has actual content after trimming
|
// Check each field and only use if it has actual content after trimming
|
||||||
@@ -42,30 +85,34 @@ const getTaskDisplayName = (task: Task): string => {
|
|||||||
// Memoized date formatter to avoid repeated date parsing
|
// Memoized date formatter to avoid repeated date parsing
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
try {
|
try {
|
||||||
return format(new Date(dateString), 'MMM d');
|
return format(new Date(dateString), 'MMM d, yyyy');
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Memoized date formatter to avoid repeated date parsing
|
const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumns, isSubtask = false }) => {
|
||||||
|
|
||||||
const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumns }) => {
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const task = useAppSelector(state => selectTaskById(state, taskId));
|
const task = useAppSelector(state => selectTaskById(state, taskId));
|
||||||
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
|
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
|
||||||
|
// State for tracking which date picker is open
|
||||||
|
const [activeDatePicker, setActiveDatePicker] = useState<string | null>(null);
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
return null; // Don't render if task is not found in store
|
return null; // Don't render if task is not found in store
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag and drop functionality
|
// Drag and drop functionality - only enable for parent tasks
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
data: {
|
data: {
|
||||||
type: 'task',
|
type: 'task',
|
||||||
task,
|
task,
|
||||||
},
|
},
|
||||||
|
disabled: isSubtask, // Disable drag and drop for subtasks
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoize style object to prevent unnecessary re-renders
|
// Memoize style object to prevent unnecessary re-renders
|
||||||
@@ -101,35 +148,43 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
}), [task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]);
|
}), [task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]);
|
||||||
|
|
||||||
// Memoize formatted dates
|
// Memoize formatted dates
|
||||||
const formattedDueDate = useMemo(() =>
|
const formattedDates = useMemo(() => ({
|
||||||
task.dueDate ? formatDate(task.dueDate) : null,
|
due: (() => {
|
||||||
[task.dueDate]
|
const dateValue = task.dueDate || task.due_date;
|
||||||
|
return dateValue ? formatDate(dateValue) : null;
|
||||||
|
})(),
|
||||||
|
start: task.startDate ? formatDate(task.startDate) : null,
|
||||||
|
completed: task.completedAt ? formatDate(task.completedAt) : null,
|
||||||
|
created: task.created_at ? formatDate(task.created_at) : null,
|
||||||
|
updated: task.updatedAt ? formatDate(task.updatedAt) : null,
|
||||||
|
}), [task.dueDate, task.due_date, task.startDate, task.completedAt, task.created_at, task.updatedAt]);
|
||||||
|
|
||||||
|
// Memoize date values for DatePicker
|
||||||
|
const dateValues = useMemo(
|
||||||
|
() => ({
|
||||||
|
start: task.startDate ? dayjs(task.startDate) : undefined,
|
||||||
|
due: (task.dueDate || task.due_date) ? dayjs(task.dueDate || task.due_date) : undefined,
|
||||||
|
}),
|
||||||
|
[task.startDate, task.dueDate, task.due_date]
|
||||||
);
|
);
|
||||||
|
|
||||||
const formattedStartDate = useMemo(() =>
|
// Create labels adapter for LabelsSelector
|
||||||
task.startDate ? formatDate(task.startDate) : null,
|
const labelsAdapter = useMemo(() => ({
|
||||||
[task.startDate]
|
id: task.id,
|
||||||
);
|
name: task.title || task.name,
|
||||||
|
parent_task_id: task.parent_task_id,
|
||||||
const formattedCompletedDate = useMemo(() =>
|
manual_progress: false,
|
||||||
task.completedAt ? formatDate(task.completedAt) : null,
|
all_labels: task.labels?.map(label => ({
|
||||||
[task.completedAt]
|
id: label.id,
|
||||||
);
|
name: label.name,
|
||||||
|
color_code: label.color,
|
||||||
const formattedCreatedDate = useMemo(() =>
|
})) || [],
|
||||||
task.created_at ? formatDate(task.created_at) : null,
|
labels: task.labels?.map(label => ({
|
||||||
[task.created_at]
|
id: label.id,
|
||||||
);
|
name: label.name,
|
||||||
|
color_code: label.color,
|
||||||
const formattedUpdatedDate = useMemo(() =>
|
})) || [],
|
||||||
task.updatedAt ? formatDate(task.updatedAt) : null,
|
}), [task.id, task.title, task.name, task.parent_task_id, task.labels]);
|
||||||
[task.updatedAt]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Debugging: Log assignee_names whenever the task prop changes
|
|
||||||
React.useEffect(() => {
|
|
||||||
console.log(`Task ${task.id} assignees:`, task.assignee_names);
|
|
||||||
}, [task.id, task.assignee_names]);
|
|
||||||
|
|
||||||
// Handle checkbox change
|
// Handle checkbox change
|
||||||
const handleCheckboxChange = useCallback((e: any) => {
|
const handleCheckboxChange = useCallback((e: any) => {
|
||||||
@@ -137,30 +192,61 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
dispatch(toggleTaskSelection(taskId));
|
dispatch(toggleTaskSelection(taskId));
|
||||||
}, [dispatch, taskId]);
|
}, [dispatch, taskId]);
|
||||||
|
|
||||||
// Memoize status style
|
// Handle task expansion toggle
|
||||||
const statusStyle = useMemo(() => ({
|
const handleToggleExpansion = useCallback((e: React.MouseEvent) => {
|
||||||
backgroundColor: task.statusColor ? `${task.statusColor}20` : 'rgb(229, 231, 235)',
|
e.stopPropagation();
|
||||||
color: task.statusColor || 'rgb(31, 41, 55)',
|
|
||||||
}), [task.statusColor]);
|
|
||||||
|
|
||||||
// Memoize priority style
|
// Always try to fetch subtasks when expanding, regardless of count
|
||||||
const priorityStyle = useMemo(() => ({
|
if (!task.show_sub_tasks && (!task.sub_tasks || task.sub_tasks.length === 0)) {
|
||||||
backgroundColor: task.priorityColor ? `${task.priorityColor}20` : 'rgb(229, 231, 235)',
|
dispatch(fetchSubTasks({ taskId: task.id, projectId }));
|
||||||
color: task.priorityColor || 'rgb(31, 41, 55)',
|
}
|
||||||
}), [task.priorityColor]);
|
|
||||||
|
|
||||||
// Memoize labels display
|
// Toggle expansion state
|
||||||
const labelsDisplay = useMemo(() => {
|
dispatch(toggleTaskExpansion(task.id));
|
||||||
if (!task.labels || task.labels.length === 0) return null;
|
}, [dispatch, task.id, task.sub_tasks, task.show_sub_tasks, projectId]);
|
||||||
|
|
||||||
const visibleLabels = task.labels.slice(0, 2);
|
// Handle date change
|
||||||
const remainingCount = task.labels.length - 2;
|
const handleDateChange = useCallback(
|
||||||
|
(date: dayjs.Dayjs | null, field: 'startDate' | 'dueDate') => {
|
||||||
|
if (!connected || !socket) return;
|
||||||
|
|
||||||
return {
|
const eventType =
|
||||||
visibleLabels,
|
field === 'startDate'
|
||||||
remainingCount: remainingCount > 0 ? remainingCount : null,
|
? SocketEvents.TASK_START_DATE_CHANGE
|
||||||
};
|
: SocketEvents.TASK_END_DATE_CHANGE;
|
||||||
}, [task.labels]);
|
const dateField = field === 'startDate' ? 'start_date' : 'end_date';
|
||||||
|
|
||||||
|
socket.emit(
|
||||||
|
eventType.toString(),
|
||||||
|
JSON.stringify({
|
||||||
|
task_id: task.id,
|
||||||
|
[dateField]: date?.format('YYYY-MM-DD'),
|
||||||
|
parent_task: null,
|
||||||
|
time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close the date picker after selection
|
||||||
|
setActiveDatePicker(null);
|
||||||
|
},
|
||||||
|
[connected, socket, task.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize date picker handlers
|
||||||
|
const datePickerHandlers = useMemo(() => ({
|
||||||
|
setDueDate: () => setActiveDatePicker('dueDate'),
|
||||||
|
setStartDate: () => setActiveDatePicker('startDate'),
|
||||||
|
clearDueDate: (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDateChange(null, 'dueDate');
|
||||||
|
},
|
||||||
|
clearStartDate: (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDateChange(null, 'startDate');
|
||||||
|
},
|
||||||
|
}), [handleDateChange]);
|
||||||
|
|
||||||
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
||||||
const baseStyle = { width };
|
const baseStyle = { width };
|
||||||
@@ -169,12 +255,11 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
case 'dragHandle':
|
case 'dragHandle':
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="cursor-grab active:cursor-grabbing flex items-center justify-center"
|
className={`flex items-center justify-center ${isSubtask ? '' : 'cursor-grab active:cursor-grabbing'}`}
|
||||||
style={baseStyle}
|
style={baseStyle}
|
||||||
{...attributes}
|
{...(isSubtask ? {} : { ...attributes, ...listeners })}
|
||||||
{...listeners}
|
|
||||||
>
|
>
|
||||||
<HolderOutlined className="text-gray-400 hover:text-gray-600" />
|
{!isSubtask && <HolderOutlined className="text-gray-400 hover:text-gray-600" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -200,10 +285,63 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
|
|
||||||
case 'title':
|
case 'title':
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center" style={baseStyle}>
|
<div className="flex items-center justify-between group" style={baseStyle}>
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
<div className="flex items-center flex-1">
|
||||||
{taskDisplayName}
|
{/* Indentation for subtasks - increased padding */}
|
||||||
</span>
|
{isSubtask && <div className="w-8" />}
|
||||||
|
|
||||||
|
{/* Expand/Collapse button - only show for parent tasks */}
|
||||||
|
{!isSubtask && (
|
||||||
|
<button
|
||||||
|
onClick={handleToggleExpansion}
|
||||||
|
className={`flex h-4 w-4 items-center justify-center rounded-sm text-xs mr-2 hover:border hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors ${
|
||||||
|
task.sub_tasks_count && task.sub_tasks_count > 0
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.sub_tasks_count && task.sub_tasks_count > 0 ? (
|
||||||
|
task.show_sub_tasks ? (
|
||||||
|
<DownOutlined className="text-gray-600 dark:text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<RightOutlined className="text-gray-600 dark:text-gray-400" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<RightOutlined className="text-gray-600 dark:text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Additional indentation for subtasks after the expand button space */}
|
||||||
|
{isSubtask && <div className="w-4" />}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||||
|
{taskDisplayName}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Subtask count indicator */}
|
||||||
|
{!isSubtask && task.sub_tasks_count && 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">
|
||||||
|
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
|
||||||
|
{task.sub_tasks_count}
|
||||||
|
</span>
|
||||||
|
<DoubleRightOutlined className="text-xs text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 ml-2 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 border-none bg-transparent cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
dispatch(setSelectedTaskId(task.id));
|
||||||
|
dispatch(setShowTaskDrawer(true));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('openButton')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -248,11 +386,58 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
|
|
||||||
case 'dueDate':
|
case 'dueDate':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle} className="relative group">
|
||||||
{formattedDueDate && (
|
{activeDatePicker === 'dueDate' ? (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="w-full relative">
|
||||||
{formattedDueDate}
|
<DatePicker
|
||||||
</span>
|
{...taskManagementAntdConfig.datePickerDefaults}
|
||||||
|
className="w-full bg-transparent border-none shadow-none"
|
||||||
|
value={dateValues.due}
|
||||||
|
onChange={date => handleDateChange(date, 'dueDate')}
|
||||||
|
placeholder={t('dueDatePlaceholder')}
|
||||||
|
allowClear={false}
|
||||||
|
suffixIcon={null}
|
||||||
|
open={true}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setActiveDatePicker(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{/* Custom clear button */}
|
||||||
|
{dateValues.due && (
|
||||||
|
<button
|
||||||
|
onClick={datePickerHandlers.clearDueDate}
|
||||||
|
className={`absolute right-1 top-1/2 transform -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded-full text-xs ${
|
||||||
|
isDarkMode
|
||||||
|
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
title={t('clearDueDate')}
|
||||||
|
>
|
||||||
|
<CloseOutlined style={{ fontSize: '10px' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded px-2 py-1 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
datePickerHandlers.setDueDate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formattedDates.due ? (
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{formattedDates.due}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
{t('setDueDate')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -283,48 +468,27 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
|
|
||||||
case 'labels':
|
case 'labels':
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1" style={baseStyle}>
|
<div className="flex items-center gap-1 flex-wrap min-w-0" style={{ ...baseStyle, minWidth: '200px' }}>
|
||||||
{labelsDisplay?.visibleLabels.map((label, index) => (
|
<TaskLabelsCell labels={task.labels} isDarkMode={isDarkMode} />
|
||||||
<span
|
<LabelsSelector task={labelsAdapter} isDarkMode={isDarkMode} />
|
||||||
key={`${label.id}-${index}`}
|
|
||||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor: label.color ? `${label.color}20` : 'rgb(229, 231, 235)',
|
|
||||||
color: label.color || 'rgb(31, 41, 55)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{labelsDisplay?.remainingCount && (
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
+{labelsDisplay.remainingCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'phase':
|
case 'phase':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle}>
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
|
<TaskPhaseDropdown
|
||||||
{task.phase}
|
task={task}
|
||||||
</span>
|
projectId={projectId}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'timeTracking':
|
case 'timeTracking':
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1" style={baseStyle}>
|
<div style={baseStyle}>
|
||||||
<ClockIcon className="w-4 h-4 text-gray-400" />
|
<TaskTimeTracking taskId={task.id || ''} isDarkMode={isDarkMode} />
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{task.timeTracking?.logged || 0}h
|
|
||||||
</span>
|
|
||||||
{task.timeTracking?.estimated && (
|
|
||||||
<span className="text-sm text-gray-400 dark:text-gray-500">
|
|
||||||
/{task.timeTracking.estimated}h
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -341,11 +505,58 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
|
|
||||||
case 'startDate':
|
case 'startDate':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle} className="relative group">
|
||||||
{formattedStartDate && (
|
{activeDatePicker === 'startDate' ? (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="w-full relative">
|
||||||
{formattedStartDate}
|
<DatePicker
|
||||||
</span>
|
{...taskManagementAntdConfig.datePickerDefaults}
|
||||||
|
className="w-full bg-transparent border-none shadow-none"
|
||||||
|
value={dateValues.start}
|
||||||
|
onChange={date => handleDateChange(date, 'startDate')}
|
||||||
|
placeholder={t('startDatePlaceholder')}
|
||||||
|
allowClear={false}
|
||||||
|
suffixIcon={null}
|
||||||
|
open={true}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setActiveDatePicker(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{/* Custom clear button */}
|
||||||
|
{dateValues.start && (
|
||||||
|
<button
|
||||||
|
onClick={datePickerHandlers.clearStartDate}
|
||||||
|
className={`absolute right-1 top-1/2 transform -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded-full text-xs ${
|
||||||
|
isDarkMode
|
||||||
|
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
title={t('clearStartDate')}
|
||||||
|
>
|
||||||
|
<CloseOutlined style={{ fontSize: '10px' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded px-2 py-1 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
datePickerHandlers.setStartDate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formattedDates.start ? (
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{formattedDates.start}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
{t('setStartDate')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -353,9 +564,9 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
case 'completedDate':
|
case 'completedDate':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle}>
|
||||||
{formattedCompletedDate && (
|
{formattedDates.completed && (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{formattedCompletedDate}
|
{formattedDates.completed}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -364,9 +575,9 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
case 'createdDate':
|
case 'createdDate':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle}>
|
||||||
{formattedCreatedDate && (
|
{formattedDates.created && (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{formattedCreatedDate}
|
{formattedDates.created}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -375,9 +586,9 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
case 'lastUpdated':
|
case 'lastUpdated':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle}>
|
||||||
{formattedUpdatedDate && (
|
{formattedDates.updated && (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{formattedUpdatedDate}
|
{formattedDates.updated}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -396,30 +607,31 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
// Essential props and state
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
task.task_key,
|
|
||||||
task.status,
|
|
||||||
task.priority,
|
|
||||||
task.phase,
|
|
||||||
task.reporter,
|
|
||||||
task.assignee_names,
|
|
||||||
task.timeTracking,
|
|
||||||
task.progress,
|
|
||||||
task.sub_tasks,
|
|
||||||
taskDisplayName,
|
|
||||||
statusStyle,
|
|
||||||
priorityStyle,
|
|
||||||
formattedDueDate,
|
|
||||||
formattedStartDate,
|
|
||||||
formattedCompletedDate,
|
|
||||||
formattedCreatedDate,
|
|
||||||
formattedUpdatedDate,
|
|
||||||
labelsDisplay,
|
|
||||||
isDarkMode,
|
|
||||||
convertedTask,
|
|
||||||
isSelected,
|
isSelected,
|
||||||
handleCheckboxChange,
|
handleCheckboxChange,
|
||||||
|
activeDatePicker,
|
||||||
|
isDarkMode,
|
||||||
|
projectId,
|
||||||
|
|
||||||
|
// Task data
|
||||||
|
task,
|
||||||
|
taskDisplayName,
|
||||||
|
convertedTask,
|
||||||
|
|
||||||
|
// Memoized values
|
||||||
|
dateValues,
|
||||||
|
formattedDates,
|
||||||
|
labelsAdapter,
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
handleDateChange,
|
||||||
|
datePickerHandlers,
|
||||||
|
|
||||||
|
// Translation
|
||||||
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import React, { memo, useState, useCallback } from 'react';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { selectTaskById, createSubtask, selectSubtaskLoading } from '@/features/task-management/task-management.slice';
|
||||||
|
import TaskRow from './TaskRow';
|
||||||
|
import SubtaskLoadingSkeleton from './SubtaskLoadingSkeleton';
|
||||||
|
import { Task } from '@/types/task-management.types';
|
||||||
|
import { Input, Button } from 'antd';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface TaskRowWithSubtasksProps {
|
||||||
|
taskId: string;
|
||||||
|
projectId: string;
|
||||||
|
visibleColumns: Array<{
|
||||||
|
id: string;
|
||||||
|
width: string;
|
||||||
|
isSticky?: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddSubtaskRowProps {
|
||||||
|
parentTaskId: string;
|
||||||
|
projectId: string;
|
||||||
|
visibleColumns: Array<{
|
||||||
|
id: string;
|
||||||
|
width: string;
|
||||||
|
isSticky?: boolean;
|
||||||
|
}>;
|
||||||
|
onSubtaskAdded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
||||||
|
parentTaskId,
|
||||||
|
projectId,
|
||||||
|
visibleColumns,
|
||||||
|
onSubtaskAdded
|
||||||
|
}) => {
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [subtaskName, setSubtaskName] = useState('');
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleAddSubtask = useCallback(() => {
|
||||||
|
if (!subtaskName.trim()) return;
|
||||||
|
|
||||||
|
// Create optimistic subtask immediately for better UX
|
||||||
|
dispatch(createSubtask({
|
||||||
|
parentTaskId,
|
||||||
|
name: subtaskName.trim(),
|
||||||
|
projectId
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Emit socket event for server-side creation
|
||||||
|
if (connected && socket) {
|
||||||
|
socket.emit(
|
||||||
|
SocketEvents.QUICK_TASK.toString(),
|
||||||
|
JSON.stringify({
|
||||||
|
name: subtaskName.trim(),
|
||||||
|
project_id: projectId,
|
||||||
|
parent_task_id: parentTaskId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubtaskName('');
|
||||||
|
setIsAdding(false);
|
||||||
|
onSubtaskAdded();
|
||||||
|
}, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, onSubtaskAdded]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
setSubtaskName('');
|
||||||
|
setIsAdding(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderColumn = useCallback((columnId: string, width: string) => {
|
||||||
|
const baseStyle = { width };
|
||||||
|
|
||||||
|
switch (columnId) {
|
||||||
|
case 'dragHandle':
|
||||||
|
return <div style={baseStyle} />;
|
||||||
|
case 'checkbox':
|
||||||
|
return <div style={baseStyle} />;
|
||||||
|
case 'taskKey':
|
||||||
|
return <div style={baseStyle} />;
|
||||||
|
case 'title':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center h-full" style={baseStyle}>
|
||||||
|
<div className="flex items-center w-full h-full">
|
||||||
|
{/* Match subtask indentation pattern - same as TaskRow for subtasks */}
|
||||||
|
<div className="w-8" />
|
||||||
|
|
||||||
|
{!isAdding ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors h-full"
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-xs" />
|
||||||
|
{t('addSubTaskText')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={subtaskName}
|
||||||
|
onChange={(e) => setSubtaskName(e.target.value)}
|
||||||
|
onPressEnter={handleAddSubtask}
|
||||||
|
onBlur={handleCancel}
|
||||||
|
placeholder="Type subtask name and press Enter to save"
|
||||||
|
className="w-full h-full border-none shadow-none bg-transparent"
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
minHeight: '42px',
|
||||||
|
padding: '0',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <div style={baseStyle} />;
|
||||||
|
}
|
||||||
|
}, [isAdding, subtaskName, handleAddSubtask, handleCancel, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center min-w-max px-4 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[42px]">
|
||||||
|
{visibleColumns.map((column) =>
|
||||||
|
renderColumn(column.id, column.width)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
AddSubtaskRow.displayName = 'AddSubtaskRow';
|
||||||
|
|
||||||
|
const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
|
||||||
|
taskId,
|
||||||
|
projectId,
|
||||||
|
visibleColumns
|
||||||
|
}) => {
|
||||||
|
const task = useAppSelector(state => selectTaskById(state, taskId));
|
||||||
|
const isLoadingSubtasks = useAppSelector(state => selectSubtaskLoading(state, taskId));
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleSubtaskAdded = useCallback(() => {
|
||||||
|
// Refresh subtasks after adding a new one
|
||||||
|
// The socket event will handle the real-time update
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Main task row */}
|
||||||
|
<TaskRow
|
||||||
|
taskId={taskId}
|
||||||
|
projectId={projectId}
|
||||||
|
visibleColumns={visibleColumns}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Subtasks and add subtask row when expanded */}
|
||||||
|
{task.show_sub_tasks && (
|
||||||
|
<>
|
||||||
|
{/* Show loading skeleton while fetching subtasks */}
|
||||||
|
{isLoadingSubtasks && (
|
||||||
|
<>
|
||||||
|
<SubtaskLoadingSkeleton visibleColumns={visibleColumns} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Render existing subtasks when not loading */}
|
||||||
|
{!isLoadingSubtasks && task.sub_tasks?.map((subtask: Task) => (
|
||||||
|
<div key={subtask.id} className="bg-gray-50 dark:bg-gray-800/50 border-l-2 border-blue-200 dark:border-blue-700">
|
||||||
|
<TaskRow
|
||||||
|
taskId={subtask.id}
|
||||||
|
projectId={projectId}
|
||||||
|
visibleColumns={visibleColumns}
|
||||||
|
isSubtask={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add subtask row - only show when not loading */}
|
||||||
|
{!isLoadingSubtasks && (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800/50 border-l-2 border-blue-200 dark:border-blue-700">
|
||||||
|
<AddSubtaskRow
|
||||||
|
parentTaskId={taskId}
|
||||||
|
projectId={projectId}
|
||||||
|
visibleColumns={visibleColumns}
|
||||||
|
onSubtaskAdded={handleSubtaskAdded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TaskRowWithSubtasks.displayName = 'TaskRowWithSubtasks';
|
||||||
|
|
||||||
|
export default TaskRowWithSubtasks;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
|
||||||
|
import { useTaskTimer } from '@/hooks/useTaskTimer';
|
||||||
|
|
||||||
|
interface TaskTimeTrackingProps {
|
||||||
|
taskId: string;
|
||||||
|
isDarkMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskTimeTracking: React.FC<TaskTimeTrackingProps> = React.memo(({ taskId, isDarkMode }) => {
|
||||||
|
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
|
||||||
|
taskId,
|
||||||
|
null // The hook will get the timer start time from Redux
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TaskTimer
|
||||||
|
taskId={taskId}
|
||||||
|
started={started}
|
||||||
|
handleStartTimer={handleStartTimer}
|
||||||
|
handleStopTimer={handleStopTimer}
|
||||||
|
timeString={timeString}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TaskTimeTracking.displayName = 'TaskTimeTracking';
|
||||||
|
|
||||||
|
export default TaskTimeTracking;
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
RetweetOutlined,
|
RetweetOutlined,
|
||||||
DownOutlined, // Added DownOutlined for expand/collapse
|
DownOutlined, // Added DownOutlined for expand/collapse
|
||||||
|
CloseOutlined, // Added CloseOutlined for clear button
|
||||||
} from '@/shared/antd-imports';
|
} from '@/shared/antd-imports';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
@@ -752,7 +753,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
className={`flex items-center px-2 ${borderClasses}`}
|
className={`flex items-center px-2 ${borderClasses}`}
|
||||||
style={{ width: col.width }}
|
style={{ width: col.width }}
|
||||||
>
|
>
|
||||||
<TaskKey taskKey={task.task_key} isDarkMode={isDarkMode} />
|
<TaskKey taskKey={task.task_key || ''} isDarkMode={isDarkMode} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -892,7 +893,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
className={`flex items-center px-2 ${borderClasses}`}
|
className={`flex items-center px-2 ${borderClasses}`}
|
||||||
style={{ width: col.width }}
|
style={{ width: col.width }}
|
||||||
>
|
>
|
||||||
<TaskKey taskKey={task.task_key} isDarkMode={isDarkMode} />
|
<TaskKey taskKey={task.task_key || ''} isDarkMode={isDarkMode} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1316,13 +1317,35 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
className={`flex items-center px-2 ${borderClasses}`}
|
className={`flex items-center px-2 ${borderClasses}`}
|
||||||
style={{ width: col.width }}
|
style={{ width: col.width }}
|
||||||
>
|
>
|
||||||
<DatePicker
|
<div className="w-full relative group">
|
||||||
{...taskManagementAntdConfig.datePickerDefaults}
|
<DatePicker
|
||||||
className="w-full bg-transparent border-none shadow-none"
|
{...taskManagementAntdConfig.datePickerDefaults}
|
||||||
value={dateValues.start}
|
className="w-full bg-transparent border-none shadow-none"
|
||||||
onChange={date => handleDateChange(date, 'startDate')}
|
value={dateValues.start}
|
||||||
placeholder="Start Date"
|
onChange={date => handleDateChange(date, 'startDate')}
|
||||||
/>
|
placeholder="Start Date"
|
||||||
|
allowClear={false} // We'll handle clear manually
|
||||||
|
suffixIcon={null}
|
||||||
|
/>
|
||||||
|
{/* Custom clear button - only show when there's a value */}
|
||||||
|
{dateValues.start && (
|
||||||
|
<button
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDateChange(null, 'startDate');
|
||||||
|
}}
|
||||||
|
className={`absolute right-1 top-1/2 transform -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 w-4 h-4 flex items-center justify-center rounded-full text-xs ${
|
||||||
|
isDarkMode
|
||||||
|
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
title="Clear start date"
|
||||||
|
>
|
||||||
|
<CloseOutlined style={{ fontSize: '10px' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1333,13 +1356,35 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
|
|||||||
className={`flex items-center px-2 ${borderClasses}`}
|
className={`flex items-center px-2 ${borderClasses}`}
|
||||||
style={{ width: col.width }}
|
style={{ width: col.width }}
|
||||||
>
|
>
|
||||||
<DatePicker
|
<div className="w-full relative group">
|
||||||
{...taskManagementAntdConfig.datePickerDefaults}
|
<DatePicker
|
||||||
className="w-full bg-transparent border-none shadow-none"
|
{...taskManagementAntdConfig.datePickerDefaults}
|
||||||
value={dateValues.due}
|
className="w-full bg-transparent border-none shadow-none"
|
||||||
onChange={date => handleDateChange(date, 'dueDate')}
|
value={dateValues.due}
|
||||||
placeholder="Due Date"
|
onChange={date => handleDateChange(date, 'dueDate')}
|
||||||
/>
|
placeholder="Due Date"
|
||||||
|
allowClear={false} // We'll handle clear manually
|
||||||
|
suffixIcon={null}
|
||||||
|
/>
|
||||||
|
{/* Custom clear button - only show when there's a value */}
|
||||||
|
{dateValues.due && (
|
||||||
|
<button
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDateChange(null, 'dueDate');
|
||||||
|
}}
|
||||||
|
className={`absolute right-1 top-1/2 transform -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 w-4 h-4 flex items-center justify-center rounded-full text-xs ${
|
||||||
|
isDarkMode
|
||||||
|
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
title="Clear due date"
|
||||||
|
>
|
||||||
|
<CloseOutlined style={{ fontSize: '10px' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const initialState: TaskManagementState = {
|
|||||||
grouping: undefined,
|
grouping: undefined,
|
||||||
selectedPriorities: [],
|
selectedPriorities: [],
|
||||||
search: '',
|
search: '',
|
||||||
|
loadingSubtasks: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Async thunk to fetch tasks from API
|
// Async thunk to fetch tasks from API
|
||||||
@@ -151,11 +152,12 @@ export const fetchTasks = createAsyncThunk(
|
|||||||
task.labels?.map((l: any) => ({
|
task.labels?.map((l: any) => ({
|
||||||
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,
|
||||||
})) || [],
|
})) || [],
|
||||||
dueDate: task.end_date,
|
dueDate: task.dueDate,
|
||||||
|
startDate: task.startDate,
|
||||||
timeTracking: {
|
timeTracking: {
|
||||||
estimated: convertTimeValue(task.total_time),
|
estimated: convertTimeValue(task.total_time),
|
||||||
logged: convertTimeValue(task.time_spent),
|
logged: convertTimeValue(task.time_spent),
|
||||||
@@ -163,6 +165,8 @@ export const fetchTasks = createAsyncThunk(
|
|||||||
customFields: {},
|
customFields: {},
|
||||||
createdAt: task.created_at || new Date().toISOString(),
|
createdAt: task.created_at || new Date().toISOString(),
|
||||||
updatedAt: task.updated_at || new Date().toISOString(),
|
updatedAt: task.updated_at || new Date().toISOString(),
|
||||||
|
created_at: task.created_at || new Date().toISOString(),
|
||||||
|
updated_at: task.updated_at || new Date().toISOString(),
|
||||||
order: typeof task.sort_order === 'number' ? task.sort_order : 0,
|
order: typeof task.sort_order === 'number' ? task.sort_order : 0,
|
||||||
// Ensure all Task properties are mapped, even if undefined in API response
|
// Ensure all Task properties are mapped, even if undefined in API response
|
||||||
sub_tasks: task.sub_tasks || [],
|
sub_tasks: task.sub_tasks || [],
|
||||||
@@ -234,16 +238,13 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
|
|
||||||
const response = await tasksApiService.getTaskListV3(config);
|
const response = await tasksApiService.getTaskListV3(config);
|
||||||
|
|
||||||
// Log raw response for debugging
|
|
||||||
console.log('Raw API response:', response.body);
|
|
||||||
console.log('Sample task from backend:', response.body.allTasks?.[0]);
|
|
||||||
console.log('Task key from backend:', response.body.allTasks?.[0]?.task_key);
|
|
||||||
|
|
||||||
// Ensure tasks are properly normalized
|
// Ensure tasks are properly normalized
|
||||||
const tasks: Task[] = response.body.allTasks.map((task: any) => {
|
const tasks: Task[] = response.body.allTasks.map((task: any) => {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
return {
|
const transformedTask = {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
task_key: task.task_key || task.key || '',
|
task_key: task.task_key || task.key || '',
|
||||||
title: (task.title && task.title.trim()) ? task.title.trim() : DEFAULT_TASK_NAME,
|
title: (task.title && task.title.trim()) ? task.title.trim() : DEFAULT_TASK_NAME,
|
||||||
@@ -257,11 +258,12 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
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_code: 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,
|
||||||
})) || [],
|
})) || [],
|
||||||
dueDate: task.end_date,
|
dueDate: task.dueDate,
|
||||||
|
startDate: task.startDate,
|
||||||
timeTracking: {
|
timeTracking: {
|
||||||
estimated: convertTimeValue(task.total_time),
|
estimated: convertTimeValue(task.total_time),
|
||||||
logged: convertTimeValue(task.time_spent),
|
logged: convertTimeValue(task.time_spent),
|
||||||
@@ -269,6 +271,8 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
customFields: {},
|
customFields: {},
|
||||||
createdAt: task.created_at || now,
|
createdAt: task.created_at || now,
|
||||||
updatedAt: task.updated_at || now,
|
updatedAt: task.updated_at || now,
|
||||||
|
created_at: task.created_at || now,
|
||||||
|
updated_at: task.updated_at || now,
|
||||||
order: typeof task.sort_order === 'number' ? task.sort_order : 0,
|
order: typeof task.sort_order === 'number' ? task.sort_order : 0,
|
||||||
sub_tasks: task.sub_tasks || [],
|
sub_tasks: task.sub_tasks || [],
|
||||||
sub_tasks_count: task.sub_tasks_count || 0,
|
sub_tasks_count: task.sub_tasks_count || 0,
|
||||||
@@ -283,6 +287,8 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
has_dependencies: task.has_dependencies || false,
|
has_dependencies: task.has_dependencies || false,
|
||||||
schedule_id: task.schedule_id || null,
|
schedule_id: task.schedule_id || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return transformedTask;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -698,6 +704,68 @@ const taskManagementSlice = createSlice({
|
|||||||
parent.sub_tasks_count = (parent.sub_tasks_count || 0) + 1;
|
parent.sub_tasks_count = (parent.sub_tasks_count || 0) + 1;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
createSubtask: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ parentTaskId: string; name: string; projectId: string }>
|
||||||
|
) => {
|
||||||
|
const { parentTaskId, name, projectId } = action.payload;
|
||||||
|
const parent = state.entities[parentTaskId];
|
||||||
|
if (parent) {
|
||||||
|
// Create a temporary subtask - the real one will come from the socket
|
||||||
|
const tempId = `temp-${Date.now()}`;
|
||||||
|
const tempSubtask: Task = {
|
||||||
|
id: tempId,
|
||||||
|
task_key: '',
|
||||||
|
title: name,
|
||||||
|
name: name,
|
||||||
|
description: '',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 'low',
|
||||||
|
phase: 'Development',
|
||||||
|
progress: 0,
|
||||||
|
assignees: [],
|
||||||
|
assignee_names: [],
|
||||||
|
labels: [],
|
||||||
|
dueDate: undefined,
|
||||||
|
due_date: undefined,
|
||||||
|
startDate: undefined,
|
||||||
|
timeTracking: {
|
||||||
|
estimated: 0,
|
||||||
|
logged: 0,
|
||||||
|
},
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
order: 0,
|
||||||
|
parent_task_id: parentTaskId,
|
||||||
|
is_sub_task: true,
|
||||||
|
sub_tasks_count: 0,
|
||||||
|
show_sub_tasks: false,
|
||||||
|
isTemporary: true, // Mark as temporary
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add temporary subtask for immediate UI feedback
|
||||||
|
if (!parent.sub_tasks) {
|
||||||
|
parent.sub_tasks = [];
|
||||||
|
}
|
||||||
|
parent.sub_tasks.push(tempSubtask);
|
||||||
|
parent.sub_tasks_count = (parent.sub_tasks_count || 0) + 1;
|
||||||
|
state.entities[tempId] = tempSubtask;
|
||||||
|
state.ids.push(tempId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeTemporarySubtask: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ parentTaskId: string; tempId: string }>
|
||||||
|
) => {
|
||||||
|
const { parentTaskId, tempId } = action.payload;
|
||||||
|
const parent = state.entities[parentTaskId];
|
||||||
|
if (parent && parent.sub_tasks) {
|
||||||
|
parent.sub_tasks = parent.sub_tasks.filter(subtask => subtask.id !== tempId);
|
||||||
|
parent.sub_tasks_count = Math.max((parent.sub_tasks_count || 0) - 1, 0);
|
||||||
|
delete state.entities[tempId];
|
||||||
|
state.ids = state.ids.filter(id => id !== tempId);
|
||||||
|
}
|
||||||
|
},
|
||||||
updateTaskAssignees: (state, action: PayloadAction<{
|
updateTaskAssignees: (state, action: PayloadAction<{
|
||||||
taskId: string;
|
taskId: string;
|
||||||
assigneeIds: string[];
|
assigneeIds: string[];
|
||||||
@@ -714,6 +782,7 @@ const taskManagementSlice = createSlice({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder
|
builder
|
||||||
@@ -737,20 +806,66 @@ const taskManagementSlice = createSlice({
|
|||||||
state.groups = [];
|
state.groups = [];
|
||||||
})
|
})
|
||||||
.addCase(fetchSubTasks.pending, (state, action) => {
|
.addCase(fetchSubTasks.pending, (state, action) => {
|
||||||
// Don't set global loading state for subtasks
|
// Set loading state for specific task
|
||||||
|
const { taskId } = action.meta.arg;
|
||||||
|
state.loadingSubtasks[taskId] = true;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
})
|
})
|
||||||
.addCase(fetchSubTasks.fulfilled, (state, action) => {
|
.addCase(fetchSubTasks.fulfilled, (state, action) => {
|
||||||
const { parentTaskId, subtasks } = action.payload;
|
const { parentTaskId, subtasks } = action.payload;
|
||||||
const parentTask = state.entities[parentTaskId];
|
const parentTask = state.entities[parentTaskId];
|
||||||
if (parentTask) {
|
// Clear loading state
|
||||||
parentTask.sub_tasks = subtasks;
|
state.loadingSubtasks[parentTaskId] = false;
|
||||||
parentTask.sub_tasks_count = subtasks.length;
|
if (parentTask && subtasks) {
|
||||||
parentTask.show_sub_tasks = true;
|
// Convert subtasks to the proper format
|
||||||
|
const convertedSubtasks = subtasks.map(subtask => ({
|
||||||
|
id: subtask.id || '',
|
||||||
|
task_key: subtask.task_key || '',
|
||||||
|
title: subtask.name || subtask.title || '',
|
||||||
|
name: subtask.name || subtask.title || '',
|
||||||
|
description: subtask.description || '',
|
||||||
|
status: subtask.status || 'todo',
|
||||||
|
priority: subtask.priority || 'low',
|
||||||
|
phase: subtask.phase_name || subtask.phase || 'Development',
|
||||||
|
progress: subtask.complete_ratio || subtask.progress || 0,
|
||||||
|
assignees: subtask.assignees || [],
|
||||||
|
assignee_names: subtask.assignee_names || subtask.names || [],
|
||||||
|
labels: subtask.labels || [],
|
||||||
|
dueDate: subtask.end_date || subtask.dueDate,
|
||||||
|
due_date: subtask.end_date || subtask.due_date,
|
||||||
|
startDate: subtask.start_date || subtask.startDate,
|
||||||
|
timeTracking: subtask.timeTracking || {
|
||||||
|
estimated: 0,
|
||||||
|
logged: 0,
|
||||||
|
},
|
||||||
|
createdAt: subtask.created_at || subtask.createdAt || new Date().toISOString(),
|
||||||
|
created_at: subtask.created_at || subtask.createdAt || new Date().toISOString(),
|
||||||
|
updatedAt: subtask.updated_at || subtask.updatedAt || new Date().toISOString(),
|
||||||
|
updated_at: subtask.updated_at || subtask.updatedAt || new Date().toISOString(),
|
||||||
|
order: subtask.sort_order || subtask.order || 0,
|
||||||
|
parent_task_id: parentTaskId,
|
||||||
|
is_sub_task: true,
|
||||||
|
sub_tasks_count: 0,
|
||||||
|
show_sub_tasks: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update parent task with subtasks
|
||||||
|
parentTask.sub_tasks = convertedSubtasks;
|
||||||
|
parentTask.sub_tasks_count = convertedSubtasks.length;
|
||||||
|
|
||||||
|
// Add subtasks to entities so they can be accessed by ID
|
||||||
|
convertedSubtasks.forEach(subtask => {
|
||||||
|
state.entities[subtask.id] = subtask;
|
||||||
|
if (!state.ids.includes(subtask.id)) {
|
||||||
|
state.ids.push(subtask.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.addCase(fetchSubTasks.rejected, (state, action) => {
|
.addCase(fetchSubTasks.rejected, (state, action) => {
|
||||||
// Set error but don't clear task data
|
// Clear loading state and set error
|
||||||
|
const { taskId } = action.meta.arg;
|
||||||
|
state.loadingSubtasks[taskId] = false;
|
||||||
state.error = action.error.message || action.payload || 'Failed to fetch subtasks. Please try again.';
|
state.error = action.error.message || action.payload || 'Failed to fetch subtasks. Please try again.';
|
||||||
})
|
})
|
||||||
.addCase(fetchTasks.pending, (state) => {
|
.addCase(fetchTasks.pending, (state) => {
|
||||||
@@ -796,6 +911,8 @@ export const {
|
|||||||
toggleTaskExpansion,
|
toggleTaskExpansion,
|
||||||
addSubtaskToParent,
|
addSubtaskToParent,
|
||||||
updateTaskAssignees,
|
updateTaskAssignees,
|
||||||
|
createSubtask,
|
||||||
|
removeTemporarySubtask,
|
||||||
} = taskManagementSlice.actions;
|
} = taskManagementSlice.actions;
|
||||||
|
|
||||||
// Export the selectors
|
// Export the selectors
|
||||||
@@ -809,6 +926,7 @@ export const selectLoading = (state: RootState) => state.taskManagement.loading;
|
|||||||
export const selectError = (state: RootState) => state.taskManagement.error;
|
export const selectError = (state: RootState) => state.taskManagement.error;
|
||||||
export const selectSelectedPriorities = (state: RootState) => state.taskManagement.selectedPriorities;
|
export const selectSelectedPriorities = (state: RootState) => state.taskManagement.selectedPriorities;
|
||||||
export const selectSearch = (state: RootState) => state.taskManagement.search;
|
export const selectSearch = (state: RootState) => state.taskManagement.search;
|
||||||
|
export const selectSubtaskLoading = (state: RootState, taskId: string) => state.taskManagement.loadingSubtasks[taskId] || false;
|
||||||
|
|
||||||
// Memoized selectors
|
// Memoized selectors
|
||||||
export const selectTasksByStatus = (state: RootState, status: string) =>
|
export const selectTasksByStatus = (state: RootState, status: string) =>
|
||||||
|
|||||||
@@ -200,49 +200,57 @@ export const useTaskSocketHandlers = () => {
|
|||||||
// Update enhanced kanban slice
|
// Update enhanced kanban slice
|
||||||
dispatch(updateEnhancedKanbanTaskStatus(response));
|
dispatch(updateEnhancedKanbanTaskStatus(response));
|
||||||
|
|
||||||
// For the task management slice, move task between groups without resetting
|
// For the task management slice, update the task entity and handle group movement
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
const groups = state.taskManagement.groups;
|
const groups = state.taskManagement.groups;
|
||||||
const currentTask = state.taskManagement.entities[response.id];
|
const currentTask = state.taskManagement.entities[response.id];
|
||||||
|
const currentGrouping = state.taskManagement.grouping;
|
||||||
|
|
||||||
if (groups && groups.length > 0 && currentTask && response.status_id) {
|
if (currentTask) {
|
||||||
// Find current group containing the task
|
// Determine the new status value based on status category
|
||||||
const currentGroup = groups.find(group => group.taskIds.includes(response.id));
|
let newStatusValue: 'todo' | 'doing' | 'done' = 'todo';
|
||||||
|
if (response.statusCategory) {
|
||||||
// Find target group based on new status ID
|
if (response.statusCategory.is_done) {
|
||||||
// The status_id from response is the UUID of the new status
|
newStatusValue = 'done';
|
||||||
const targetGroup = groups.find(group => group.id === response.status_id);
|
} else if (response.statusCategory.is_doing) {
|
||||||
|
newStatusValue = 'doing';
|
||||||
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
|
} else {
|
||||||
// Determine the new status value based on status category
|
newStatusValue = 'todo';
|
||||||
let newStatusValue: 'todo' | 'doing' | 'done' = 'todo';
|
|
||||||
if (response.statusCategory) {
|
|
||||||
if (response.statusCategory.is_done) {
|
|
||||||
newStatusValue = 'done';
|
|
||||||
} else if (response.statusCategory.is_doing) {
|
|
||||||
newStatusValue = 'doing';
|
|
||||||
} else {
|
|
||||||
newStatusValue = 'todo';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use the new action to move task between groups
|
// Update the task entity first
|
||||||
dispatch(
|
dispatch(
|
||||||
moveTaskBetweenGroups({
|
updateTask({
|
||||||
taskId: response.id,
|
...currentTask,
|
||||||
fromGroupId: currentGroup.id,
|
status: newStatusValue,
|
||||||
toGroupId: targetGroup.id,
|
progress: response.complete_ratio || currentTask.progress,
|
||||||
taskUpdate: {
|
updatedAt: new Date().toISOString(),
|
||||||
status: newStatusValue,
|
})
|
||||||
progress: response.complete_ratio || currentTask.progress,
|
);
|
||||||
},
|
|
||||||
})
|
// Handle group movement ONLY if grouping by status
|
||||||
);
|
if (groups && groups.length > 0 && currentGrouping === 'status') {
|
||||||
} else if (!currentGroup || !targetGroup) {
|
// Find current group containing the task
|
||||||
// Remove unnecessary refetch that causes data thrashing
|
const currentGroup = groups.find(group => group.taskIds.includes(response.id));
|
||||||
// if (projectId) {
|
|
||||||
// dispatch(fetchTasksV3(projectId));
|
// Find target group based on new status value (not UUID)
|
||||||
// }
|
const targetGroup = groups.find(group => group.groupValue === newStatusValue);
|
||||||
|
|
||||||
|
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
|
||||||
|
// Use the action to move task between groups
|
||||||
|
dispatch(
|
||||||
|
moveTaskBetweenGroups({
|
||||||
|
taskId: response.id,
|
||||||
|
sourceGroupId: currentGroup.id,
|
||||||
|
targetGroupId: targetGroup.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log('🔧 No group movement needed for status change');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('🔧 Not grouped by status, skipping group movement');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -310,9 +318,10 @@ export const useTaskSocketHandlers = () => {
|
|||||||
// Update enhanced kanban slice
|
// Update enhanced kanban slice
|
||||||
dispatch(updateEnhancedKanbanTaskPriority(response));
|
dispatch(updateEnhancedKanbanTaskPriority(response));
|
||||||
|
|
||||||
// For the task management slice, always update the task entity first
|
// For the task management slice, update the task entity and handle group movement
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
const currentTask = state.taskManagement.entities[response.id];
|
const currentTask = state.taskManagement.entities[response.id];
|
||||||
|
const currentGrouping = state.taskManagement.grouping;
|
||||||
|
|
||||||
if (currentTask) {
|
if (currentTask) {
|
||||||
// Get priority list to map priority_id to priority name
|
// Get priority list to map priority_id to priority name
|
||||||
@@ -327,20 +336,17 @@ export const useTaskSocketHandlers = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the task entity
|
// Update the task entity first
|
||||||
dispatch(
|
dispatch(
|
||||||
updateTask({
|
updateTask({
|
||||||
id: response.id,
|
...currentTask,
|
||||||
changes: {
|
priority: newPriorityValue,
|
||||||
priority: newPriorityValue,
|
updatedAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle group movement ONLY if grouping by priority
|
// Handle group movement ONLY if grouping by priority
|
||||||
const groups = state.taskManagement.groups;
|
const groups = state.taskManagement.groups;
|
||||||
const currentGrouping = state.taskManagement.grouping;
|
|
||||||
|
|
||||||
if (groups && groups.length > 0 && currentGrouping === 'priority') {
|
if (groups && groups.length > 0 && currentGrouping === 'priority') {
|
||||||
// Find current group containing the task
|
// Find current group containing the task
|
||||||
@@ -348,18 +354,15 @@ export const useTaskSocketHandlers = () => {
|
|||||||
|
|
||||||
// Find target group based on new priority value
|
// Find target group based on new priority value
|
||||||
const targetGroup = groups.find(
|
const targetGroup = groups.find(
|
||||||
group => group.groupValue.toLowerCase() === newPriorityValue.toLowerCase()
|
group => group.groupValue?.toLowerCase() === newPriorityValue.toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
|
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
|
||||||
dispatch(
|
dispatch(
|
||||||
moveTaskBetweenGroups({
|
moveTaskBetweenGroups({
|
||||||
taskId: response.id,
|
taskId: response.id,
|
||||||
fromGroupId: currentGroup.id,
|
sourceGroupId: currentGroup.id,
|
||||||
toGroupId: targetGroup.id,
|
targetGroupId: targetGroup.id,
|
||||||
taskUpdate: {
|
|
||||||
priority: newPriorityValue,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -387,6 +390,16 @@ export const useTaskSocketHandlers = () => {
|
|||||||
|
|
||||||
// Update enhanced kanban slice
|
// Update enhanced kanban slice
|
||||||
dispatch(updateEnhancedKanbanTaskEndDate({ task: taskWithProgress }));
|
dispatch(updateEnhancedKanbanTaskEndDate({ task: taskWithProgress }));
|
||||||
|
|
||||||
|
// Update task-management slice for task-list-v2 components
|
||||||
|
const currentTask = store.getState().taskManagement.entities[task.id];
|
||||||
|
if (currentTask) {
|
||||||
|
dispatch(updateTask({
|
||||||
|
...currentTask,
|
||||||
|
dueDate: task.end_date,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
@@ -517,6 +530,16 @@ export const useTaskSocketHandlers = () => {
|
|||||||
|
|
||||||
dispatch(updateTaskStartDate({ task: taskWithProgress }));
|
dispatch(updateTaskStartDate({ task: taskWithProgress }));
|
||||||
dispatch(setStartDate(taskWithProgress));
|
dispatch(setStartDate(taskWithProgress));
|
||||||
|
|
||||||
|
// Update task-management slice for task-list-v2 components
|
||||||
|
const currentTask = store.getState().taskManagement.entities[task.id];
|
||||||
|
if (currentTask) {
|
||||||
|
dispatch(updateTask({
|
||||||
|
...currentTask,
|
||||||
|
startDate: task.start_date,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
@@ -599,7 +622,7 @@ export const useTaskSocketHandlers = () => {
|
|||||||
parent_task_id: data.parent_task_id,
|
parent_task_id: data.parent_task_id,
|
||||||
is_sub_task: true,
|
is_sub_task: true,
|
||||||
};
|
};
|
||||||
dispatch(addSubtaskToParent({ subtask, parentTaskId: data.parent_task_id }));
|
dispatch(addSubtaskToParent({ parentId: data.parent_task_id, subtask }));
|
||||||
|
|
||||||
// Also update enhanced kanban slice for subtask creation
|
// Also update enhanced kanban slice for subtask creation
|
||||||
dispatch(
|
dispatch(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Flex } from 'antd';
|
import { Flex } from 'antd';
|
||||||
import CustomColordLabel from '@/components/taskListCommon/labelsSelector/CustomColordLabel';
|
|
||||||
import CustomNumberLabel from '@/components/taskListCommon/labelsSelector/CustomNumberLabel';
|
import CustomNumberLabel from '@/components/taskListCommon/labelsSelector/CustomNumberLabel';
|
||||||
import LabelsSelector from '@/components/taskListCommon/labelsSelector/LabelsSelector';
|
import LabelsSelector from '@/components/taskListCommon/labelsSelector/LabelsSelector';
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
|
import { CustomColordLabel } from '@/components';
|
||||||
|
|
||||||
interface TaskListLabelsCellProps {
|
interface TaskListLabelsCellProps {
|
||||||
task: IProjectTask;
|
task: IProjectTask;
|
||||||
|
|||||||
@@ -310,8 +310,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark .ant-btn {
|
.dark .ant-btn {
|
||||||
background-color: #262626;
|
|
||||||
border-color: #404040;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export interface TaskManagementState {
|
|||||||
grouping: string | undefined;
|
grouping: string | undefined;
|
||||||
selectedPriorities: string[];
|
selectedPriorities: string[];
|
||||||
search: string;
|
search: string;
|
||||||
|
loadingSubtasks: Record<string, boolean>; // Track loading state for individual tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskGroupsState {
|
export interface TaskGroupsState {
|
||||||
|
|||||||
Reference in New Issue
Block a user