Merge pull request #195 from Worklenz/fix/task-list-realtime-update
Fix/task list realtime update
This commit is contained in:
@@ -1,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"noTasksInGroup": "Nuk ka detyra në këtë grup",
|
"noTasksInGroup": "Nuk ka detyra në këtë grup",
|
||||||
"noTasksInGroupDescription": "Shtoni një detyrë për të filluar",
|
"noTasksInGroupDescription": "Shtoni një detyrë për të filluar",
|
||||||
"addFirstTask": "Shtoni detyrën tuaj të parë"
|
"addFirstTask": "Shtoni detyrën tuaj të parë",
|
||||||
|
"openTask": "Hap",
|
||||||
|
"subtask": "nën-detyrë",
|
||||||
|
"subtasks": "nën-detyra",
|
||||||
|
"comment": "koment",
|
||||||
|
"comments": "komente",
|
||||||
|
"attachment": "bashkëngjitje",
|
||||||
|
"attachments": "bashkëngjitje",
|
||||||
|
"enterSubtaskName": "Shkruani emrin e nën-detyrës...",
|
||||||
|
"add": "Shto",
|
||||||
|
"cancel": "Anulo"
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"noTasksInGroup": "Keine Aufgaben in dieser Gruppe",
|
"noTasksInGroup": "Keine Aufgaben in dieser Gruppe",
|
||||||
"noTasksInGroupDescription": "Fügen Sie eine Aufgabe hinzu, um zu beginnen",
|
"noTasksInGroupDescription": "Fügen Sie eine Aufgabe hinzu, um zu beginnen",
|
||||||
"addFirstTask": "Fügen Sie Ihre erste Aufgabe hinzu"
|
"addFirstTask": "Fügen Sie Ihre erste Aufgabe hinzu",
|
||||||
|
"openTask": "Öffnen",
|
||||||
|
"subtask": "Unteraufgabe",
|
||||||
|
"subtasks": "Unteraufgaben",
|
||||||
|
"comment": "Kommentar",
|
||||||
|
"comments": "Kommentare",
|
||||||
|
"attachment": "Anhang",
|
||||||
|
"attachments": "Anhänge",
|
||||||
|
"enterSubtaskName": "Unteraufgabenname eingeben...",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"cancel": "Abbrechen"
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"noTasksInGroup": "No tasks in this group",
|
"noTasksInGroup": "No tasks in this group",
|
||||||
"noTasksInGroupDescription": "Add a task to get started",
|
"noTasksInGroupDescription": "Add a task to get started",
|
||||||
"addFirstTask": "Add your first task"
|
"addFirstTask": "Add your first task",
|
||||||
|
"openTask": "Open",
|
||||||
|
"subtask": "subtask",
|
||||||
|
"subtasks": "subtasks",
|
||||||
|
"comment": "comment",
|
||||||
|
"comments": "comments",
|
||||||
|
"attachment": "attachment",
|
||||||
|
"attachments": "attachments",
|
||||||
|
"enterSubtaskName": "Enter subtask name...",
|
||||||
|
"add": "Add",
|
||||||
|
"cancel": "Cancel"
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"noTasksInGroup": "No hay tareas en este grupo",
|
"noTasksInGroup": "No hay tareas en este grupo",
|
||||||
"noTasksInGroupDescription": "Añade una tarea para comenzar",
|
"noTasksInGroupDescription": "Añade una tarea para comenzar",
|
||||||
"addFirstTask": "Añade tu primera tarea"
|
"addFirstTask": "Añade tu primera tarea",
|
||||||
|
"openTask": "Abrir",
|
||||||
|
"subtask": "subtarea",
|
||||||
|
"subtasks": "subtareas",
|
||||||
|
"comment": "comentario",
|
||||||
|
"comments": "comentarios",
|
||||||
|
"attachment": "adjunto",
|
||||||
|
"attachments": "adjuntos",
|
||||||
|
"enterSubtaskName": "Ingresa el nombre de la subtarea...",
|
||||||
|
"add": "Añadir",
|
||||||
|
"cancel": "Cancelar"
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"noTasksInGroup": "Nenhuma tarefa neste grupo",
|
"noTasksInGroup": "Nenhuma tarefa neste grupo",
|
||||||
"noTasksInGroupDescription": "Adicione uma tarefa para começar",
|
"noTasksInGroupDescription": "Adicione uma tarefa para começar",
|
||||||
"addFirstTask": "Adicione sua primeira tarefa"
|
"addFirstTask": "Adicione sua primeira tarefa",
|
||||||
|
"openTask": "Abrir",
|
||||||
|
"subtask": "subtarefa",
|
||||||
|
"subtasks": "subtarefas",
|
||||||
|
"comment": "comentário",
|
||||||
|
"comments": "comentários",
|
||||||
|
"attachment": "anexo",
|
||||||
|
"attachments": "anexos",
|
||||||
|
"enterSubtaskName": "Digite o nome da subtarefa...",
|
||||||
|
"add": "Adicionar",
|
||||||
|
"cancel": "Cancelar"
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,20 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
loading,
|
loading,
|
||||||
t
|
t
|
||||||
}) => {
|
}) => {
|
||||||
|
// Preload project view components on hover for smoother navigation
|
||||||
|
const handleProjectHover = React.useCallback((project_id: string) => {
|
||||||
|
if (project_id) {
|
||||||
|
// Preload the project view route to reduce loading time
|
||||||
|
import('@/pages/projects/projectView/project-view').catch(() => {
|
||||||
|
// Silently fail if preload doesn't work
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also preload critical task management components
|
||||||
|
import('@/components/task-management/task-list-board').catch(() => {
|
||||||
|
// Silently fail if preload doesn't work
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -360,6 +374,8 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
if (actionButtons) {
|
if (actionButtons) {
|
||||||
actionButtons.style.opacity = '1';
|
actionButtons.style.opacity = '1';
|
||||||
}
|
}
|
||||||
|
// Preload components for smoother navigation
|
||||||
|
handleProjectHover(project.id);
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
Object.assign(e.currentTarget.style, styles.projectCard);
|
Object.assign(e.currentTarget.style, styles.projectCard);
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export const SuspenseFallback = memo(() => {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -45,12 +45,8 @@ import VirtualizedTaskList from './virtualized-task-list';
|
|||||||
import { AppDispatch } from '@/app/store';
|
import { AppDispatch } from '@/app/store';
|
||||||
import { shallowEqual } from 'react-redux';
|
import { shallowEqual } from 'react-redux';
|
||||||
|
|
||||||
// Import the improved TaskListFilters component
|
// Import the improved TaskListFilters component synchronously to avoid suspense
|
||||||
const ImprovedTaskFilters = React.lazy(
|
import ImprovedTaskFilters from './improved-task-filters';
|
||||||
() => import('./improved-task-filters')
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface TaskListBoardProps {
|
interface TaskListBoardProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -393,9 +389,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
|
|
||||||
{/* Task Filters */}
|
{/* Task Filters */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<React.Suspense fallback={<div>Loading filters...</div>}>
|
<ImprovedTaskFilters position="list" />
|
||||||
<ImprovedTaskFilters position="list" />
|
|
||||||
</React.Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Virtualized Task Groups Container */}
|
{/* Virtualized Task Groups Container */}
|
||||||
|
|||||||
@@ -235,4 +235,105 @@
|
|||||||
|
|
||||||
.task-row-optimized * {
|
.task-row-optimized * {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task row hover effects for better performance */
|
||||||
|
.task-cell-container:hover .task-open-button {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-open-button {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expand icon smart visibility */
|
||||||
|
.expand-icon-container {
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Always show expand icon if task has subtasks */
|
||||||
|
.expand-icon-container.has-subtasks {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon-container.has-subtasks .expand-toggle-btn {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-cell-container:hover .expand-icon-container.has-subtasks .expand-toggle-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show expand icon on hover for tasks without subtasks (for adding subtasks) */
|
||||||
|
.expand-icon-container.hover-only {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-cell-container:hover .expand-icon-container.hover-only {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon-container.hover-only .expand-toggle-btn {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-cell-container:hover .expand-icon-container.hover-only .expand-toggle-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add subtask row styling */
|
||||||
|
.add-subtask-row {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-subtask-row.visible {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 60px;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-subtask-input {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-subtask-input:focus {
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode add subtask row */
|
||||||
|
.dark .add-subtask-row {
|
||||||
|
background-color: #1f2937;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .add-subtask-input {
|
||||||
|
background-color: #374151;
|
||||||
|
border-color: #4b5563;
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .add-subtask-input:focus {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
box-shadow: 0 2px 8px rgba(96, 165, 250, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task indicators hover effects */
|
||||||
|
.task-indicators .indicator-badge {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-indicators .indicator-badge:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode specific hover effects */
|
||||||
|
.dark .task-indicators .indicator-badge:hover {
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
UserOutlined,
|
UserOutlined,
|
||||||
type InputRef
|
type InputRef
|
||||||
} from './antd-imports';
|
} from './antd-imports';
|
||||||
|
import { DownOutlined, RightOutlined, ExpandAltOutlined, DoubleRightOutlined } from '@ant-design/icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tooltip } from '@/components';
|
import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tooltip } from '@/components';
|
||||||
@@ -185,7 +187,10 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
// Edit task name state
|
// Edit task name state
|
||||||
const [editTaskName, setEditTaskName] = useState(false);
|
const [editTaskName, setEditTaskName] = useState(false);
|
||||||
const [taskName, setTaskName] = useState(task.title || '');
|
const [taskName, setTaskName] = useState(task.title || '');
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const [showAddSubtask, setShowAddSubtask] = useState(false);
|
||||||
|
const [newSubtaskName, setNewSubtaskName] = useState('');
|
||||||
|
const inputRef = useRef<InputRef>(null);
|
||||||
|
const addSubtaskInputRef = useRef<InputRef>(null);
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Intersection Observer for lazy loading
|
// PERFORMANCE OPTIMIZATION: Intersection Observer for lazy loading
|
||||||
@@ -244,6 +249,9 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
|
|
||||||
// Get theme from Redux store - memoized selector
|
// Get theme from Redux store - memoized selector
|
||||||
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
||||||
|
|
||||||
|
// Translation hook
|
||||||
|
const { t } = useTranslation('task-management');
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Only setup click outside detection when editing
|
// PERFORMANCE OPTIMIZATION: Only setup click outside detection when editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -265,7 +273,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
|
|
||||||
// Optimized task name save handler
|
// Optimized task name save handler
|
||||||
const handleTaskNameSave = useCallback(() => {
|
const handleTaskNameSave = useCallback(() => {
|
||||||
const newTaskName = inputRef.current?.value?.trim();
|
const newTaskName = taskName?.trim();
|
||||||
if (newTaskName && connected && newTaskName !== task.title) {
|
if (newTaskName && connected && newTaskName !== task.title) {
|
||||||
socket?.emit(
|
socket?.emit(
|
||||||
SocketEvents.TASK_NAME_CHANGE.toString(),
|
SocketEvents.TASK_NAME_CHANGE.toString(),
|
||||||
@@ -277,7 +285,30 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
setEditTaskName(false);
|
setEditTaskName(false);
|
||||||
}, [connected, socket, task.id, task.title]);
|
}, [connected, socket, task.id, task.title, taskName]);
|
||||||
|
|
||||||
|
// Handle adding new subtask
|
||||||
|
const handleAddSubtask = useCallback(() => {
|
||||||
|
const subtaskName = newSubtaskName?.trim();
|
||||||
|
if (subtaskName && connected) {
|
||||||
|
socket?.emit(
|
||||||
|
SocketEvents.TASK_NAME_CHANGE.toString(), // Using existing event for now
|
||||||
|
JSON.stringify({
|
||||||
|
name: subtaskName,
|
||||||
|
parent_task_id: task.id,
|
||||||
|
project_id: projectId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setNewSubtaskName('');
|
||||||
|
setShowAddSubtask(false);
|
||||||
|
}
|
||||||
|
}, [newSubtaskName, connected, socket, task.id, projectId]);
|
||||||
|
|
||||||
|
// Handle canceling add subtask
|
||||||
|
const handleCancelAddSubtask = useCallback(() => {
|
||||||
|
setNewSubtaskName('');
|
||||||
|
setShowAddSubtask(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Optimized style calculations with better memoization
|
// Optimized style calculations with better memoization
|
||||||
const dragStyle = useMemo(() => {
|
const dragStyle = useMemo(() => {
|
||||||
@@ -302,6 +333,18 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
onToggleSubtasks?.(task.id);
|
onToggleSubtasks?.(task.id);
|
||||||
}, [onToggleSubtasks, task.id]);
|
}, [onToggleSubtasks, task.id]);
|
||||||
|
|
||||||
|
// Handle expand/collapse or add subtask
|
||||||
|
const handleExpandClick = useCallback(() => {
|
||||||
|
// For now, just toggle add subtask row for all tasks
|
||||||
|
setShowAddSubtask(!showAddSubtask);
|
||||||
|
if (!showAddSubtask) {
|
||||||
|
// Focus the input after state update
|
||||||
|
setTimeout(() => {
|
||||||
|
addSubtaskInputRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [showAddSubtask]);
|
||||||
|
|
||||||
// Optimized date handling with better memoization
|
// Optimized date handling with better memoization
|
||||||
const dateValues = useMemo(() => ({
|
const dateValues = useMemo(() => ({
|
||||||
start: task.startDate ? dayjs(task.startDate) : undefined,
|
start: task.startDate ? dayjs(task.startDate) : undefined,
|
||||||
@@ -494,26 +537,46 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className={`flex items-center px-2 ${borderClasses}${editTaskName ? ' task-name-edit-active' : ''}`}
|
className={`task-cell-container flex items-center px-2 ${borderClasses}${editTaskName ? ' task-name-edit-active' : ''}`}
|
||||||
style={cellStyle}
|
style={cellStyle}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0 flex flex-col justify-center h-full overflow-hidden">
|
<div className="flex-1 min-w-0 flex items-center justify-between h-full overflow-hidden">
|
||||||
<div className="flex items-center gap-2 h-5 overflow-hidden">
|
{/* Left section with expand icon and task content */}
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{/* Expand/Collapse Icon - Smart visibility */}
|
||||||
|
<div className="expand-icon-container hover-only w-5 h-5 flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleExpandClick();
|
||||||
|
}}
|
||||||
|
className={`expand-toggle-btn w-4 h-4 flex items-center justify-center border-none rounded text-xs cursor-pointer transition-all duration-200 ${
|
||||||
|
isDarkMode
|
||||||
|
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: 'transparent' }}
|
||||||
|
title="Add subtask"
|
||||||
|
>
|
||||||
|
{showAddSubtask ? <DownOutlined /> : <RightOutlined />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task name and input */}
|
||||||
<div ref={wrapperRef} className="flex-1 min-w-0">
|
<div ref={wrapperRef} className="flex-1 min-w-0">
|
||||||
{editTaskName ? (
|
{editTaskName ? (
|
||||||
<input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="task-name-input w-full bg-transparent border-none outline-none text-sm"
|
className="task-name-input"
|
||||||
value={taskName}
|
value={taskName}
|
||||||
onChange={(e) => setTaskName(e.target.value)}
|
onChange={(e) => setTaskName(e.target.value)}
|
||||||
onBlur={handleTaskNameSave}
|
onBlur={handleTaskNameSave}
|
||||||
onKeyDown={(e) => {
|
onPressEnter={handleTaskNameSave}
|
||||||
if (e.key === 'Enter') {
|
variant="borderless"
|
||||||
handleTaskNameSave();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
color: isDarkMode ? '#ffffff' : '#262626'
|
color: isDarkMode ? '#ffffff' : '#262626',
|
||||||
|
padding: 0
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -528,7 +591,90 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Indicators section */}
|
||||||
|
{!editTaskName && (
|
||||||
|
<div className="task-indicators flex items-center gap-1">
|
||||||
|
{/* Subtasks count */}
|
||||||
|
{task.subtasks_count && task.subtasks_count > 0 && (
|
||||||
|
<Tooltip title={`${task.subtasks_count} ${task.subtasks_count !== 1 ? t('subtasks') : t('subtask')}`}>
|
||||||
|
<div
|
||||||
|
className={`indicator-badge subtasks flex items-center gap-1 px-1 py-0.5 rounded text-xs font-semibold cursor-pointer transition-colors duration-200 ${
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-gray-800 border-gray-600 text-gray-400 hover:bg-gray-700'
|
||||||
|
: 'bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
style={{ fontSize: '10px', border: '1px solid' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleSubtasks?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{task.subtasks_count}</span>
|
||||||
|
<RightOutlined style={{ fontSize: '8px' }} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments indicator */}
|
||||||
|
{task.comments_count && task.comments_count > 0 && (
|
||||||
|
<Tooltip title={`${task.comments_count} ${task.comments_count !== 1 ? t('comments') : t('comment')}`}>
|
||||||
|
<div
|
||||||
|
className={`indicator-badge comments flex items-center gap-1 px-1 py-0.5 rounded text-xs font-semibold ${
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-green-900 border-green-700 text-green-300'
|
||||||
|
: 'bg-green-50 border-green-200 text-green-700'
|
||||||
|
}`}
|
||||||
|
style={{ fontSize: '10px', border: '1px solid' }}
|
||||||
|
>
|
||||||
|
<MessageOutlined style={{ fontSize: '8px' }} />
|
||||||
|
<span>{task.comments_count}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attachments indicator */}
|
||||||
|
{task.attachments_count && task.attachments_count > 0 && (
|
||||||
|
<Tooltip title={`${task.attachments_count} ${task.attachments_count !== 1 ? t('attachments') : t('attachment')}`}>
|
||||||
|
<div
|
||||||
|
className={`indicator-badge attachments flex items-center gap-1 px-1 py-0.5 rounded text-xs font-semibold ${
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-blue-900 border-blue-700 text-blue-300'
|
||||||
|
: 'bg-blue-50 border-blue-200 text-blue-700'
|
||||||
|
}`}
|
||||||
|
style={{ fontSize: '10px', border: '1px solid' }}
|
||||||
|
>
|
||||||
|
<PaperClipOutlined style={{ fontSize: '8px' }} />
|
||||||
|
<span>{task.attachments_count}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right section with open button - CSS hover only */}
|
||||||
|
{!editTaskName && (
|
||||||
|
<div className="task-open-button ml-2 opacity-0 transition-opacity duration-200" style={{ zIndex: 10 }}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
// Handle opening task drawer
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded border transition-all duration-200 text-xs font-medium ${
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-gray-700 border-gray-600 text-gray-300 hover:bg-gray-600 hover:border-gray-500 hover:text-gray-100'
|
||||||
|
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100 hover:border-gray-300 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
style={{ fontSize: '11px', minWidth: 'fit-content' }}
|
||||||
|
>
|
||||||
|
<ExpandAltOutlined style={{ fontSize: '10px' }} />
|
||||||
|
<span>{t('openTask')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -767,6 +913,106 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add Subtask Row */}
|
||||||
|
{showAddSubtask && (
|
||||||
|
<div className={`add-subtask-row ${showAddSubtask ? 'visible' : ''} ${isDarkMode ? 'dark' : ''}`}>
|
||||||
|
<div className="flex h-10 max-h-10 overflow-visible relative">
|
||||||
|
{/* Fixed Columns for Add Subtask */}
|
||||||
|
{fixedColumns && fixedColumns.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex overflow-visible"
|
||||||
|
style={{
|
||||||
|
width: fixedColumns.reduce((sum, col) => sum + col.width, 0),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fixedColumns.map((col, index) => {
|
||||||
|
const isLast = index === fixedColumns.length - 1;
|
||||||
|
const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||||
|
|
||||||
|
if (col.key === 'task') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className={`flex items-center px-2 ${borderClasses}`}
|
||||||
|
style={{ width: col.width }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0 pl-6">
|
||||||
|
<Input
|
||||||
|
ref={addSubtaskInputRef}
|
||||||
|
placeholder={t('enterSubtaskName')}
|
||||||
|
value={newSubtaskName}
|
||||||
|
onChange={(e) => setNewSubtaskName(e.target.value)}
|
||||||
|
onPressEnter={handleAddSubtask}
|
||||||
|
onBlur={handleCancelAddSubtask}
|
||||||
|
className={`add-subtask-input flex-1 ${
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-gray-700 border-gray-600 text-gray-200'
|
||||||
|
: 'bg-white border-gray-300 text-gray-900'
|
||||||
|
}`}
|
||||||
|
size="small"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
onClick={handleAddSubtask}
|
||||||
|
disabled={!newSubtaskName.trim()}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
{t('add')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={handleCancelAddSubtask}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className={`flex items-center px-2 ${borderClasses}`}
|
||||||
|
style={{ width: col.width }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scrollable Columns for Add Subtask */}
|
||||||
|
{scrollableColumns && scrollableColumns.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="overflow-visible"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
minWidth: scrollableColumns.reduce((sum, col) => sum + col.width, 0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scrollableColumns.map((col, index) => {
|
||||||
|
const isLast = index === scrollableColumns.length - 1;
|
||||||
|
const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className={`flex items-center px-2 ${borderClasses}`}
|
||||||
|
style={{ width: col.width }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, (prevProps, nextProps) => {
|
}, (prevProps, nextProps) => {
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ const taskDrawerSlice = createSlice({
|
|||||||
state.taskFormViewModel.task.schedule_id = schedule_id;
|
state.taskFormViewModel.task.schedule_id = schedule_id;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
resetTaskDrawer: (state) => {
|
||||||
|
return initialState;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder.addCase(fetchTask.pending, state => {
|
builder.addCase(fetchTask.pending, state => {
|
||||||
@@ -142,6 +145,7 @@ export const {
|
|||||||
setTaskLabels,
|
setTaskLabels,
|
||||||
setTaskSubscribers,
|
setTaskSubscribers,
|
||||||
setTimeLogEditing,
|
setTimeLogEditing,
|
||||||
setTaskRecurringSchedule
|
setTaskRecurringSchedule,
|
||||||
|
resetTaskDrawer
|
||||||
} = taskDrawerSlice.actions;
|
} = taskDrawerSlice.actions;
|
||||||
export default taskDrawerSlice.reducer;
|
export default taskDrawerSlice.reducer;
|
||||||
|
|||||||
163
worklenz-frontend/src/hooks/usePerformanceOptimization.ts
Normal file
163
worklenz-frontend/src/hooks/usePerformanceOptimization.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useEffect, useState } from 'react';
|
||||||
|
import { useAppSelector } from './useAppSelector';
|
||||||
|
import { debounce, throttle } from 'lodash';
|
||||||
|
|
||||||
|
// Performance optimization utilities
|
||||||
|
export const usePerformanceOptimization = () => {
|
||||||
|
const renderCountRef = useRef(0);
|
||||||
|
const lastRenderTimeRef = useRef(0);
|
||||||
|
|
||||||
|
// Track render performance
|
||||||
|
const trackRender = useCallback((componentName: string) => {
|
||||||
|
renderCountRef.current += 1;
|
||||||
|
const now = performance.now();
|
||||||
|
const timeSinceLastRender = now - lastRenderTimeRef.current;
|
||||||
|
lastRenderTimeRef.current = now;
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log(`[${componentName}] Render #${renderCountRef.current}, Time since last: ${timeSinceLastRender.toFixed(2)}ms`);
|
||||||
|
|
||||||
|
if (timeSinceLastRender < 16) { // Less than 60fps
|
||||||
|
console.warn(`[${componentName}] Potential over-rendering detected`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Debounced callback creator
|
||||||
|
const createDebouncedCallback = useCallback(<T extends (...args: any[]) => any>(
|
||||||
|
callback: T,
|
||||||
|
delay: number = 300
|
||||||
|
) => {
|
||||||
|
return debounce(callback, delay);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Throttled callback creator
|
||||||
|
const createThrottledCallback = useCallback(<T extends (...args: any[]) => any>(
|
||||||
|
callback: T,
|
||||||
|
delay: number = 100
|
||||||
|
) => {
|
||||||
|
return throttle(callback, delay);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackRender,
|
||||||
|
createDebouncedCallback,
|
||||||
|
createThrottledCallback,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimized selector hook to prevent unnecessary re-renders
|
||||||
|
export const useOptimizedSelector = <T>(
|
||||||
|
selector: (state: any) => T,
|
||||||
|
equalityFn?: (left: T, right: T) => boolean
|
||||||
|
) => {
|
||||||
|
const defaultEqualityFn = useCallback((left: T, right: T) => {
|
||||||
|
// Deep equality check for objects and arrays
|
||||||
|
if (typeof left === 'object' && typeof right === 'object') {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
}
|
||||||
|
return left === right;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return useAppSelector(selector, equalityFn || defaultEqualityFn);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoized component props
|
||||||
|
export const useMemoizedProps = <T extends Record<string, any>>(props: T): T => {
|
||||||
|
return useMemo(() => props, Object.values(props));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimized event handlers
|
||||||
|
export const useOptimizedEventHandlers = <T extends Record<string, (...args: any[]) => any>>(
|
||||||
|
handlers: T
|
||||||
|
) => {
|
||||||
|
return useMemo(() => {
|
||||||
|
const optimizedHandlers = {} as any;
|
||||||
|
|
||||||
|
Object.entries(handlers).forEach(([key, handler]) => {
|
||||||
|
optimizedHandlers[key] = useCallback(handler, [handler]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return optimizedHandlers as T;
|
||||||
|
}, [handlers]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Virtual scrolling utilities
|
||||||
|
export const useVirtualScrolling = (
|
||||||
|
itemCount: number,
|
||||||
|
itemHeight: number,
|
||||||
|
containerHeight: number
|
||||||
|
) => {
|
||||||
|
const visibleRange = useMemo(() => {
|
||||||
|
const startIndex = Math.floor(window.scrollY / itemHeight);
|
||||||
|
const endIndex = Math.min(
|
||||||
|
startIndex + Math.ceil(containerHeight / itemHeight) + 1,
|
||||||
|
itemCount
|
||||||
|
);
|
||||||
|
|
||||||
|
return { startIndex: Math.max(0, startIndex), endIndex };
|
||||||
|
}, [itemCount, itemHeight, containerHeight]);
|
||||||
|
|
||||||
|
return visibleRange;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Image lazy loading hook
|
||||||
|
export const useLazyLoading = (threshold: number = 0.1) => {
|
||||||
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
const targetRef = useCallback((node: HTMLElement | null) => {
|
||||||
|
if (observerRef.current) observerRef.current.disconnect();
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
observerRef.current = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
observerRef.current?.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold }
|
||||||
|
);
|
||||||
|
observerRef.current.observe(node);
|
||||||
|
}
|
||||||
|
}, [threshold]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
observerRef.current?.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { targetRef, isVisible };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memory optimization for large datasets
|
||||||
|
export const useMemoryOptimization = <T>(
|
||||||
|
data: T[],
|
||||||
|
maxCacheSize: number = 1000
|
||||||
|
) => {
|
||||||
|
const cacheRef = useRef(new Map<string, T>());
|
||||||
|
|
||||||
|
const optimizedData = useMemo(() => {
|
||||||
|
if (data.length <= maxCacheSize) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only the most recently accessed items
|
||||||
|
const cache = cacheRef.current;
|
||||||
|
const recentData = data.slice(0, maxCacheSize);
|
||||||
|
|
||||||
|
// Clear old cache entries
|
||||||
|
cache.clear();
|
||||||
|
recentData.forEach((item, index) => {
|
||||||
|
cache.set(String(index), item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return recentData;
|
||||||
|
}, [data, maxCacheSize]);
|
||||||
|
|
||||||
|
return optimizedData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usePerformanceOptimization;
|
||||||
@@ -437,6 +437,21 @@ const ProjectList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
|
// Preload project view components on hover for smoother navigation
|
||||||
|
const handleProjectHover = useCallback((project_id: string | undefined) => {
|
||||||
|
if (project_id) {
|
||||||
|
// Preload the project view route to reduce loading time
|
||||||
|
import('@/pages/projects/projectView/project-view').catch(() => {
|
||||||
|
// Silently fail if preload doesn't work
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also preload critical task management components
|
||||||
|
import('@/components/task-management/task-list-board').catch(() => {
|
||||||
|
// Silently fail if preload doesn't work
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Define table columns directly in the component to avoid hooks order issues
|
// Define table columns directly in the component to avoid hooks order issues
|
||||||
const tableColumns: ColumnsType<IProjectViewModel> = useMemo(
|
const tableColumns: ColumnsType<IProjectViewModel> = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -629,6 +644,7 @@ const ProjectList: React.FC = () => {
|
|||||||
locale={{ emptyText }}
|
locale={{ emptyText }}
|
||||||
onRow={record => ({
|
onRow={record => ({
|
||||||
onClick: () => navigateToProject(record.id, record.team_member_default_view),
|
onClick: () => navigateToProject(record.id, record.team_member_default_view),
|
||||||
|
onMouseEnter: () => handleProjectHover(record.id),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -4,3 +4,222 @@
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Enhanced Project View Tab Styles - Compact */
|
||||||
|
.project-view-tabs {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove default tab border */
|
||||||
|
.project-view-tabs .ant-tabs-nav::before {
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab bar container */
|
||||||
|
.project-view-tabs .ant-tabs-nav {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual tab styling - Compact */
|
||||||
|
.project-view-tabs .ant-tabs-tab {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 4px 0 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
min-height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode tab styles */
|
||||||
|
[data-theme="default"] .project-view-tabs .ant-tabs-tab {
|
||||||
|
color: #64748b;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="default"] .project-view-tabs .ant-tabs-tab:hover {
|
||||||
|
color: #3b82f6;
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="default"] .project-view-tabs .ant-tabs-tab-active {
|
||||||
|
color: #1e40af !important;
|
||||||
|
background: #ffffff !important;
|
||||||
|
border-color: #3b82f6 !important;
|
||||||
|
border-bottom-color: #ffffff !important;
|
||||||
|
box-shadow: 0 -2px 8px rgba(59, 130, 246, 0.1), 0 4px 16px rgba(59, 130, 246, 0.1);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode tab styles */
|
||||||
|
[data-theme="dark"] .project-view-tabs .ant-tabs-tab {
|
||||||
|
color: #94a3b8;
|
||||||
|
background: #1e293b;
|
||||||
|
border-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-view-tabs .ant-tabs-tab:hover {
|
||||||
|
color: #60a5fa;
|
||||||
|
background: #1e3a8a;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(96, 165, 250, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-view-tabs .ant-tabs-tab-active {
|
||||||
|
color: #60a5fa !important;
|
||||||
|
background: #0f172a !important;
|
||||||
|
border-color: #3b82f6 !important;
|
||||||
|
border-bottom-color: #0f172a !important;
|
||||||
|
box-shadow: 0 -2px 8px rgba(96, 165, 250, 0.15), 0 4px 16px rgba(96, 165, 250, 0.15);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab content area - Compact */
|
||||||
|
.project-view-tabs .ant-tabs-content-holder {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="default"] .project-view-tabs .ant-tabs-content-holder {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-view-tabs .ant-tabs-content-holder {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-view-tabs .ant-tabs-tabpane {
|
||||||
|
padding: 0;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pin button styling - Compact */
|
||||||
|
.project-view-tabs .borderless-icon-btn {
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-view-tabs .borderless-icon-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="default"] .project-view-tabs .borderless-icon-btn:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-view-tabs .borderless-icon-btn:hover {
|
||||||
|
background: rgba(96, 165, 250, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pinned tab indicator */
|
||||||
|
.project-view-tabs .ant-tabs-tab-active .borderless-icon-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="default"] .project-view-tabs .ant-tabs-tab-active .borderless-icon-btn {
|
||||||
|
background: rgba(59, 130, 246, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-view-tabs .ant-tabs-tab-active .borderless-icon-btn {
|
||||||
|
background: rgba(96, 165, 250, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab label flex container */
|
||||||
|
.project-view-tabs .ant-tabs-tab .ant-tabs-tab-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments - Compact */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.project-view-tabs .ant-tabs-nav {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-view-tabs .ant-tabs-tab {
|
||||||
|
margin: 0 2px 0 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-view-tabs .borderless-icon-btn {
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.project-view-tabs .ant-tabs-tab {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-view-tabs .borderless-icon-btn {
|
||||||
|
display: none; /* Hide pin buttons on very small screens */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation for tab switching */
|
||||||
|
.project-view-tabs .ant-tabs-content {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-view-tabs .ant-tabs-tabpane-active {
|
||||||
|
animation: fadeInUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus states for accessibility - Compact */
|
||||||
|
.project-view-tabs .ant-tabs-tab:focus-visible {
|
||||||
|
outline: 1px solid #3b82f6;
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .project-view-tabs .ant-tabs-tab:focus-visible {
|
||||||
|
outline-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state for tab content */
|
||||||
|
.project-view-tabs .ant-tabs-tabpane .suspense-fallback {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
import React, { useEffect, useState, useMemo, useCallback, Suspense } from 'react';
|
||||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
@@ -33,32 +33,57 @@ import { resetFields } from '@/features/task-management/taskListFields.slice';
|
|||||||
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||||
import { tabItems } from '@/lib/project/project-view-constants';
|
import { tabItems } from '@/lib/project/project-view-constants';
|
||||||
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
import { setSelectedTaskId, setShowTaskDrawer, resetTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||||
|
import { resetState as resetEnhancedKanbanState } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||||
|
import { setProjectId as setInsightsProjectId } from '@/features/projects/insights/project-insights.slice';
|
||||||
|
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
|
||||||
|
|
||||||
|
// Import critical components synchronously to avoid suspense interruptions
|
||||||
|
import TaskDrawer from '@components/task-drawer/task-drawer';
|
||||||
|
|
||||||
|
// Lazy load non-critical components with better error handling
|
||||||
const DeleteStatusDrawer = React.lazy(() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer'));
|
const DeleteStatusDrawer = React.lazy(() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer'));
|
||||||
const PhaseDrawer = React.lazy(() => import('@features/projects/singleProject/phase/PhaseDrawer'));
|
const PhaseDrawer = React.lazy(() => import('@/features/projects/singleProject/phase/PhaseDrawer'));
|
||||||
const StatusDrawer = React.lazy(
|
const StatusDrawer = React.lazy(() => import('@/components/project-task-filters/create-status-drawer/create-status-drawer'));
|
||||||
() => import('@/components/project-task-filters/create-status-drawer/create-status-drawer')
|
const ProjectMemberDrawer = React.lazy(() => import('@/components/projects/project-member-invite-drawer/project-member-invite-drawer'));
|
||||||
);
|
|
||||||
const ProjectMemberDrawer = React.lazy(
|
|
||||||
() => import('@/components/projects/project-member-invite-drawer/project-member-invite-drawer')
|
|
||||||
);
|
|
||||||
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
|
|
||||||
|
|
||||||
const ProjectView = () => {
|
|
||||||
|
|
||||||
|
const ProjectView = React.memo(() => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { projectId } = useParams();
|
const { projectId } = useParams();
|
||||||
|
|
||||||
|
// Memoized selectors to prevent unnecessary re-renders
|
||||||
const selectedProject = useAppSelector(state => state.projectReducer.project);
|
const selectedProject = useAppSelector(state => state.projectReducer.project);
|
||||||
|
const projectLoading = useAppSelector(state => state.projectReducer.projectLoading);
|
||||||
|
|
||||||
|
// Optimize document title updates
|
||||||
useDocumentTitle(selectedProject?.name || 'Project View');
|
useDocumentTitle(selectedProject?.name || 'Project View');
|
||||||
const [activeTab, setActiveTab] = useState<string>(searchParams.get('tab') || tabItems[0].key);
|
|
||||||
const [pinnedTab, setPinnedTab] = useState<string>(searchParams.get('pinned_tab') || '');
|
// Memoize URL params to prevent unnecessary state updates
|
||||||
const [taskid, setTaskId] = useState<string>(searchParams.get('task') || '');
|
const urlParams = useMemo(() => ({
|
||||||
|
tab: searchParams.get('tab') || tabItems[0].key,
|
||||||
|
pinnedTab: searchParams.get('pinned_tab') || '',
|
||||||
|
taskId: searchParams.get('task') || ''
|
||||||
|
}), [searchParams]);
|
||||||
|
|
||||||
const resetProjectData = useCallback(() => {
|
const [activeTab, setActiveTab] = useState<string>(urlParams.tab);
|
||||||
|
const [pinnedTab, setPinnedTab] = useState<string>(urlParams.pinnedTab);
|
||||||
|
const [taskid, setTaskId] = useState<string>(urlParams.taskId);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
|
// Update local state when URL params change
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveTab(urlParams.tab);
|
||||||
|
setPinnedTab(urlParams.pinnedTab);
|
||||||
|
setTaskId(urlParams.taskId);
|
||||||
|
}, [urlParams]);
|
||||||
|
|
||||||
|
// Comprehensive cleanup function for when leaving project view entirely
|
||||||
|
const resetAllProjectData = useCallback(() => {
|
||||||
dispatch(setProjectId(null));
|
dispatch(setProjectId(null));
|
||||||
dispatch(resetStatuses());
|
dispatch(resetStatuses());
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAll());
|
||||||
@@ -68,140 +93,259 @@ const ProjectView = () => {
|
|||||||
dispatch(resetGrouping());
|
dispatch(resetGrouping());
|
||||||
dispatch(resetSelection());
|
dispatch(resetSelection());
|
||||||
dispatch(resetFields());
|
dispatch(resetFields());
|
||||||
|
dispatch(resetEnhancedKanbanState());
|
||||||
|
|
||||||
|
// Reset project insights
|
||||||
|
dispatch(setInsightsProjectId(''));
|
||||||
|
|
||||||
|
// Reset task drawer completely
|
||||||
|
dispatch(resetTaskDrawer());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// Effect for handling component unmount (leaving project view entirely)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
// This cleanup only runs when the component unmounts
|
||||||
dispatch(setProjectId(projectId));
|
return () => {
|
||||||
dispatch(getProject(projectId)).then((res: any) => {
|
resetAllProjectData();
|
||||||
if (!res.payload) {
|
};
|
||||||
navigate('/worklenz/projects');
|
}, []); // Empty dependency array - only runs on mount/unmount
|
||||||
return;
|
|
||||||
}
|
// Effect for handling route changes (when navigating away from project view)
|
||||||
dispatch(fetchStatuses(projectId));
|
useEffect(() => {
|
||||||
dispatch(fetchLabels());
|
const currentPath = location.pathname;
|
||||||
});
|
|
||||||
|
// If we're not on a project view path, clean up
|
||||||
|
if (!currentPath.includes('/worklenz/projects/') || currentPath === '/worklenz/projects') {
|
||||||
|
resetAllProjectData();
|
||||||
}
|
}
|
||||||
if (taskid) {
|
}, [location.pathname, resetAllProjectData]);
|
||||||
dispatch(setSelectedTaskId(taskid || ''));
|
|
||||||
|
// Optimized project data loading with better error handling and performance tracking
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectId && !isInitialized) {
|
||||||
|
const loadProjectData = async () => {
|
||||||
|
try {
|
||||||
|
// Clean up previous project data before loading new project
|
||||||
|
dispatch(resetTaskListData());
|
||||||
|
dispatch(resetBoardData());
|
||||||
|
dispatch(resetTaskManagement());
|
||||||
|
dispatch(resetEnhancedKanbanState());
|
||||||
|
dispatch(deselectAll());
|
||||||
|
|
||||||
|
// Load new project data
|
||||||
|
dispatch(setProjectId(projectId));
|
||||||
|
|
||||||
|
// Load project and essential data in parallel
|
||||||
|
const [projectResult] = await Promise.allSettled([
|
||||||
|
dispatch(getProject(projectId)),
|
||||||
|
dispatch(fetchStatuses(projectId)),
|
||||||
|
dispatch(fetchLabels())
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (projectResult.status === 'fulfilled' && !projectResult.value.payload) {
|
||||||
|
navigate('/worklenz/projects');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsInitialized(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading project data:', error);
|
||||||
|
navigate('/worklenz/projects');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadProjectData();
|
||||||
|
}
|
||||||
|
}, [dispatch, navigate, projectId]);
|
||||||
|
|
||||||
|
// Reset initialization when project changes
|
||||||
|
useEffect(() => {
|
||||||
|
setIsInitialized(false);
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
// Effect for handling task drawer opening from URL params
|
||||||
|
useEffect(() => {
|
||||||
|
if (taskid && isInitialized) {
|
||||||
|
dispatch(setSelectedTaskId(taskid));
|
||||||
dispatch(setShowTaskDrawer(true));
|
dispatch(setShowTaskDrawer(true));
|
||||||
}
|
}
|
||||||
|
}, [dispatch, taskid, isInitialized]);
|
||||||
|
|
||||||
return () => {
|
// Optimized pin tab function with better error handling
|
||||||
resetProjectData();
|
|
||||||
};
|
|
||||||
}, [dispatch, navigate, projectId, taskid, resetProjectData]);
|
|
||||||
|
|
||||||
const pinToDefaultTab = useCallback(async (itemKey: string) => {
|
const pinToDefaultTab = useCallback(async (itemKey: string) => {
|
||||||
if (!itemKey || !projectId) return;
|
if (!itemKey || !projectId) return;
|
||||||
|
|
||||||
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
|
try {
|
||||||
const res = await projectsApiService.updateDefaultTab({
|
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
|
||||||
project_id: projectId,
|
const res = await projectsApiService.updateDefaultTab({
|
||||||
default_view: defaultView,
|
project_id: projectId,
|
||||||
});
|
default_view: defaultView,
|
||||||
|
|
||||||
if (res.done) {
|
|
||||||
setPinnedTab(itemKey);
|
|
||||||
tabItems.forEach(item => {
|
|
||||||
if (item.key === itemKey) {
|
|
||||||
item.isPinned = true;
|
|
||||||
} else {
|
|
||||||
item.isPinned = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
navigate({
|
if (res.done) {
|
||||||
pathname: `/worklenz/projects/${projectId}`,
|
setPinnedTab(itemKey);
|
||||||
search: new URLSearchParams({
|
|
||||||
tab: activeTab,
|
// Optimize tab items update
|
||||||
pinned_tab: itemKey
|
tabItems.forEach(item => {
|
||||||
}).toString(),
|
item.isPinned = item.key === itemKey;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
navigate({
|
||||||
|
pathname: `/worklenz/projects/${projectId}`,
|
||||||
|
search: new URLSearchParams({
|
||||||
|
tab: activeTab,
|
||||||
|
pinned_tab: itemKey
|
||||||
|
}).toString(),
|
||||||
|
}, { replace: true }); // Use replace to avoid history pollution
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating default tab:', error);
|
||||||
}
|
}
|
||||||
}, [projectId, activeTab, navigate]);
|
}, [projectId, activeTab, navigate]);
|
||||||
|
|
||||||
|
// Optimized tab change handler
|
||||||
const handleTabChange = useCallback((key: string) => {
|
const handleTabChange = useCallback((key: string) => {
|
||||||
setActiveTab(key);
|
setActiveTab(key);
|
||||||
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
|
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
|
||||||
|
|
||||||
|
// Use replace for better performance and history management
|
||||||
navigate({
|
navigate({
|
||||||
pathname: location.pathname,
|
pathname: location.pathname,
|
||||||
search: new URLSearchParams({
|
search: new URLSearchParams({
|
||||||
tab: key,
|
tab: key,
|
||||||
pinned_tab: pinnedTab,
|
pinned_tab: pinnedTab,
|
||||||
}).toString(),
|
}).toString(),
|
||||||
});
|
}, { replace: true });
|
||||||
}, [dispatch, location.pathname, navigate, pinnedTab]);
|
}, [dispatch, location.pathname, navigate, pinnedTab]);
|
||||||
|
|
||||||
const tabMenuItems = useMemo(() => tabItems.map(item => ({
|
// Memoized tab menu items with enhanced styling
|
||||||
key: item.key,
|
const tabMenuItems = useMemo(() => {
|
||||||
label: (
|
const menuItems = tabItems.map(item => ({
|
||||||
<Flex align="center" style={{ color: colors.skyBlue }}>
|
key: item.key,
|
||||||
{item.label}
|
label: (
|
||||||
{item.key === 'tasks-list' || item.key === 'board' ? (
|
<Flex align="center" gap={6} style={{ color: 'inherit' }}>
|
||||||
<ConfigProvider wave={{ disabled: true }}>
|
<span style={{ fontWeight: 500, fontSize: '13px' }}>{item.label}</span>
|
||||||
<Button
|
{(item.key === 'tasks-list' || item.key === 'board') && (
|
||||||
className="borderless-icon-btn"
|
<ConfigProvider wave={{ disabled: true }}>
|
||||||
style={{
|
<Button
|
||||||
backgroundColor: colors.transparent,
|
className="borderless-icon-btn"
|
||||||
boxShadow: 'none',
|
size="small"
|
||||||
}}
|
type="text"
|
||||||
icon={
|
style={{
|
||||||
item.key === pinnedTab ? (
|
backgroundColor: 'transparent',
|
||||||
<PushpinFilled
|
border: 'none',
|
||||||
size={20}
|
boxShadow: 'none',
|
||||||
style={{
|
padding: '2px',
|
||||||
color: colors.skyBlue,
|
minWidth: 'auto',
|
||||||
rotate: '-45deg',
|
height: 'auto',
|
||||||
transition: 'transform ease-in 300ms',
|
lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
/>
|
icon={
|
||||||
) : (
|
item.key === pinnedTab ? (
|
||||||
<PushpinOutlined
|
<PushpinFilled
|
||||||
size={20}
|
style={{
|
||||||
style={{
|
fontSize: '12px',
|
||||||
color: colors.skyBlue,
|
color: 'currentColor',
|
||||||
}}
|
transform: 'rotate(-45deg)',
|
||||||
/>
|
transition: 'all 0.3s ease',
|
||||||
)
|
}}
|
||||||
}
|
/>
|
||||||
onClick={e => {
|
) : (
|
||||||
e.stopPropagation();
|
<PushpinOutlined
|
||||||
pinToDefaultTab(item.key);
|
style={{
|
||||||
}}
|
fontSize: '12px',
|
||||||
/>
|
color: 'currentColor',
|
||||||
</ConfigProvider>
|
transition: 'all 0.3s ease',
|
||||||
) : null}
|
}}
|
||||||
</Flex>
|
/>
|
||||||
),
|
)
|
||||||
children: item.element,
|
}
|
||||||
})), [pinnedTab, pinToDefaultTab]);
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
pinToDefaultTab(item.key);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ConfigProvider>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
children: item.element,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return menuItems;
|
||||||
|
}, [pinnedTab, pinToDefaultTab]);
|
||||||
|
|
||||||
|
// Optimized secondary components loading with better UX
|
||||||
|
const [shouldLoadSecondaryComponents, setShouldLoadSecondaryComponents] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialized) {
|
||||||
|
// Reduce delay and load secondary components after core data is ready
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShouldLoadSecondaryComponents(true);
|
||||||
|
}, 500); // Reduced from 1000ms to 500ms
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isInitialized]);
|
||||||
|
|
||||||
|
// Optimized portal elements with better error boundaries
|
||||||
const portalElements = useMemo(() => (
|
const portalElements = useMemo(() => (
|
||||||
<>
|
<>
|
||||||
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
|
{/* Critical component - load immediately without suspense */}
|
||||||
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
|
|
||||||
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
|
|
||||||
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
|
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
|
||||||
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
|
|
||||||
|
{/* Non-critical components - load after delay with suspense fallback */}
|
||||||
|
{shouldLoadSecondaryComponents && (
|
||||||
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
|
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
|
||||||
|
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
|
||||||
|
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
|
||||||
|
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
), []);
|
), [shouldLoadSecondaryComponents]);
|
||||||
|
|
||||||
|
// Show loading state while project is being fetched
|
||||||
|
if (projectLoading || !isInitialized) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBlockStart: 80, marginBlockEnd: 16, minHeight: '80vh' }}>
|
||||||
|
<SuspenseFallback />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBlockStart: 80, marginBlockEnd: 24, minHeight: '80vh' }}>
|
<div style={{ marginBlockStart: 80, marginBlockEnd: 16, minHeight: '80vh' }}>
|
||||||
<ProjectViewHeader />
|
<ProjectViewHeader />
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
|
className="project-view-tabs"
|
||||||
activeKey={activeTab}
|
activeKey={activeTab}
|
||||||
onChange={handleTabChange}
|
onChange={handleTabChange}
|
||||||
items={tabMenuItems}
|
items={tabMenuItems}
|
||||||
tabBarStyle={{ paddingInline: 0 }}
|
tabBarStyle={{
|
||||||
destroyInactiveTabPane
|
paddingInline: 0,
|
||||||
|
marginBottom: 8,
|
||||||
|
background: 'transparent',
|
||||||
|
minHeight: '36px',
|
||||||
|
}}
|
||||||
|
tabBarGutter={0}
|
||||||
|
destroyInactiveTabPane={true} // Destroy inactive tabs to save memory
|
||||||
|
animated={{
|
||||||
|
inkBar: true,
|
||||||
|
tabPane: false, // Disable content animation for better performance
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
type="card"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{portalElements}
|
{portalElements}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default React.memo(ProjectView);
|
ProjectView.displayName = 'ProjectView';
|
||||||
|
|
||||||
|
export default ProjectView;
|
||||||
|
|||||||
@@ -82,7 +82,12 @@ export {
|
|||||||
PushpinFilled,
|
PushpinFilled,
|
||||||
PushpinOutlined,
|
PushpinOutlined,
|
||||||
UsergroupAddOutlined,
|
UsergroupAddOutlined,
|
||||||
ImportOutlined
|
ImportOutlined,
|
||||||
|
UnorderedListOutlined,
|
||||||
|
TableOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
MessageOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
// Re-export all components with React
|
// Re-export all components with React
|
||||||
|
|||||||
Reference in New Issue
Block a user