-
-
-
+
+
+
+
-
-
-
-
-
- 
-
- |
-
-
-
+
-
-
-
-
-
-
- Reset your password on Worklenz
-
- |
-
-
-
- 
-
- |
-
-
-
-
-
-
-
- You have requested to reset your password
-
- To reset your password, click the following link and follow the instructions.
-
- |
-
-
-
+ |
+
+
+
+
+
+ 
+
+ |
+
+
+
+
+
+
+
+
+
+
+ Reset your password
+
+ |
+
+
+
+ 
+
+ |
+
+
+
+
+
+
+
+ We received a request to reset your Worklenz account password.
+ Click the button below to set a new password. If you did not request this, you can safely ignore this email.
+
+ |
+
+
+
+ |
+
+
+
+ |
+
+
-
-
-
-
-
+ |
+
+
+
diff --git a/worklenz-backend/worklenz-email-templates/team-invitation.html b/worklenz-backend/worklenz-email-templates/team-invitation.html
index 921e845d..f0d17e33 100644
--- a/worklenz-backend/worklenz-email-templates/team-invitation.html
+++ b/worklenz-backend/worklenz-email-templates/team-invitation.html
@@ -2,31 +2,30 @@
-
+ Join Your Team on Worklenz
+
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
- 
-
- |
-
-
-
+
-
-
-
-
-
-
- Join your team on Worklenz
-
- |
-
-
-
- 
-
- |
-
-
-
-
-
-
-
- You have been added to the "[VAR_TEAM_NAME]" team
- on Worklenz!
-
- Sign in to your Worklenz account to continue.
-
- |
-
-
-
+ |
+
+
+
+
+
+ 
+
+ |
+
+
+
+
+
+
+
+
+
+
+ Join your team on Worklenz
+
+ |
+
+
+
+ 
+
+ |
+
+
+
+
+
+
+
+ You have been added to the "[VAR_TEAM_NAME]" team on Worklenz!
+ Sign in to your Worklenz account to continue.
+
+ |
+
+
+
+ |
+
+
+
+ |
+
+
-
-
-
-
-
+ |
+
+
+
diff --git a/worklenz-backend/worklenz-email-templates/unregistered-team-invitation-notification.html b/worklenz-backend/worklenz-email-templates/unregistered-team-invitation-notification.html
index a231f9ad..2db5cfc2 100644
--- a/worklenz-backend/worklenz-email-templates/unregistered-team-invitation-notification.html
+++ b/worklenz-backend/worklenz-email-templates/unregistered-team-invitation-notification.html
@@ -2,31 +2,30 @@
-
+ Join Your Team on Worklenz
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 
-
- |
-
-
-
-
-
-
-
-
-
-
- Join your team on Worklenz
-
- |
-
-
-
- 
-
- |
-
-
-
-
-
-
-
- You have been added to the "[VAR_TEAM_NAME]" team
- on Worklenz!
- Create an account in Worklenz to continue.
-
- |
-
-
-
+ |
+
+
+
+
+
+
+
+
+
+ Join your team on Worklenz
+
+ |
+
+
+
+ 
+
+ |
+
+
+
+
+
+
+
+ You have been added to the "[VAR_TEAM_NAME]" team
+ on Worklenz!
+ Create an account in Worklenz to continue.
+
+ |
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+
diff --git a/worklenz-backend/worklenz-email-templates/welcome.html b/worklenz-backend/worklenz-email-templates/welcome.html
index bc258a6d..7bb62821 100644
--- a/worklenz-backend/worklenz-email-templates/welcome.html
+++ b/worklenz-backend/worklenz-email-templates/welcome.html
@@ -2,31 +2,30 @@
-
+ Welcome to Worklenz
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 
-
- |
-
-
-
-
-
-
-
-
-
-
- Let's get started with Worklenz.
-
- |
-
-
-
- 
-
- |
-
-
-
-
-
-
-
- Thanks for joining Worklenz!
- We're excited to have you on board.
-
- |
-
-
-
+ |
+
+
+
+
+
+
+
+
+
+ Let's get started with Worklenz.
+
+ |
+
+
+
+ 
+
+ |
+
+
+
+
+
+
+
+ Thanks for joining Worklenz!
+ We're excited to have you on board.
+
+ |
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+
diff --git a/worklenz-frontend/Dockerfile b/worklenz-frontend/Dockerfile
index a32f879e..46a87fa7 100644
--- a/worklenz-frontend/Dockerfile
+++ b/worklenz-frontend/Dockerfile
@@ -12,7 +12,7 @@ COPY . .
RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \
echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js
-RUN npm run build
+RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build
FROM node:22-alpine AS production
diff --git a/worklenz-frontend/index.html b/worklenz-frontend/index.html
index 57a2a1b0..ba93ca2c 100644
--- a/worklenz-frontend/index.html
+++ b/worklenz-frontend/index.html
@@ -48,6 +48,17 @@
+
\ No newline at end of file
diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json
index 42ffdc83..b5caeb72 100644
--- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json
+++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json
@@ -15,7 +15,8 @@
"hide-start-date": "Hide Start Date",
"show-start-date": "Show Start Date",
"hours": "Hours",
- "minutes": "Minutes"
+ "minutes": "Minutes",
+ "recurring": "Recurring"
},
"description": {
"title": "Description",
diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json
new file mode 100644
index 00000000..10a9db71
--- /dev/null
+++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json
@@ -0,0 +1,34 @@
+{
+ "recurring": "Recurring",
+ "recurringTaskConfiguration": "Recurring task configuration",
+ "repeats": "Repeats",
+ "daily": "Daily",
+ "weekly": "Weekly",
+ "everyXDays": "Every X Days",
+ "everyXWeeks": "Every X Weeks",
+ "everyXMonths": "Every X Months",
+ "monthly": "Monthly",
+ "selectDaysOfWeek": "Select Days of the Week",
+ "mon": "Mon",
+ "tue": "Tue",
+ "wed": "Wed",
+ "thu": "Thu",
+ "fri": "Fri",
+ "sat": "Sat",
+ "sun": "Sun",
+ "monthlyRepeatType": "Monthly repeat type",
+ "onSpecificDate": "On a specific date",
+ "onSpecificDay": "On a specific day",
+ "dateOfMonth": "Date of the month",
+ "weekOfMonth": "Week of the month",
+ "dayOfWeek": "Day of the week",
+ "first": "First",
+ "second": "Second",
+ "third": "Third",
+ "fourth": "Fourth",
+ "last": "Last",
+ "intervalDays": "Interval (days)",
+ "intervalWeeks": "Interval (weeks)",
+ "intervalMonths": "Interval (months)",
+ "saveChanges": "Save Changes"
+}
\ No newline at end of file
diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json
index e013b4f2..06575ee1 100644
--- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json
+++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json
@@ -30,7 +30,8 @@
"taskWeight": "Task Weight",
"taskWeightTooltip": "Set the weight of this subtask (percentage)",
"taskWeightRequired": "Please enter a task weight",
- "taskWeightRange": "Weight must be between 0 and 100"
+ "taskWeightRange": "Weight must be between 0 and 100",
+ "recurring": "Recurring"
},
"labels": {
"labelInputPlaceholder": "Search or create",
diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json
index 58c5715e..cdafd81c 100644
--- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json
+++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json
@@ -15,7 +15,8 @@
"hide-start-date": "Ocultar fecha de inicio",
"show-start-date": "Mostrar fecha de inicio",
"hours": "Horas",
- "minutes": "Minutos"
+ "minutes": "Minutos",
+ "recurring": "Recurrente"
},
"description": {
"title": "Descripción",
diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json
new file mode 100644
index 00000000..ecc48c5f
--- /dev/null
+++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json
@@ -0,0 +1,34 @@
+{
+ "recurring": "Recurrente",
+ "recurringTaskConfiguration": "Configuración de tarea recurrente",
+ "repeats": "Repeticiones",
+ "daily": "Diario",
+ "weekly": "Semanal",
+ "everyXDays": "Cada X días",
+ "everyXWeeks": "Cada X semanas",
+ "everyXMonths": "Cada X meses",
+ "monthly": "Mensual",
+ "selectDaysOfWeek": "Seleccionar días de la semana",
+ "mon": "Lun",
+ "tue": "Mar",
+ "wed": "Mié",
+ "thu": "Jue",
+ "fri": "Vie",
+ "sat": "Sáb",
+ "sun": "Dom",
+ "monthlyRepeatType": "Tipo de repetición mensual",
+ "onSpecificDate": "En una fecha específica",
+ "onSpecificDay": "En un día específico",
+ "dateOfMonth": "Fecha del mes",
+ "weekOfMonth": "Semana del mes",
+ "dayOfWeek": "Día de la semana",
+ "first": "Primero",
+ "second": "Segundo",
+ "third": "Tercero",
+ "fourth": "Cuarto",
+ "last": "Último",
+ "intervalDays": "Intervalo (días)",
+ "intervalWeeks": "Intervalo (semanas)",
+ "intervalMonths": "Intervalo (meses)",
+ "saveChanges": "Guardar cambios"
+}
\ No newline at end of file
diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json
index 8b3ef220..c3980da8 100644
--- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json
+++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json
@@ -30,7 +30,8 @@
"taskWeight": "Peso de la Tarea",
"taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)",
"taskWeightRequired": "Por favor, introduce un peso para la tarea",
- "taskWeightRange": "El peso debe estar entre 0 y 100"
+ "taskWeightRange": "El peso debe estar entre 0 y 100",
+ "recurring": "Recurrente"
},
"labels": {
"labelInputPlaceholder": "Buscar o crear",
diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json
index 48922a52..fde2215a 100644
--- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json
+++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json
@@ -15,7 +15,8 @@
"hide-start-date": "Ocultar data de início",
"show-start-date": "Mostrar data de início",
"hours": "Horas",
- "minutes": "Minutos"
+ "minutes": "Minutos",
+ "recurring": "Recorrente"
},
"description": {
"title": "Descrição",
diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json
new file mode 100644
index 00000000..d693f277
--- /dev/null
+++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json
@@ -0,0 +1,34 @@
+{
+ "recurring": "Recorrente",
+ "recurringTaskConfiguration": "Configuração de tarefa recorrente",
+ "repeats": "Repete",
+ "daily": "Diário",
+ "weekly": "Semanal",
+ "everyXDays": "A cada X dias",
+ "everyXWeeks": "A cada X semanas",
+ "everyXMonths": "A cada X meses",
+ "monthly": "Mensal",
+ "selectDaysOfWeek": "Selecionar dias da semana",
+ "mon": "Seg",
+ "tue": "Ter",
+ "wed": "Qua",
+ "thu": "Qui",
+ "fri": "Sex",
+ "sat": "Sáb",
+ "sun": "Dom",
+ "monthlyRepeatType": "Tipo de repetição mensal",
+ "onSpecificDate": "Em uma data específica",
+ "onSpecificDay": "Em um dia específico",
+ "dateOfMonth": "Data do mês",
+ "weekOfMonth": "Semana do mês",
+ "dayOfWeek": "Dia da semana",
+ "first": "Primeira",
+ "second": "Segunda",
+ "third": "Terceira",
+ "fourth": "Quarta",
+ "last": "Última",
+ "intervalDays": "Intervalo (dias)",
+ "intervalWeeks": "Intervalo (semanas)",
+ "intervalMonths": "Intervalo (meses)",
+ "saveChanges": "Salvar alterações"
+}
\ No newline at end of file
diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json
index 7a3933f2..6288af92 100644
--- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json
+++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json
@@ -30,7 +30,8 @@
"taskWeight": "Peso da Tarefa",
"taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)",
"taskWeightRequired": "Por favor, insira um peso para a tarefa",
- "taskWeightRange": "O peso deve estar entre 0 e 100"
+ "taskWeightRange": "O peso deve estar entre 0 e 100",
+ "recurring": "Recorrente"
},
"labels": {
"labelInputPlaceholder": "Pesquisar ou criar",
diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx
index 8b313508..3181a25e 100644
--- a/worklenz-frontend/src/App.tsx
+++ b/worklenz-frontend/src/App.tsx
@@ -13,6 +13,7 @@ import router from './app/routes';
// Hooks & Utils
import { useAppSelector } from './hooks/useAppSelector';
import { initMixpanel } from './utils/mixpanelInit';
+import { initializeCsrfToken } from './api/api-client';
// Types & Constants
import { Language } from './features/i18n/localesSlice';
@@ -35,6 +36,13 @@ const App: React.FC<{ children: React.ReactNode }> = ({ children }) => {
});
}, [language]);
+ // Initialize CSRF token on app startup
+ useEffect(() => {
+ initializeCsrfToken().catch(error => {
+ logger.error('Failed to initialize CSRF token:', error);
+ });
+ }, []);
+
return (
}>
diff --git a/worklenz-frontend/src/api/api-client.ts b/worklenz-frontend/src/api/api-client.ts
index ec43f7a5..721a5274 100644
--- a/worklenz-frontend/src/api/api-client.ts
+++ b/worklenz-frontend/src/api/api-client.ts
@@ -4,27 +4,36 @@ import alertService from '@/services/alerts/alertService';
import logger from '@/utils/errorLogger';
import config from '@/config/env';
-export const getCsrfToken = (): string | null => {
- const match = document.cookie.split('; ').find(cookie => cookie.startsWith('XSRF-TOKEN='));
+// Store CSRF token in memory (since csrf-sync uses session-based tokens)
+let csrfToken: string | null = null;
- if (!match) {
- return null;
- }
- return decodeURIComponent(match.split('=')[1]);
+export const getCsrfToken = (): string | null => {
+ return csrfToken;
};
-// Function to refresh CSRF token if needed
+// Function to refresh CSRF token from server
export const refreshCsrfToken = async (): Promise => {
try {
// Make a GET request to the server to get a fresh CSRF token
- await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
- return getCsrfToken();
+ const response = await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
+ if (response.data && response.data.token) {
+ csrfToken = response.data.token;
+ return csrfToken;
+ }
+ return null;
} catch (error) {
console.error('Failed to refresh CSRF token:', error);
return null;
}
};
+// Initialize CSRF token on app load
+export const initializeCsrfToken = async (): Promise => {
+ if (!csrfToken) {
+ await refreshCsrfToken();
+ }
+};
+
const apiClient = axios.create({
baseURL: config.apiUrl,
withCredentials: true,
@@ -36,12 +45,16 @@ const apiClient = axios.create({
// Request interceptor
apiClient.interceptors.request.use(
- config => {
- const token = getCsrfToken();
- if (token) {
- config.headers['X-CSRF-Token'] = token;
+ async config => {
+ // Ensure we have a CSRF token before making requests
+ if (!csrfToken) {
+ await refreshCsrfToken();
+ }
+
+ if (csrfToken) {
+ config.headers['X-CSRF-Token'] = csrfToken;
} else {
- console.warn('No CSRF token found');
+ console.warn('No CSRF token available');
}
return config;
},
@@ -84,7 +97,7 @@ apiClient.interceptors.response.use(
(typeof errorResponse.data === 'object' &&
errorResponse.data !== null &&
'message' in errorResponse.data &&
- errorResponse.data.message === 'Invalid CSRF token' ||
+ (errorResponse.data.message === 'invalid csrf token' || errorResponse.data.message === 'Invalid CSRF token') ||
(error as any).code === 'EBADCSRFTOKEN')) {
alertService.error('Security Error', 'Invalid security token. Refreshing your session...');
@@ -94,7 +107,7 @@ apiClient.interceptors.response.use(
// Update the token in the failed request
error.config.headers['X-CSRF-Token'] = newToken;
// Retry the original request with the new token
- return axios(error.config);
+ return apiClient(error.config);
} else {
// If token refresh failed, redirect to login
window.location.href = '/auth/login';
diff --git a/worklenz-frontend/src/api/home-page/home-page.api.service.ts b/worklenz-frontend/src/api/home-page/home-page.api.service.ts
index 74f5615a..b71e03a3 100644
--- a/worklenz-frontend/src/api/home-page/home-page.api.service.ts
+++ b/worklenz-frontend/src/api/home-page/home-page.api.service.ts
@@ -5,7 +5,7 @@ import { toQueryString } from '@/utils/toQueryString';
import { IHomeTasksModel, IHomeTasksConfig } from '@/types/home/home-page.types';
import { IMyTask } from '@/types/home/my-tasks.types';
import { IProject } from '@/types/project/project.types';
-import { getCsrfToken } from '../api-client';
+import { getCsrfToken, refreshCsrfToken } from '../api-client';
import config from '@/config/env';
const rootUrl = '/home';
@@ -14,9 +14,18 @@ const api = createApi({
reducerPath: 'homePageApi',
baseQuery: fetchBaseQuery({
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
- prepareHeaders: headers => {
- headers.set('X-CSRF-Token', getCsrfToken() || '');
+ prepareHeaders: async headers => {
+ // Get CSRF token, refresh if needed
+ let token = getCsrfToken();
+ if (!token) {
+ token = await refreshCsrfToken();
+ }
+
+ if (token) {
+ headers.set('X-CSRF-Token', token);
+ }
headers.set('Content-Type', 'application/json');
+ return headers;
},
credentials: 'include',
}),
diff --git a/worklenz-frontend/src/api/projects/projects.v1.api.service.ts b/worklenz-frontend/src/api/projects/projects.v1.api.service.ts
index 1fe279d5..1ad45b8b 100644
--- a/worklenz-frontend/src/api/projects/projects.v1.api.service.ts
+++ b/worklenz-frontend/src/api/projects/projects.v1.api.service.ts
@@ -5,7 +5,7 @@ import { IProjectCategory } from '@/types/project/projectCategory.types';
import { IProjectsViewModel } from '@/types/project/projectsViewModel.types';
import { IServerResponse } from '@/types/common.types';
import { IProjectMembersViewModel } from '@/types/projectMember.types';
-import { getCsrfToken } from '../api-client';
+import { getCsrfToken, refreshCsrfToken } from '../api-client';
import config from '@/config/env';
const rootUrl = '/projects';
@@ -14,9 +14,18 @@ export const projectsApi = createApi({
reducerPath: 'projectsApi',
baseQuery: fetchBaseQuery({
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
- prepareHeaders: headers => {
- headers.set('X-CSRF-Token', getCsrfToken() || '');
+ prepareHeaders: async headers => {
+ // Get CSRF token, refresh if needed
+ let token = getCsrfToken();
+ if (!token) {
+ token = await refreshCsrfToken();
+ }
+
+ if (token) {
+ headers.set('X-CSRF-Token', token);
+ }
headers.set('Content-Type', 'application/json');
+ return headers;
},
credentials: 'include',
}),
diff --git a/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts b/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts
new file mode 100644
index 00000000..6e19d7cb
--- /dev/null
+++ b/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts
@@ -0,0 +1,16 @@
+import { API_BASE_URL } from "@/shared/constants";
+import { IServerResponse } from "@/types/common.types";
+import { ITaskRecurringSchedule } from "@/types/tasks/task-recurring-schedule";
+import apiClient from "../api-client";
+
+const rootUrl = `${API_BASE_URL}/task-recurring`;
+
+export const taskRecurringApiService = {
+ getTaskRecurringData: async (schedule_id: string): Promise> => {
+ const response = await apiClient.get(`${rootUrl}/${schedule_id}`);
+ return response.data;
+ },
+ updateTaskRecurringData: async (schedule_id: string, body: any): Promise> => {
+ return apiClient.put(`${rootUrl}/${schedule_id}`, body);
+ }
+}
\ No newline at end of file
diff --git a/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts b/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts
index f4565837..37673590 100644
--- a/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts
+++ b/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts
@@ -5,6 +5,16 @@ import { ITaskLogViewModel } from "@/types/tasks/task-log-view.types";
const rootUrl = `${API_BASE_URL}/task-time-log`;
+export interface IRunningTimer {
+ task_id: string;
+ start_time: string;
+ task_name: string;
+ project_id: string;
+ project_name: string;
+ parent_task_id?: string;
+ parent_task_name?: string;
+}
+
export const taskTimeLogsApiService = {
getByTask: async (id: string) : Promise> => {
const response = await apiClient.get(`${rootUrl}/task/${id}`);
@@ -26,6 +36,11 @@ export const taskTimeLogsApiService = {
return response.data;
},
+ getRunningTimers: async (): Promise> => {
+ const response = await apiClient.get(`${rootUrl}/running-timers`);
+ return response.data;
+ },
+
exportToExcel(taskId: string) {
window.location.href = `${import.meta.env.VITE_API_URL}${API_BASE_URL}/task-time-log/export/${taskId}`;
},
diff --git a/worklenz-frontend/src/components/EmptyListPlaceholder.tsx b/worklenz-frontend/src/components/EmptyListPlaceholder.tsx
index 372cd845..dfe1aa76 100644
--- a/worklenz-frontend/src/components/EmptyListPlaceholder.tsx
+++ b/worklenz-frontend/src/components/EmptyListPlaceholder.tsx
@@ -8,7 +8,7 @@ type EmptyListPlaceholderProps = {
};
const EmptyListPlaceholder = ({
- imageSrc = '/src/assets/images/empty-box.webp',
+ imageSrc = 'https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp',
imageHeight = 60,
text,
}: EmptyListPlaceholderProps) => {
diff --git a/worklenz-frontend/src/components/HubSpot.tsx b/worklenz-frontend/src/components/HubSpot.tsx
new file mode 100644
index 00000000..072ca433
--- /dev/null
+++ b/worklenz-frontend/src/components/HubSpot.tsx
@@ -0,0 +1,24 @@
+import { useEffect } from 'react';
+
+const HubSpot = () => {
+ useEffect(() => {
+ const script = document.createElement('script');
+ script.type = 'text/javascript';
+ script.id = 'hs-script-loader';
+ script.async = true;
+ script.defer = true;
+ script.src = '//js.hs-scripts.com/22348300.js';
+ document.body.appendChild(script);
+
+ return () => {
+ const existingScript = document.getElementById('hs-script-loader');
+ if (existingScript) {
+ existingScript.remove();
+ }
+ };
+ }, []);
+
+ return null;
+};
+
+export default HubSpot;
\ No newline at end of file
diff --git a/worklenz-frontend/src/components/home-tasks/statusDropdown/home-tasks-status-dropdown.tsx b/worklenz-frontend/src/components/home-tasks/statusDropdown/home-tasks-status-dropdown.tsx
index bc75016d..f3595e99 100644
--- a/worklenz-frontend/src/components/home-tasks/statusDropdown/home-tasks-status-dropdown.tsx
+++ b/worklenz-frontend/src/components/home-tasks/statusDropdown/home-tasks-status-dropdown.tsx
@@ -21,10 +21,10 @@ const HomeTasksStatusDropdown = ({ task, teamId }: HomeTasksStatusDropdownProps)
const { socket, connected } = useSocket();
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
const {
- refetch
- } = useGetMyTasksQuery(homeTasksConfig, {
- skip: true // Skip automatic queries entirely
- });
+ refetch
+ } = useGetMyTasksQuery(homeTasksConfig, {
+ skip: false, // Ensure this query runs
+ });
const [selectedStatus, setSelectedStatus] = useState(undefined);
diff --git a/worklenz-frontend/src/components/home-tasks/taskDatePicker/home-tasks-date-picker.tsx b/worklenz-frontend/src/components/home-tasks/taskDatePicker/home-tasks-date-picker.tsx
index 604cbce5..857458ff 100644
--- a/worklenz-frontend/src/components/home-tasks/taskDatePicker/home-tasks-date-picker.tsx
+++ b/worklenz-frontend/src/components/home-tasks/taskDatePicker/home-tasks-date-picker.tsx
@@ -23,14 +23,14 @@ const HomeTasksDatePicker = ({ record }: HomeTasksDatePickerProps) => {
const { t } = useTranslation('home');
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
- skip: true // Skip automatic queries entirely
+ skip: false
});
-
+
// Use useMemo to avoid re-renders when record.end_date is the same
- const initialDate = useMemo(() =>
+ const initialDate = useMemo(() =>
record.end_date ? dayjs(record.end_date) : null
- , [record.end_date]);
-
+ , [record.end_date]);
+
const [selectedDate, setSelectedDate] = useState(initialDate);
// Update selected date when record changes
diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx
new file mode 100644
index 00000000..1ff8b315
--- /dev/null
+++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx
@@ -0,0 +1,382 @@
+import React, { useState, useMemo, useEffect } from 'react';
+import {
+ Form,
+ Switch,
+ Button,
+ Popover,
+ Select,
+ Checkbox,
+ Radio,
+ InputNumber,
+ Skeleton,
+ Row,
+ Col,
+} from 'antd';
+import { SettingOutlined } from '@ant-design/icons';
+import { useSocket } from '@/socket/socketContext';
+import { SocketEvents } from '@/shared/socket-events';
+import { IRepeatOption, ITaskRecurring, ITaskRecurringSchedule, ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule';
+import { ITaskViewModel } from '@/types/tasks/task.types';
+import { useTranslation } from 'react-i18next';
+import { useAppDispatch } from '@/hooks/useAppDispatch';
+import { updateRecurringChange } from '@/features/tasks/tasks.slice';
+import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service';
+import logger from '@/utils/errorLogger';
+import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice';
+
+const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1);
+
+const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
+ const { socket, connected } = useSocket();
+ const dispatch = useAppDispatch();
+ const { t } = useTranslation('task-drawer/task-drawer-recurring-config');
+
+ const repeatOptions: IRepeatOption[] = [
+ { label: t('daily'), value: ITaskRecurring.Daily },
+ { label: t('weekly'), value: ITaskRecurring.Weekly },
+ { label: t('everyXDays'), value: ITaskRecurring.EveryXDays },
+ { label: t('everyXWeeks'), value: ITaskRecurring.EveryXWeeks },
+ { label: t('everyXMonths'), value: ITaskRecurring.EveryXMonths },
+ { label: t('monthly'), value: ITaskRecurring.Monthly },
+ ];
+
+ const daysOfWeek = [
+ { label: t('sun'), value: 0, checked: false },
+ { label: t('mon'), value: 1, checked: false },
+ { label: t('tue'), value: 2, checked: false },
+ { label: t('wed'), value: 3, checked: false },
+ { label: t('thu'), value: 4, checked: false },
+ { label: t('fri'), value: 5, checked: false },
+ { label: t('sat'), value: 6, checked: false }
+ ];
+
+ const weekOptions = [
+ { label: t('first'), value: 1 },
+ { label: t('second'), value: 2 },
+ { label: t('third'), value: 3 },
+ { label: t('fourth'), value: 4 },
+ { label: t('last'), value: 5 }
+ ];
+
+ const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value }));
+
+ const [recurring, setRecurring] = useState(false);
+ const [showConfig, setShowConfig] = useState(false);
+ const [repeatOption, setRepeatOption] = useState({});
+ const [selectedDays, setSelectedDays] = useState([]);
+ const [monthlyOption, setMonthlyOption] = useState('date');
+ const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1);
+ const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value);
+ const [selectedMonthlyDay, setSelectedMonthlyDay] = useState(dayOptions[0].value);
+ const [intervalDays, setIntervalDays] = useState(1);
+ const [intervalWeeks, setIntervalWeeks] = useState(1);
+ const [intervalMonths, setIntervalMonths] = useState(1);
+ const [loadingData, setLoadingData] = useState(false);
+ const [updatingData, setUpdatingData] = useState(false);
+ const [scheduleData, setScheduleData] = useState({});
+
+ const handleChange = (checked: boolean) => {
+ if (!task.id) return;
+
+ socket?.emit(SocketEvents.TASK_RECURRING_CHANGE.toString(), {
+ task_id: task.id,
+ schedule_id: task.schedule_id,
+ });
+
+ socket?.once(
+ SocketEvents.TASK_RECURRING_CHANGE.toString(),
+ (schedule: ITaskRecurringScheduleData) => {
+ if (schedule.id && schedule.schedule_type) {
+ const selected = repeatOptions.find(e => e.value == schedule.schedule_type);
+ if (selected) setRepeatOption(selected);
+ }
+ dispatch(updateRecurringChange(schedule));
+ dispatch(setTaskRecurringSchedule({ schedule_id: schedule.id as string, task_id: task.id }));
+
+ setRecurring(checked);
+ if (!checked) setShowConfig(false);
+ }
+ );
+ };
+
+ const configVisibleChange = (visible: boolean) => {
+ setShowConfig(visible);
+ };
+
+ const isMonthlySelected = useMemo(
+ () => repeatOption.value === ITaskRecurring.Monthly,
+ [repeatOption]
+ );
+
+ const handleDayCheckboxChange = (checkedValues: number[]) => {
+ setSelectedDays(checkedValues);
+ };
+
+ const getSelectedDays = () => {
+ return daysOfWeek
+ .filter(day => day.checked) // Get only the checked days
+ .map(day => day.value); // Extract their numeric values
+ }
+
+ const getUpdateBody = () => {
+ if (!task.id || !task.schedule_id || !repeatOption.value) return;
+
+ const body: ITaskRecurringSchedule = {
+ id: task.id,
+ schedule_type: repeatOption.value
+ };
+
+ switch (repeatOption.value) {
+ case ITaskRecurring.Weekly:
+ body.days_of_week = getSelectedDays();
+ break;
+
+ case ITaskRecurring.Monthly:
+ if (monthlyOption === 'date') {
+ body.date_of_month = selectedMonthlyDate;
+ setSelectedMonthlyDate(0);
+ setSelectedMonthlyDay(0);
+ } else {
+ body.week_of_month = selectedMonthlyWeek;
+ body.day_of_month = selectedMonthlyDay;
+ setSelectedMonthlyDate(0);
+ }
+ break;
+
+ case ITaskRecurring.EveryXDays:
+ body.interval_days = intervalDays;
+ break;
+
+ case ITaskRecurring.EveryXWeeks:
+ body.interval_weeks = intervalWeeks;
+ break;
+
+ case ITaskRecurring.EveryXMonths:
+ body.interval_months = intervalMonths;
+ break;
+ }
+ return body;
+ }
+
+ const handleSave = async () => {
+ if (!task.id || !task.schedule_id) return;
+
+ try {
+ setUpdatingData(true);
+ const body = getUpdateBody();
+
+ const res = await taskRecurringApiService.updateTaskRecurringData(task.schedule_id, body);
+ if (res.done) {
+ setRecurring(true);
+ setShowConfig(false);
+ configVisibleChange(false);
+ }
+ } catch (e) {
+ logger.error("handleSave", e);
+ } finally {
+ setUpdatingData(false);
+ }
+ };
+
+ const updateDaysOfWeek = () => {
+ for (let i = 0; i < daysOfWeek.length; i++) {
+ daysOfWeek[i].checked = scheduleData.days_of_week?.includes(daysOfWeek[i].value) ?? false;
+ }
+ };
+
+ const getScheduleData = async () => {
+ if (!task.schedule_id) return;
+ setLoadingData(true);
+ try {
+ const res = await taskRecurringApiService.getTaskRecurringData(task.schedule_id);
+ if (res.done) {
+ setScheduleData(res.body);
+ if (!res.body) {
+ setRepeatOption(repeatOptions[0]);
+ } else {
+ const selected = repeatOptions.find(e => e.value == res.body.schedule_type);
+ if (selected) {
+ setRepeatOption(selected);
+ setSelectedMonthlyDate(scheduleData.date_of_month || 1);
+ setSelectedMonthlyDay(scheduleData.day_of_month || 0);
+ setSelectedMonthlyWeek(scheduleData.week_of_month || 0);
+ setIntervalDays(scheduleData.interval_days || 1);
+ setIntervalWeeks(scheduleData.interval_weeks || 1);
+ setIntervalMonths(scheduleData.interval_months || 1);
+ setMonthlyOption(selectedMonthlyDate ? 'date' : 'day');
+ updateDaysOfWeek();
+ }
+ }
+ };
+ } catch (e) {
+ logger.error("getScheduleData", e);
+ }
+ finally {
+ setLoadingData(false);
+ }
+ }
+
+ const handleResponse = (response: ITaskRecurringScheduleData) => {
+ if (!task || !response.task_id) return;
+ };
+
+ useEffect(() => {
+ if (!task) return;
+
+ if (task) setRecurring(!!task.schedule_id);
+ if (task.schedule_id) void getScheduleData();
+ socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse);
+ }, [task?.schedule_id]);
+
+ return (
+
+
+
+
+
+ {recurring && (
+
+
+
+
+ {repeatOption.value === ITaskRecurring.Weekly && (
+
+ ({
+ label: day.label,
+ value: day.value
+ }))}
+ value={selectedDays}
+ onChange={handleDayCheckboxChange}
+ style={{ width: '100%' }}
+ >
+
+ {daysOfWeek.map(day => (
+
+ {day.label}
+
+ ))}
+
+
+
+ )}
+
+ {isMonthlySelected && (
+ <>
+
+ setMonthlyOption(e.target.value)}
+ >
+ {t('onSpecificDate')}
+ {t('onSpecificDay')}
+
+
+ {monthlyOption === 'date' && (
+
+
+ )}
+ {monthlyOption === 'day' && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+ >
+ )}
+
+ {repeatOption.value === ITaskRecurring.EveryXDays && (
+
+ value && setIntervalDays(value)}
+ />
+
+ )}
+ {repeatOption.value === ITaskRecurring.EveryXWeeks && (
+
+ value && setIntervalWeeks(value)}
+ />
+
+ )}
+ {repeatOption.value === ITaskRecurring.EveryXMonths && (
+
+ value && setIntervalMonths(value)}
+ />
+
+ )}
+
+
+
+
+
+ }
+ overlayStyle={{ width: 510 }}
+ open={showConfig}
+ onOpenChange={configVisibleChange}
+ trigger="click"
+ >
+
+
+ )}
+
+
+
+ );
+};
+
+export default TaskDrawerRecurringConfig;
diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx
index f9792485..23dac128 100644
--- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx
+++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx
@@ -29,6 +29,8 @@ import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billa
import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progress';
import { useAppSelector } from '@/hooks/useAppSelector';
import logger from '@/utils/errorLogger';
+import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/task-drawer-recurring-config';
+import { InlineMember } from '@/types/teamMembers/inlineMember.types';
interface TaskDetailsFormProps {
taskFormViewModel?: ITaskFormViewModel | null;
@@ -44,29 +46,32 @@ const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps)
const { project } = useAppSelector(state => state.projectReducer);
const hasSubTasks = task?.sub_tasks_count > 0;
const isSubTask = !!task?.parent_task_id;
-
- // Add more aggressive logging and checks
- logger.debug(`Task ${task.id} status: hasSubTasks=${hasSubTasks}, isSubTask=${isSubTask}, modes: time=${project?.use_time_progress}, manual=${project?.use_manual_progress}, weighted=${project?.use_weighted_progress}`);
-
+
// STRICT RULE: Never show progress input for parent tasks with subtasks
// This is the most important check and must be done first
if (hasSubTasks) {
logger.debug(`Task ${task.id} has ${task.sub_tasks_count} subtasks. Hiding progress input.`);
return null;
}
-
+
// Only for tasks without subtasks, determine which input to show based on project mode
if (project?.use_time_progress) {
// In time-based mode, show progress input ONLY for tasks without subtasks
- return ;
+ return (
+
+ );
} else if (project?.use_manual_progress) {
// In manual mode, show progress input ONLY for tasks without subtasks
- return ;
+ return (
+
+ );
} else if (project?.use_weighted_progress && isSubTask) {
// In weighted mode, show weight input for subtasks
- return ;
+ return (
+
+ );
}
-
+
return null;
};
@@ -147,7 +152,13 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
-
+
@@ -159,10 +170,7 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
{taskFormViewModel?.task && (
-
+
)}
@@ -175,6 +183,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
+
+
+
+
diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx
index 6a20f0b9..0bc322f3 100644
--- a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx
+++ b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx
@@ -27,6 +27,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
const { socket, connected } = useSocket();
const { clearTaskFromUrl } = useTaskDrawerUrlSync();
const isDeleting = useRef(false);
+ const [isEditing, setIsEditing] = useState(false);
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
const [taskName, setTaskName] = useState(taskFormViewModel?.task?.name ?? '');
@@ -88,6 +89,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
};
const handleInputBlur = () => {
+ setIsEditing(false);
if (
!selectedTaskId ||
!connected ||
@@ -113,21 +115,39 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
return (
- onTaskNameChange(e)}
- onBlur={handleInputBlur}
- placeholder={t('taskHeader.taskNamePlaceholder')}
- className="task-name-input"
- style={{
- width: '100%',
- border: 'none',
- }}
- showCount={false}
- maxLength={250}
- />
+ {isEditing ? (
+ onTaskNameChange(e)}
+ onBlur={handleInputBlur}
+ placeholder={t('taskHeader.taskNamePlaceholder')}
+ className="task-name-input"
+ style={{
+ width: '100%',
+ border: 'none',
+ }}
+ showCount={true}
+ maxLength={250}
+ autoFocus
+ />
+ ) : (
+ setIsEditing(true)}
+ style={{
+ margin: 0,
+ padding: '4px 11px',
+ fontSize: '16px',
+ cursor: 'pointer',
+ wordWrap: 'break-word',
+ overflowWrap: 'break-word',
+ width: '100%'
+ }}
+ >
+ {taskName || t('taskHeader.taskNamePlaceholder')}
+
+ )}
{
- const { socket, connected } = useSocket();
+ const { socket } = useSocket();
const dispatch = useAppDispatch();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { tab } = useTabSearchParam();
diff --git a/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx b/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx
index ce38df32..0099ae11 100644
--- a/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx
+++ b/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx
@@ -58,7 +58,7 @@ import alertService from '@/services/alerts/alertService';
interface ITaskAssignee {
id: string;
- name?: string;
+ name: string;
email?: string;
avatar_url?: string;
team_member_id: string;
@@ -437,7 +437,7 @@ const TaskListBulkActionsBar = () => {
placement="top"
arrow
trigger={['click']}
- destroyPopupOnHide
+ destroyOnHidden
onOpenChange={value => {
if (!value) {
setSelectedLabels([]);
diff --git a/worklenz-frontend/src/features/navbar/navbar.tsx b/worklenz-frontend/src/features/navbar/navbar.tsx
index 295a8a17..430318d3 100644
--- a/worklenz-frontend/src/features/navbar/navbar.tsx
+++ b/worklenz-frontend/src/features/navbar/navbar.tsx
@@ -5,7 +5,6 @@ import { Col, ConfigProvider, Flex, Menu, MenuProps, Alert } from 'antd';
import { createPortal } from 'react-dom';
import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members';
-import HelpButton from './help/HelpButton';
import InviteButton from './invite/InviteButton';
import MobileMenuButton from './mobileMenu/MobileMenuButton';
import NavbarLogo from './navbar-logo';
@@ -22,6 +21,7 @@ import { useAuthService } from '@/hooks/useAuth';
import { authApiService } from '@/api/auth/auth.api.service';
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
import logger from '@/utils/errorLogger';
+import TimerButton from './timers/timer-button';
const Navbar = () => {
const [current, setCurrent] = useState('home');
@@ -90,6 +90,7 @@ const Navbar = () => {
}, [location]);
return (
+
{
justifyContent: 'space-between',
}}
>
- {daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && (
- 0 ? `Your license will expire in ${daysUntilExpiry} days` : `Your license has expired ${Math.abs(daysUntilExpiry)} days ago`}
- type="warning"
- showIcon
- style={{ width: '100%', marginTop: 12 }}
- />
- )}
{
-
+
diff --git a/worklenz-frontend/src/features/navbar/timers/timer-button.tsx b/worklenz-frontend/src/features/navbar/timers/timer-button.tsx
new file mode 100644
index 00000000..b9e050f0
--- /dev/null
+++ b/worklenz-frontend/src/features/navbar/timers/timer-button.tsx
@@ -0,0 +1,275 @@
+import { ClockCircleOutlined, StopOutlined } from '@ant-design/icons';
+import { Badge, Button, Dropdown, List, Tooltip, Typography, Space, Divider, theme } from 'antd';
+import React, { useEffect, useState, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { taskTimeLogsApiService, IRunningTimer } from '@/api/tasks/task-time-logs.api.service';
+import { useAppDispatch } from '@/hooks/useAppDispatch';
+import { useSocket } from '@/socket/socketContext';
+import { SocketEvents } from '@/shared/socket-events';
+import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice';
+import moment from 'moment';
+
+const { Text } = Typography;
+const { useToken } = theme;
+
+const TimerButton = () => {
+ const [runningTimers, setRunningTimers] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [currentTimes, setCurrentTimes] = useState>({});
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+ const { t } = useTranslation('navbar');
+ const { token } = useToken();
+ const dispatch = useAppDispatch();
+ const { socket } = useSocket();
+
+ const fetchRunningTimers = useCallback(async () => {
+ try {
+ setLoading(true);
+ const response = await taskTimeLogsApiService.getRunningTimers();
+ if (response.done) {
+ setRunningTimers(response.body || []);
+ }
+ } catch (error) {
+ console.error('Error fetching running timers:', error);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const updateCurrentTimes = () => {
+ const newTimes: Record = {};
+ runningTimers.forEach(timer => {
+ const startTime = moment(timer.start_time);
+ const now = moment();
+ const duration = moment.duration(now.diff(startTime));
+ const hours = Math.floor(duration.asHours());
+ const minutes = duration.minutes();
+ const seconds = duration.seconds();
+ newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
+ });
+ setCurrentTimes(newTimes);
+ };
+
+ useEffect(() => {
+ fetchRunningTimers();
+
+ // Set up polling to refresh timers every 30 seconds
+ const pollInterval = setInterval(() => {
+ fetchRunningTimers();
+ }, 30000);
+
+ return () => clearInterval(pollInterval);
+ }, [fetchRunningTimers]);
+
+ useEffect(() => {
+ if (runningTimers.length > 0) {
+ updateCurrentTimes();
+ const interval = setInterval(updateCurrentTimes, 1000);
+ return () => clearInterval(interval);
+ }
+ }, [runningTimers]);
+
+ // Listen for timer start/stop events and project updates to refresh the count
+ useEffect(() => {
+ if (!socket) return;
+
+ const handleTimerStart = (data: string) => {
+ try {
+ const { id } = typeof data === 'string' ? JSON.parse(data) : data;
+ if (id) {
+ // Refresh the running timers list when a new timer is started
+ fetchRunningTimers();
+ }
+ } catch (error) {
+ console.error('Error parsing timer start event:', error);
+ }
+ };
+
+ const handleTimerStop = (data: string) => {
+ try {
+ const { id } = typeof data === 'string' ? JSON.parse(data) : data;
+ if (id) {
+ // Refresh the running timers list when a timer is stopped
+ fetchRunningTimers();
+ }
+ } catch (error) {
+ console.error('Error parsing timer stop event:', error);
+ }
+ };
+
+ const handleProjectUpdates = () => {
+ // Refresh timers when project updates are available
+ fetchRunningTimers();
+ };
+
+ socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
+ socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
+ socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
+
+ return () => {
+ socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
+ socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
+ socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
+ };
+ }, [socket, fetchRunningTimers]);
+
+ const hasRunningTimers = () => {
+ return runningTimers.length > 0;
+ };
+
+ const timerCount = () => {
+ return runningTimers.length;
+ };
+
+ const handleStopTimer = (taskId: string) => {
+ if (!socket) return;
+
+ socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId }));
+ dispatch(updateTaskTimeTracking({ taskId, timeTracking: null }));
+ };
+
+ const dropdownContent = (
+
+ {runningTimers.length === 0 ? (
+
+ No running timers
+
+ ) : (
+ (
+
+
+
+
+ {timer.task_name}
+
+
+ {timer.project_name}
+
+ {timer.parent_task_name && (
+
+ Parent: {timer.parent_task_name}
+
+ )}
+
+
+
+
+ Started: {moment(timer.start_time).format('HH:mm')}
+
+
+ {currentTimes[timer.task_id] || '00:00:00'}
+
+
+
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ handleStopTimer(timer.task_id);
+ }}
+ style={{
+ backgroundColor: token.colorErrorBg,
+ borderColor: token.colorError,
+ color: token.colorError,
+ fontWeight: 500
+ }}
+ >
+ Stop
+
+
+
+
+
+ )}
+ />
+ )}
+ {runningTimers.length > 0 && (
+ <>
+
+
+
+ {runningTimers.length} timer{runningTimers.length !== 1 ? 's' : ''} running
+
+
+ >
+ )}
+
+ );
+
+ return (
+ dropdownContent}
+ trigger={['click']}
+ placement="bottomRight"
+ open={dropdownOpen}
+ onOpenChange={(open) => {
+ setDropdownOpen(open);
+ if (open) {
+ fetchRunningTimers();
+ }
+ }}
+ >
+
+
+
+ );
+};
+
+export default TimerButton;
\ No newline at end of file
diff --git a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts
index 9654a2d0..74ba350c 100644
--- a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts
+++ b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts
@@ -105,6 +105,15 @@ const taskDrawerSlice = createSlice({
}>) => {
state.timeLogEditing = action.payload;
},
+ setTaskRecurringSchedule: (state, action: PayloadAction<{
+ schedule_id: string;
+ task_id: string;
+ }>) => {
+ const { schedule_id, task_id } = action.payload;
+ if (state.taskFormViewModel?.task && state.taskFormViewModel.task.id === task_id) {
+ state.taskFormViewModel.task.schedule_id = schedule_id;
+ }
+ },
},
extraReducers: builder => {
builder.addCase(fetchTask.pending, state => {
@@ -133,5 +142,6 @@ export const {
setTaskLabels,
setTaskSubscribers,
setTimeLogEditing,
+ setTaskRecurringSchedule
} = taskDrawerSlice.actions;
export default taskDrawerSlice.reducer;
diff --git a/worklenz-frontend/src/features/tasks/tasks.slice.ts b/worklenz-frontend/src/features/tasks/tasks.slice.ts
index cd443dbf..49c85e28 100644
--- a/worklenz-frontend/src/features/tasks/tasks.slice.ts
+++ b/worklenz-frontend/src/features/tasks/tasks.slice.ts
@@ -22,6 +22,7 @@ import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-respon
import { produce } from 'immer';
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
import { SocketEvents } from '@/shared/socket-events';
+import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule';
export enum IGroupBy {
STATUS = 'status',
@@ -1006,6 +1007,15 @@ const taskSlice = createSlice({
column.pinned = isVisible;
}
},
+
+ updateRecurringChange: (state, action: PayloadAction) => {
+ const {id, schedule_type, task_id} = action.payload;
+ const taskInfo = findTaskInGroups(state.taskGroups, task_id as string);
+ if (!taskInfo) return;
+
+ const { task } = taskInfo;
+ task.schedule_id = id;
+ }
},
extraReducers: builder => {
@@ -1165,6 +1175,7 @@ export const {
updateSubTasks,
updateCustomColumnValue,
updateCustomColumnPinned,
+ updateRecurringChange
} = taskSlice.actions;
export default taskSlice.reducer;
diff --git a/worklenz-frontend/src/hooks/useFilterDataLoader.ts b/worklenz-frontend/src/hooks/useFilterDataLoader.ts
new file mode 100644
index 00000000..e3aa4f41
--- /dev/null
+++ b/worklenz-frontend/src/hooks/useFilterDataLoader.ts
@@ -0,0 +1,69 @@
+import { useEffect, useCallback } from 'react';
+import { useAppDispatch } from '@/hooks/useAppDispatch';
+import { useAppSelector } from '@/hooks/useAppSelector';
+import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
+import {
+ fetchLabelsByProject,
+ fetchTaskAssignees,
+} from '@/features/tasks/tasks.slice';
+import { getTeamMembers } from '@/features/team-members/team-members.slice';
+
+/**
+ * Hook to manage filter data loading independently of main task list loading
+ * This ensures filter data loading doesn't block the main UI skeleton
+ */
+export const useFilterDataLoader = () => {
+ const dispatch = useAppDispatch();
+
+ const { priorities } = useAppSelector(state => ({
+ priorities: state.priorityReducer.priorities,
+ }));
+
+ const { projectId } = useAppSelector(state => ({
+ projectId: state.projectReducer.projectId,
+ }));
+
+ // Load filter data asynchronously
+ const loadFilterData = useCallback(async () => {
+ try {
+ // Load priorities if not already loaded (usually fast/cached)
+ if (!priorities.length) {
+ dispatch(fetchPriorities());
+ }
+
+ // Load project-specific data in parallel without blocking
+ if (projectId) {
+ // These dispatch calls are fire-and-forget
+ // They will update the UI when ready, but won't block initial render
+ dispatch(fetchLabelsByProject(projectId));
+ dispatch(fetchTaskAssignees(projectId));
+ }
+
+ // Load team members for member filters
+ dispatch(getTeamMembers({
+ index: 0,
+ size: 100,
+ field: null,
+ order: null,
+ search: null,
+ all: true
+ }));
+ } catch (error) {
+ console.error('Error loading filter data:', error);
+ // Don't throw - filter loading errors shouldn't break the main UI
+ }
+ }, [dispatch, priorities.length, projectId]);
+
+ // Load filter data on mount and when dependencies change
+ useEffect(() => {
+ // Use setTimeout to ensure this runs after the main component render
+ // This prevents filter loading from blocking the initial render
+ const timeoutId = setTimeout(loadFilterData, 0);
+
+ return () => clearTimeout(timeoutId);
+ }, [loadFilterData]);
+
+ return {
+ loadFilterData,
+ };
+};
\ No newline at end of file
diff --git a/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts b/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts
new file mode 100644
index 00000000..cfadee8a
--- /dev/null
+++ b/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts
@@ -0,0 +1,146 @@
+import { useMemo, useCallback } from 'react';
+import {
+ DndContext,
+ DragEndEvent,
+ DragOverEvent,
+ DragStartEvent,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ KeyboardSensor,
+ TouchSensor,
+} from '@dnd-kit/core';
+import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
+import { useAppDispatch } from '@/hooks/useAppDispatch';
+import { useAppSelector } from '@/hooks/useAppSelector';
+import { updateTaskStatus } from '@/features/tasks/tasks.slice';
+import { ITaskListGroup } from '@/types/tasks/taskList.types';
+import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
+
+export const useTaskDragAndDrop = () => {
+ const dispatch = useAppDispatch();
+
+ // Memoize the selector to prevent unnecessary rerenders
+ const taskGroups = useAppSelector(state => state.taskReducer.taskGroups);
+ const groupBy = useAppSelector(state => state.taskReducer.groupBy);
+
+ // Memoize sensors configuration for better performance
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ useSensor(TouchSensor, {
+ activationConstraint: {
+ delay: 250,
+ tolerance: 5,
+ },
+ })
+ );
+
+ const handleDragStart = useCallback((event: DragStartEvent) => {
+ // Add visual feedback for drag start
+ const { active } = event;
+ if (active) {
+ document.body.style.cursor = 'grabbing';
+ }
+ }, []);
+
+ const handleDragOver = useCallback((event: DragOverEvent) => {
+ // Handle drag over logic if needed
+ // This can be used for visual feedback during drag
+ }, []);
+
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ // Reset cursor
+ document.body.style.cursor = '';
+
+ const { active, over } = event;
+
+ if (!active || !over || !taskGroups) {
+ return;
+ }
+
+ try {
+ const activeId = active.id as string;
+ const overId = over.id as string;
+
+ // Find the task being dragged
+ let draggedTask: IProjectTask | null = null;
+ let sourceGroupId: string | null = null;
+
+ for (const group of taskGroups) {
+ const task = group.tasks?.find((t: IProjectTask) => t.id === activeId);
+ if (task) {
+ draggedTask = task;
+ sourceGroupId = group.id;
+ break;
+ }
+ }
+
+ if (!draggedTask || !sourceGroupId) {
+ console.warn('Could not find dragged task');
+ return;
+ }
+
+ // Determine target group
+ let targetGroupId: string | null = null;
+
+ // Check if dropped on a group container
+ const targetGroup = taskGroups.find((group: ITaskListGroup) => group.id === overId);
+ if (targetGroup) {
+ targetGroupId = targetGroup.id;
+ } else {
+ // Check if dropped on another task
+ for (const group of taskGroups) {
+ const targetTask = group.tasks?.find((t: IProjectTask) => t.id === overId);
+ if (targetTask) {
+ targetGroupId = group.id;
+ break;
+ }
+ }
+ }
+
+ if (!targetGroupId || targetGroupId === sourceGroupId) {
+ return; // No change needed
+ }
+
+ // Update task status based on group change
+ const targetGroupData = taskGroups.find((group: ITaskListGroup) => group.id === targetGroupId);
+ if (targetGroupData && groupBy === 'status') {
+ const updatePayload: any = {
+ task_id: draggedTask.id,
+ status_id: targetGroupData.id,
+ };
+
+ if (draggedTask.parent_task_id) {
+ updatePayload.parent_task = draggedTask.parent_task_id;
+ }
+
+ dispatch(updateTaskStatus(updatePayload));
+ }
+ } catch (error) {
+ console.error('Error handling drag end:', error);
+ }
+ },
+ [taskGroups, groupBy, dispatch]
+ );
+
+ // Memoize the drag and drop configuration
+ const dragAndDropConfig = useMemo(
+ () => ({
+ sensors,
+ onDragStart: handleDragStart,
+ onDragOver: handleDragOver,
+ onDragEnd: handleDragEnd,
+ }),
+ [sensors, handleDragStart, handleDragOver, handleDragEnd]
+ );
+
+ return dragAndDropConfig;
+};
\ No newline at end of file
diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts
new file mode 100644
index 00000000..7c85ead6
--- /dev/null
+++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts
@@ -0,0 +1,343 @@
+import { useCallback, useEffect } from 'react';
+import { useAppDispatch } from '@/hooks/useAppDispatch';
+import { useAppSelector } from '@/hooks/useAppSelector';
+import { useSocket } from '@/socket/socketContext';
+import { useAuthService } from '@/hooks/useAuth';
+import { SocketEvents } from '@/shared/socket-events';
+import logger from '@/utils/errorLogger';
+import alertService from '@/services/alerts/alertService';
+
+import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
+import { ILabelsChangeResponse } from '@/types/tasks/taskList.types';
+import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types';
+import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
+import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-response';
+import { InlineMember } from '@/types/teamMembers/inlineMember.types';
+import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
+import { ITaskListGroup } from '@/types/tasks/taskList.types';
+
+import {
+ fetchTaskAssignees,
+ updateTaskAssignees,
+ fetchLabelsByProject,
+ updateTaskLabel,
+ updateTaskStatus,
+ updateTaskPriority,
+ updateTaskEndDate,
+ updateTaskEstimation,
+ updateTaskName,
+ updateTaskPhase,
+ updateTaskStartDate,
+ updateTaskDescription,
+ updateSubTasks,
+ updateTaskProgress,
+} from '@/features/tasks/tasks.slice';
+import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
+import {
+ setStartDate,
+ setTaskAssignee,
+ setTaskEndDate,
+ setTaskLabels,
+ setTaskPriority,
+ setTaskStatus,
+ setTaskSubscribers,
+} from '@/features/task-drawer/task-drawer.slice';
+import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
+
+export const useTaskSocketHandlers = () => {
+ const dispatch = useAppDispatch();
+ const { socket } = useSocket();
+ const currentSession = useAuthService().getCurrentSession();
+
+ const { loadingAssignees, taskGroups } = useAppSelector((state: any) => state.taskReducer);
+ const { projectId } = useAppSelector((state: any) => state.projectReducer);
+
+ // Memoize socket event handlers
+ const handleAssigneesUpdate = useCallback(
+ (data: ITaskAssigneesUpdateResponse) => {
+ if (!data) return;
+
+ const updatedAssignees = data.assignees?.map(assignee => ({
+ ...assignee,
+ selected: true,
+ })) || [];
+
+ const groupId = taskGroups?.find((group: ITaskListGroup) =>
+ group.tasks?.some(
+ (task: IProjectTask) =>
+ task.id === data.id ||
+ (task.sub_tasks && task.sub_tasks.some((subtask: IProjectTask) => subtask.id === data.id))
+ )
+ )?.id;
+
+ if (groupId) {
+ dispatch(
+ updateTaskAssignees({
+ groupId,
+ taskId: data.id,
+ assignees: updatedAssignees,
+ })
+ );
+
+ dispatch(
+ setTaskAssignee({
+ ...data,
+ manual_progress: false,
+ } as IProjectTask)
+ );
+
+ if (currentSession?.team_id && !loadingAssignees) {
+ dispatch(fetchTaskAssignees(currentSession.team_id));
+ }
+ }
+ },
+ [taskGroups, dispatch, currentSession?.team_id, loadingAssignees]
+ );
+
+ const handleLabelsChange = useCallback(
+ async (labels: ILabelsChangeResponse) => {
+ if (!labels) return;
+
+ await Promise.all([
+ dispatch(updateTaskLabel(labels)),
+ dispatch(setTaskLabels(labels)),
+ dispatch(fetchLabels()),
+ projectId && dispatch(fetchLabelsByProject(projectId)),
+ ]);
+ },
+ [dispatch, projectId]
+ );
+
+ const handleTaskStatusChange = useCallback(
+ (response: ITaskListStatusChangeResponse) => {
+ if (!response) return;
+
+ if (response.completed_deps === false) {
+ alertService.error(
+ 'Task is not completed',
+ 'Please complete the task dependencies before proceeding'
+ );
+ return;
+ }
+
+ dispatch(updateTaskStatus(response));
+ dispatch(deselectAll());
+ },
+ [dispatch]
+ );
+
+ const handleTaskProgress = useCallback(
+ (data: {
+ id: string;
+ status: string;
+ complete_ratio: number;
+ completed_count: number;
+ total_tasks_count: number;
+ parent_task: string;
+ }) => {
+ if (!data) return;
+
+ dispatch(
+ updateTaskProgress({
+ taskId: data.parent_task || data.id,
+ progress: data.complete_ratio,
+ totalTasksCount: data.total_tasks_count,
+ completedCount: data.completed_count,
+ })
+ );
+ },
+ [dispatch]
+ );
+
+ const handlePriorityChange = useCallback(
+ (response: ITaskListPriorityChangeResponse) => {
+ if (!response) return;
+
+ dispatch(updateTaskPriority(response));
+ dispatch(setTaskPriority(response));
+ dispatch(deselectAll());
+ },
+ [dispatch]
+ );
+
+ const handleEndDateChange = useCallback(
+ (task: {
+ id: string;
+ parent_task: string | null;
+ end_date: string;
+ }) => {
+ if (!task) return;
+
+ const taskWithProgress = {
+ ...task,
+ manual_progress: false,
+ } as IProjectTask;
+
+ dispatch(updateTaskEndDate({ task: taskWithProgress }));
+ dispatch(setTaskEndDate(taskWithProgress));
+ },
+ [dispatch]
+ );
+
+ const handleTaskNameChange = useCallback(
+ (data: { id: string; parent_task: string; name: string }) => {
+ if (!data) return;
+ dispatch(updateTaskName(data));
+ },
+ [dispatch]
+ );
+
+ const handlePhaseChange = useCallback(
+ (data: ITaskPhaseChangeResponse) => {
+ if (!data) return;
+ dispatch(updateTaskPhase(data));
+ dispatch(deselectAll());
+ },
+ [dispatch]
+ );
+
+ const handleStartDateChange = useCallback(
+ (task: {
+ id: string;
+ parent_task: string | null;
+ start_date: string;
+ }) => {
+ if (!task) return;
+
+ const taskWithProgress = {
+ ...task,
+ manual_progress: false,
+ } as IProjectTask;
+
+ dispatch(updateTaskStartDate({ task: taskWithProgress }));
+ dispatch(setStartDate(taskWithProgress));
+ },
+ [dispatch]
+ );
+
+ const handleTaskSubscribersChange = useCallback(
+ (data: InlineMember[]) => {
+ if (!data) return;
+ dispatch(setTaskSubscribers(data));
+ },
+ [dispatch]
+ );
+
+ const handleEstimationChange = useCallback(
+ (task: {
+ id: string;
+ parent_task: string | null;
+ estimation: number;
+ }) => {
+ if (!task) return;
+
+ const taskWithProgress = {
+ ...task,
+ manual_progress: false,
+ } as IProjectTask;
+
+ dispatch(updateTaskEstimation({ task: taskWithProgress }));
+ },
+ [dispatch]
+ );
+
+ const handleTaskDescriptionChange = useCallback(
+ (data: {
+ id: string;
+ parent_task: string;
+ description: string;
+ }) => {
+ if (!data) return;
+ dispatch(updateTaskDescription(data));
+ },
+ [dispatch]
+ );
+
+ const handleNewTaskReceived = useCallback(
+ (data: IProjectTask) => {
+ if (!data) return;
+ if (data.parent_task_id) {
+ dispatch(updateSubTasks(data));
+ }
+ },
+ [dispatch]
+ );
+
+ const handleTaskProgressUpdated = useCallback(
+ (data: {
+ task_id: string;
+ progress_value?: number;
+ weight?: number;
+ }) => {
+ if (!data || !taskGroups) return;
+
+ if (data.progress_value !== undefined) {
+ for (const group of taskGroups) {
+ const task = group.tasks?.find((task: IProjectTask) => task.id === data.task_id);
+ if (task) {
+ dispatch(
+ updateTaskProgress({
+ taskId: data.task_id,
+ progress: data.progress_value,
+ totalTasksCount: task.total_tasks_count || 0,
+ completedCount: task.completed_count || 0,
+ })
+ );
+ break;
+ }
+ }
+ }
+ },
+ [dispatch, taskGroups]
+ );
+
+ // Register socket event listeners
+ useEffect(() => {
+ if (!socket) return;
+
+ const eventHandlers = [
+ { event: SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handler: handleAssigneesUpdate },
+ { event: SocketEvents.TASK_LABELS_CHANGE.toString(), handler: handleLabelsChange },
+ { event: SocketEvents.TASK_STATUS_CHANGE.toString(), handler: handleTaskStatusChange },
+ { event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgress },
+ { event: SocketEvents.TASK_PRIORITY_CHANGE.toString(), handler: handlePriorityChange },
+ { event: SocketEvents.TASK_END_DATE_CHANGE.toString(), handler: handleEndDateChange },
+ { event: SocketEvents.TASK_NAME_CHANGE.toString(), handler: handleTaskNameChange },
+ { event: SocketEvents.TASK_PHASE_CHANGE.toString(), handler: handlePhaseChange },
+ { event: SocketEvents.TASK_START_DATE_CHANGE.toString(), handler: handleStartDateChange },
+ { event: SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handler: handleTaskSubscribersChange },
+ { event: SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handler: handleEstimationChange },
+ { event: SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handler: handleTaskDescriptionChange },
+ { event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived },
+ { event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated },
+ ];
+
+ // Register all event listeners
+ eventHandlers.forEach(({ event, handler }) => {
+ socket.on(event, handler);
+ });
+
+ // Cleanup function
+ return () => {
+ eventHandlers.forEach(({ event, handler }) => {
+ socket.off(event, handler);
+ });
+ };
+ }, [
+ socket,
+ handleAssigneesUpdate,
+ handleLabelsChange,
+ handleTaskStatusChange,
+ handleTaskProgress,
+ handlePriorityChange,
+ handleEndDateChange,
+ handleTaskNameChange,
+ handlePhaseChange,
+ handleStartDateChange,
+ handleTaskSubscribersChange,
+ handleEstimationChange,
+ handleTaskDescriptionChange,
+ handleNewTaskReceived,
+ handleTaskProgressUpdated,
+ ]);
+};
\ No newline at end of file
diff --git a/worklenz-frontend/src/index.css b/worklenz-frontend/src/index.css
index bb0a0781..3c1af53d 100644
--- a/worklenz-frontend/src/index.css
+++ b/worklenz-frontend/src/index.css
@@ -58,9 +58,9 @@ html.light body {
margin: 0;
padding: 0;
box-sizing: border-box;
- font-family: -apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial,
- "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
- "Noto Color Emoji" !important;
+ font-family:
+ -apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial, "Noto Sans",
+ sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !important;
}
/* helper classes */
@@ -145,3 +145,4 @@ Not supports in Firefox and IE */
tr:hover .action-buttons {
opacity: 1;
}
+
diff --git a/worklenz-frontend/src/layouts/MainLayout.tsx b/worklenz-frontend/src/layouts/MainLayout.tsx
index bbfd302b..83a4f4c4 100644
--- a/worklenz-frontend/src/layouts/MainLayout.tsx
+++ b/worklenz-frontend/src/layouts/MainLayout.tsx
@@ -7,7 +7,7 @@ import { colors } from '../styles/colors';
import { verifyAuthentication } from '@/features/auth/authSlice';
import { useEffect } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
-import TawkTo from '@/components/TawkTo';
+import HubSpot from '@/components/HubSpot';
const MainLayout = () => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
@@ -68,9 +68,6 @@ const MainLayout = () => {
- {import.meta.env.VITE_APP_ENV === 'production' && (
-
- )}
);
diff --git a/worklenz-frontend/src/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list.tsx b/worklenz-frontend/src/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list.tsx
index 27822e12..2102da02 100644
--- a/worklenz-frontend/src/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list.tsx
+++ b/worklenz-frontend/src/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list.tsx
@@ -132,7 +132,7 @@ const RecentAndFavouriteProjectList = () => {
{projectsData?.body?.length === 0 ? (
{
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
}, [dispatch]);
- const handleSelectTask = useCallback((task : IMyTask) => {
+ const handleSelectTask = useCallback((task: IMyTask) => {
dispatch(setSelectedTaskId(task.id || ''));
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
dispatch(setProjectId(task.project_id || ''));
@@ -155,7 +155,7 @@ const TasksList: React.FC = React.memo(() => {
render: (_, record) => {
return (
-
+
{record.project_name}
@@ -259,7 +259,7 @@ const TasksList: React.FC = React.memo(() => {
) : data?.body.total === 0 ? (
) : (
@@ -271,10 +271,10 @@ const TasksList: React.FC = React.memo(() => {
columns={columns as TableProps['columns']}
size="middle"
rowClassName={() => 'custom-row-height'}
- loading={homeTasksFetching && !skipAutoRefetch}
+ loading={homeTasksFetching && skipAutoRefetch}
pagination={false}
/>
-
+
{
{data?.body.length === 0 ? (
) : (
diff --git a/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx b/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx
index 8b1b862f..2d669f73 100644
--- a/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx
@@ -263,7 +263,7 @@ const ProjectViewMembers = () => {
>
{members?.total === 0 ? (
diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx
index 91c1d636..d3391109 100644
--- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { PushpinFilled, PushpinOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd';
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
@@ -43,6 +43,14 @@ const ProjectView = () => {
const [pinnedTab, setPinnedTab] = useState (searchParams.get('pinned_tab') || '');
const [taskid, setTaskId] = useState(searchParams.get('task') || '');
+ const resetProjectData = useCallback(() => {
+ dispatch(setProjectId(null));
+ dispatch(resetStatuses());
+ dispatch(deselectAll());
+ dispatch(resetTaskListData());
+ dispatch(resetBoardData());
+ }, [dispatch]);
+
useEffect(() => {
if (projectId) {
dispatch(setProjectId(projectId));
@@ -59,9 +67,13 @@ const ProjectView = () => {
dispatch(setSelectedTaskId(taskid || ''));
dispatch(setShowTaskDrawer(true));
}
- }, [dispatch, navigate, projectId, taskid]);
- const pinToDefaultTab = async (itemKey: string) => {
+ return () => {
+ resetProjectData();
+ };
+ }, [dispatch, navigate, projectId, taskid, resetProjectData]);
+
+ const pinToDefaultTab = useCallback(async (itemKey: string) => {
if (!itemKey || !projectId) return;
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
@@ -88,9 +100,9 @@ const ProjectView = () => {
}).toString(),
});
}
- };
+ }, [projectId, activeTab, navigate]);
- const handleTabChange = (key: string) => {
+ const handleTabChange = useCallback((key: string) => {
setActiveTab(key);
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
navigate({
@@ -100,9 +112,9 @@ const ProjectView = () => {
pinned_tab: pinnedTab,
}).toString(),
});
- };
+ }, [dispatch, location.pathname, navigate, pinnedTab]);
- const tabMenuItems = tabItems.map(item => ({
+ const tabMenuItems = useMemo(() => tabItems.map(item => ({
key: item.key,
label: (
@@ -144,21 +156,17 @@ const ProjectView = () => {
),
children: item.element,
- }));
+ })), [pinnedTab, pinToDefaultTab]);
- const resetProjectData = () => {
- dispatch(setProjectId(null));
- dispatch(resetStatuses());
- dispatch(deselectAll());
- dispatch(resetTaskListData());
- dispatch(resetBoardData());
- };
-
- useEffect(() => {
- return () => {
- resetProjectData();
- };
- }, []);
+ const portalElements = useMemo(() => (
+ <>
+ {createPortal(, document.body, 'project-member-drawer')}
+ {createPortal(, document.body, 'phase-drawer')}
+ {createPortal(, document.body, 'status-drawer')}
+ {createPortal(, document.body, 'task-drawer')}
+ {createPortal(, document.body, 'delete-status-drawer')}
+ >
+ ), []);
return (
@@ -169,34 +177,12 @@ const ProjectView = () => {
onChange={handleTabChange}
items={tabMenuItems}
tabBarStyle={{ paddingInline: 0 }}
- destroyInactiveTabPane={true}
- // tabBarExtraContent={
- //
- //
- //
- //
- //
- //
- //
- //
- //
- //
- // }
+ destroyOnHidden={true}
/>
- {createPortal( , document.body, 'project-member-drawer')}
- {createPortal( , document.body, 'phase-drawer')}
- {createPortal( , document.body, 'status-drawer')}
- {createPortal( , document.body, 'task-drawer')}
- {createPortal( , document.body, 'delete-status-drawer')}
+ {portalElements}
);
};
-export default ProjectView;
+export default React.memo(ProjectView);
diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx
new file mode 100644
index 00000000..e5800fe4
--- /dev/null
+++ b/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx
@@ -0,0 +1,241 @@
+import React, { useState, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useDroppable } from '@dnd-kit/core';
+import Flex from 'antd/es/flex';
+import Badge from 'antd/es/badge';
+import Button from 'antd/es/button';
+import Dropdown from 'antd/es/dropdown';
+import Input from 'antd/es/input';
+import Typography from 'antd/es/typography';
+import { MenuProps } from 'antd/es/menu';
+import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
+
+import { colors } from '@/styles/colors';
+import { useAppSelector } from '@/hooks/useAppSelector';
+import { useAppDispatch } from '@/hooks/useAppDispatch';
+import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
+import { ITaskListGroup } from '@/types/tasks/taskList.types';
+import Collapsible from '@/components/collapsible/collapsible';
+import TaskListTable from '../../task-list-table/task-list-table';
+import { IGroupBy, updateTaskGroupColor } from '@/features/tasks/tasks.slice';
+import { useAuthService } from '@/hooks/useAuth';
+import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
+import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
+import { ITaskPhase } from '@/types/tasks/taskPhase.types';
+import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
+import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
+import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
+import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
+import { ALPHA_CHANNEL } from '@/shared/constants';
+import useIsProjectManager from '@/hooks/useIsProjectManager';
+import logger from '@/utils/errorLogger';
+
+interface TaskGroupProps {
+ taskGroup: ITaskListGroup;
+ groupBy: string;
+ color: string;
+ activeId?: string | null;
+}
+
+const TaskGroup: React.FC = ({
+ taskGroup,
+ groupBy,
+ color,
+ activeId
+}) => {
+ const { t } = useTranslation('task-list-table');
+ const dispatch = useAppDispatch();
+ const { trackMixpanelEvent } = useMixpanelTracking();
+ const isProjectManager = useIsProjectManager();
+ const currentSession = useAuthService().getCurrentSession();
+
+ const [isExpanded, setIsExpanded] = useState(true);
+ const [isRenaming, setIsRenaming] = useState(false);
+ const [groupName, setGroupName] = useState(taskGroup.name || '');
+
+ const { projectId } = useAppSelector((state: any) => state.projectReducer);
+ const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
+
+ // Memoize droppable configuration
+ const { setNodeRef } = useDroppable({
+ id: taskGroup.id,
+ data: {
+ type: 'group',
+ groupId: taskGroup.id,
+ },
+ });
+
+ // Memoize task count
+ const taskCount = useMemo(() => taskGroup.tasks?.length || 0, [taskGroup.tasks]);
+
+ // Memoize dropdown items
+ const dropdownItems: MenuProps['items'] = useMemo(() => {
+ if (groupBy !== IGroupBy.STATUS || !isProjectManager) return [];
+
+ return [
+ {
+ key: 'rename',
+ label: t('renameText'),
+ icon: ,
+ onClick: () => setIsRenaming(true),
+ },
+ {
+ key: 'change-category',
+ label: t('changeCategoryText'),
+ icon: ,
+ children: [
+ {
+ key: 'todo',
+ label: t('todoText'),
+ onClick: () => handleStatusCategoryChange('0'),
+ },
+ {
+ key: 'doing',
+ label: t('doingText'),
+ onClick: () => handleStatusCategoryChange('1'),
+ },
+ {
+ key: 'done',
+ label: t('doneText'),
+ onClick: () => handleStatusCategoryChange('2'),
+ },
+ ],
+ },
+ ];
+ }, [groupBy, isProjectManager, t]);
+
+ const handleStatusCategoryChange = async (category: string) => {
+ if (!projectId || !taskGroup.id) return;
+
+ try {
+ await statusApiService.updateStatus({
+ id: taskGroup.id,
+ category_id: category,
+ project_id: projectId,
+ });
+
+ dispatch(fetchStatuses());
+ trackMixpanelEvent(evt_project_board_column_setting_click, {
+ column_id: taskGroup.id,
+ action: 'change_category',
+ category,
+ });
+ } catch (error) {
+ logger.error('Error updating status category:', error);
+ }
+ };
+
+ const handleRename = async () => {
+ if (!projectId || !taskGroup.id || !groupName.trim()) return;
+
+ try {
+ if (groupBy === IGroupBy.STATUS) {
+ await statusApiService.updateStatus({
+ id: taskGroup.id,
+ name: groupName.trim(),
+ project_id: projectId,
+ });
+ dispatch(fetchStatuses());
+ } else if (groupBy === IGroupBy.PHASE) {
+ const phaseData: ITaskPhase = {
+ id: taskGroup.id,
+ name: groupName.trim(),
+ project_id: projectId,
+ color_code: taskGroup.color_code,
+ };
+ await phasesApiService.updatePhase(phaseData);
+ dispatch(fetchPhasesByProjectId(projectId));
+ }
+
+ setIsRenaming(false);
+ } catch (error) {
+ logger.error('Error renaming group:', error);
+ }
+ };
+
+ const handleColorChange = async (newColor: string) => {
+ if (!projectId || !taskGroup.id) return;
+
+ try {
+ const baseColor = newColor.endsWith(ALPHA_CHANNEL)
+ ? newColor.slice(0, -ALPHA_CHANNEL.length)
+ : newColor;
+
+ if (groupBy === IGroupBy.PHASE) {
+ const phaseData: ITaskPhase = {
+ id: taskGroup.id,
+ name: taskGroup.name || '',
+ project_id: projectId,
+ color_code: baseColor,
+ };
+ await phasesApiService.updatePhase(phaseData);
+ dispatch(fetchPhasesByProjectId(projectId));
+ }
+
+ dispatch(updateTaskGroupColor({
+ groupId: taskGroup.id,
+ color: baseColor,
+ }));
+ } catch (error) {
+ logger.error('Error updating group color:', error);
+ }
+ };
+
+ return (
+
+
+ {/* Group Header */}
+
+ }
+ onClick={() => setIsExpanded(!isExpanded)}
+ >
+ {isRenaming ? (
+ setGroupName(e.target.value)}
+ onBlur={handleRename}
+ onPressEnter={handleRename}
+ onClick={e => e.stopPropagation()}
+ autoFocus
+ />
+ ) : (
+
+ {taskGroup.name} ({taskCount})
+
+ )}
+
+
+ {dropdownItems.length > 0 && !isRenaming && (
+
+ } className="borderless-icon-btn" />
+
+ )}
+
+
+ {/* Task List */}
+
+
+
+
+
+ );
+};
+
+export default React.memo(TaskGroup);
\ No newline at end of file
diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx
index fcd4931a..9b0524f5 100644
--- a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx
@@ -1,10 +1,10 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useMemo } from 'react';
import Flex from 'antd/es/flex';
import Skeleton from 'antd/es/skeleton';
import { useSearchParams } from 'react-router-dom';
import TaskListFilters from './task-list-filters/task-list-filters';
-import TaskGroupWrapper from './task-list-table/task-group-wrapper/task-group-wrapper';
+import TaskGroupWrapperOptimized from './task-group-wrapper-optimized';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice';
@@ -17,64 +17,99 @@ const ProjectViewTaskList = () => {
const dispatch = useAppDispatch();
const { projectView } = useTabSearchParam();
const [searchParams, setSearchParams] = useSearchParams();
- // Add local loading state to immediately show skeleton
- const [isLoading, setIsLoading] = useState(true);
+ const [initialLoadComplete, setInitialLoadComplete] = useState(false);
- const { projectId } = useAppSelector(state => state.projectReducer);
- const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector(
- state => state.taskReducer
- );
- const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
- state => state.taskStatusReducer
- );
- const { loadingPhases } = useAppSelector(state => state.phaseReducer);
- const { loadingColumns } = useAppSelector(state => state.taskReducer);
+ // Split selectors to prevent unnecessary rerenders
+ const projectId = useAppSelector(state => state.projectReducer.projectId);
+ const taskGroups = useAppSelector(state => state.taskReducer.taskGroups);
+ const loadingGroups = useAppSelector(state => state.taskReducer.loadingGroups);
+ const groupBy = useAppSelector(state => state.taskReducer.groupBy);
+ const archived = useAppSelector(state => state.taskReducer.archived);
+ const fields = useAppSelector(state => state.taskReducer.fields);
+ const search = useAppSelector(state => state.taskReducer.search);
+ const statusCategories = useAppSelector(state => state.taskStatusReducer.statusCategories);
+ const loadingStatusCategories = useAppSelector(state => state.taskStatusReducer.loading);
+
+ const loadingPhases = useAppSelector(state => state.phaseReducer.loadingPhases);
+
+ // Single source of truth for loading state - EXCLUDE labels loading from skeleton
+ // Labels loading should not block the main task list display
+ const isLoading = useMemo(() =>
+ loadingGroups || loadingPhases || loadingStatusCategories || !initialLoadComplete,
+ [loadingGroups, loadingPhases, loadingStatusCategories, initialLoadComplete]
+ );
+
+ // Memoize the empty state check
+ const isEmptyState = useMemo(() =>
+ taskGroups && taskGroups.length === 0 && !isLoading,
+ [taskGroups, isLoading]
+ );
+
+ // Handle view type changes
useEffect(() => {
- // Set default view to list if projectView is not list or board
if (projectView !== 'list' && projectView !== 'board') {
- searchParams.set('tab', 'tasks-list');
- searchParams.set('pinned_tab', 'tasks-list');
- setSearchParams(searchParams);
+ const newParams = new URLSearchParams(searchParams);
+ newParams.set('tab', 'tasks-list');
+ newParams.set('pinned_tab', 'tasks-list');
+ setSearchParams(newParams);
}
- }, [projectView, searchParams, setSearchParams]);
+ }, [projectView, setSearchParams, searchParams]);
+ // Batch initial data fetching - core data only
useEffect(() => {
- // Set loading state based on all loading conditions
- setIsLoading(loadingGroups || loadingColumns || loadingPhases || loadingStatusCategories);
- }, [loadingGroups, loadingColumns, loadingPhases, loadingStatusCategories]);
+ const fetchInitialData = async () => {
+ if (!projectId || !groupBy || initialLoadComplete) return;
- useEffect(() => {
- const loadData = async () => {
- if (projectId && groupBy) {
- const promises = [];
-
- if (!loadingColumns) promises.push(dispatch(fetchTaskListColumns(projectId)));
- if (!loadingPhases) promises.push(dispatch(fetchPhasesByProjectId(projectId)));
- if (!loadingGroups && projectView === 'list') {
- promises.push(dispatch(fetchTaskGroups(projectId)));
- }
- if (!statusCategories.length) {
- promises.push(dispatch(fetchStatusesCategories()));
- }
-
- // Wait for all data to load
- await Promise.all(promises);
+ try {
+ // Batch only essential API calls for initial load
+ // Filter data (labels, assignees, etc.) will load separately and not block the UI
+ await Promise.allSettled([
+ dispatch(fetchTaskListColumns(projectId)),
+ dispatch(fetchPhasesByProjectId(projectId)),
+ dispatch(fetchStatusesCategories()),
+ ]);
+ setInitialLoadComplete(true);
+ } catch (error) {
+ console.error('Error fetching initial data:', error);
+ setInitialLoadComplete(true); // Still mark as complete to prevent infinite loading
}
};
-
- loadData();
- }, [dispatch, projectId, groupBy, fields, search, archived]);
+
+ fetchInitialData();
+ }, [projectId, groupBy, dispatch, initialLoadComplete]);
+
+ // Fetch task groups with dependency on initial load completion
+ useEffect(() => {
+ const fetchTasks = async () => {
+ if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return;
+
+ try {
+ await dispatch(fetchTaskGroups(projectId));
+ } catch (error) {
+ console.error('Error fetching task groups:', error);
+ }
+ };
+
+ fetchTasks();
+ }, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]);
+
+ // Memoize the task groups to prevent unnecessary re-renders
+ const memoizedTaskGroups = useMemo(() => taskGroups || [], [taskGroups]);
return (
+ {/* Filters load independently and don't block the main content */}
- {(taskGroups && taskGroups.length === 0 && !isLoading) ? (
+ {isEmptyState ? (
) : (
-
+
)}
diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx
new file mode 100644
index 00000000..71257305
--- /dev/null
+++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx
@@ -0,0 +1,112 @@
+import React, { useEffect, useMemo } from 'react';
+import { createPortal } from 'react-dom';
+import Flex from 'antd/es/flex';
+import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
+
+import {
+ DndContext,
+ pointerWithin,
+} from '@dnd-kit/core';
+
+import { ITaskListGroup } from '@/types/tasks/taskList.types';
+import { useAppSelector } from '@/hooks/useAppSelector';
+
+import TaskListTableWrapper from './task-list-table/task-list-table-wrapper/task-list-table-wrapper';
+import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
+import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
+
+import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
+import { useTaskDragAndDrop } from '@/hooks/useTaskDragAndDrop';
+
+interface TaskGroupWrapperOptimizedProps {
+ taskGroups: ITaskListGroup[];
+ groupBy: string;
+}
+
+const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOptimizedProps) => {
+ const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
+
+ // Use extracted hooks
+ useTaskSocketHandlers();
+ const {
+ activeId,
+ sensors,
+ handleDragStart,
+ handleDragEnd,
+ handleDragOver,
+ resetTaskRowStyles,
+ } = useTaskDragAndDrop({ taskGroups, groupBy });
+
+ // Memoize task groups with colors
+ const taskGroupsWithColors = useMemo(() =>
+ taskGroups?.map(taskGroup => ({
+ ...taskGroup,
+ displayColor: themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code,
+ })) || [],
+ [taskGroups, themeMode]
+ );
+
+ // Add drag styles
+ useEffect(() => {
+ const style = document.createElement('style');
+ style.textContent = `
+ .task-row[data-is-dragging="true"] {
+ opacity: 0.5 !important;
+ transform: rotate(5deg) !important;
+ z-index: 1000 !important;
+ position: relative !important;
+ }
+ .task-row {
+ transition: transform 0.2s ease, opacity 0.2s ease;
+ }
+ `;
+ document.head.appendChild(style);
+
+ return () => {
+ document.head.removeChild(style);
+ };
+ }, []);
+
+ // Handle animation cleanup after drag ends
+ useIsomorphicLayoutEffect(() => {
+ if (activeId === null) {
+ const timeoutId = setTimeout(resetTaskRowStyles, 50);
+ return () => clearTimeout(timeoutId);
+ }
+ }, [activeId, resetTaskRowStyles]);
+
+ return (
+
+
+ {taskGroupsWithColors.map(taskGroup => (
+
+ ))}
+
+ {createPortal(, document.body, 'bulk-action-container')}
+
+ {createPortal(
+ {}} />,
+ document.body,
+ 'task-template-drawer'
+ )}
+
+
+ );
+};
+
+export default React.memo(TaskGroupWrapperOptimized);
\ No newline at end of file
diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx
index c32153b8..fcf866f1 100644
--- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
+import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
import {
fetchLabelsByProject,
fetchTaskAssignees,
@@ -33,23 +34,49 @@ const TaskListFilters: React.FC = ({ position }) => {
const { projectView } = useTabSearchParam();
const priorities = useAppSelector(state => state.priorityReducer.priorities);
-
const projectId = useAppSelector(state => state.projectReducer.projectId);
const archived = useAppSelector(state => state.taskReducer.archived);
const handleShowArchivedChange = () => dispatch(toggleArchived());
+ // Load filter data asynchronously and non-blocking
+ // This runs independently of the main task list loading
useEffect(() => {
- const fetchInitialData = async () => {
- if (!priorities.length) await dispatch(fetchPriorities());
- if (projectId) {
- await dispatch(fetchLabelsByProject(projectId));
- await dispatch(fetchTaskAssignees(projectId));
+ const loadFilterData = async () => {
+ try {
+ // Load priorities first (usually cached/fast)
+ if (!priorities.length) {
+ dispatch(fetchPriorities());
+ }
+
+ // Load project-specific filter data in parallel, but don't await
+ // This allows the main task list to load while filters are still loading
+ if (projectId) {
+ // Fire and forget - these will update the UI when ready
+ dispatch(fetchLabelsByProject(projectId));
+ dispatch(fetchTaskAssignees(projectId));
+ }
+
+ // Load team members (usually needed for member filters)
+ dispatch(getTeamMembers({
+ index: 0,
+ size: 100,
+ field: null,
+ order: null,
+ search: null,
+ all: true
+ }));
+ } catch (error) {
+ console.error('Error loading filter data:', error);
+ // Don't throw - filter loading errors shouldn't break the main UI
}
- dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
};
- fetchInitialData();
+ // Use setTimeout to ensure this runs after the main component render
+ // This prevents filter loading from blocking the initial render
+ const timeoutId = setTimeout(loadFilterData, 0);
+
+ return () => clearTimeout(timeoutId);
}, [dispatch, priorities.length, projectId]);
return (
diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx
index f619f20a..6a0e9374 100644
--- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx
@@ -2,7 +2,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import Flex from 'antd/es/flex';
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
@@ -87,16 +87,22 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees);
const { projectId } = useAppSelector(state => state.projectReducer);
- const sensors = useSensors(
- useSensor(PointerSensor, {
+ // Move useSensors to top level and memoize its configuration
+ const sensorConfig = useMemo(
+ () => ({
activationConstraint: { distance: 8 },
- })
+ }),
+ []
);
+ const pointerSensor = useSensor(PointerSensor, sensorConfig);
+ const sensors = useSensors(pointerSensor);
+
useEffect(() => {
setGroups(taskGroups);
}, [taskGroups]);
+ // Memoize resetTaskRowStyles to prevent unnecessary re-renders
const resetTaskRowStyles = useCallback(() => {
document.querySelectorAll('.task-row').forEach(row => {
row.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
@@ -106,21 +112,18 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
});
}, []);
- // Socket handler for assignee updates
- useEffect(() => {
- if (!socket) return;
-
- const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
+ // Memoize socket event handlers
+ const handleAssigneesUpdate = useCallback(
+ (data: ITaskAssigneesUpdateResponse) => {
if (!data) return;
- const updatedAssignees = data.assignees.map(assignee => ({
+ const updatedAssignees = data.assignees?.map(assignee => ({
...assignee,
selected: true,
- }));
+ })) || [];
- // Find the group that contains the task or its subtasks
- const groupId = groups.find(group =>
- group.tasks.some(
+ const groupId = groups?.find(group =>
+ group.tasks?.some(
task =>
task.id === data.id ||
(task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id))
@@ -136,47 +139,41 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
})
);
- dispatch(setTaskAssignee(data));
+ dispatch(
+ setTaskAssignee({
+ ...data,
+ manual_progress: false,
+ } as IProjectTask)
+ );
if (currentSession?.team_id && !loadingAssignees) {
dispatch(fetchTaskAssignees(currentSession.team_id));
}
}
- };
+ },
+ [groups, dispatch, currentSession?.team_id, loadingAssignees]
+ );
- socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
- return () => {
- socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
- };
- }, [socket, currentSession?.team_id, loadingAssignees, groups, dispatch]);
-
- // Socket handler for label updates
- useEffect(() => {
- if (!socket) return;
-
- const handleLabelsChange = async (labels: ILabelsChangeResponse) => {
+ // Memoize socket event handlers
+ const handleLabelsChange = useCallback(
+ async (labels: ILabelsChangeResponse) => {
+ if (!labels) return;
+
await Promise.all([
dispatch(updateTaskLabel(labels)),
dispatch(setTaskLabels(labels)),
dispatch(fetchLabels()),
projectId && dispatch(fetchLabelsByProject(projectId)),
]);
- };
+ },
+ [dispatch, projectId]
+ );
- socket.on(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
- socket.on(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
+ // Memoize socket event handlers
+ const handleTaskStatusChange = useCallback(
+ (response: ITaskListStatusChangeResponse) => {
+ if (!response) return;
- return () => {
- socket.off(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
- socket.off(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
- };
- }, [socket, dispatch, projectId]);
-
- // Socket handler for status updates
- useEffect(() => {
- if (!socket) return;
-
- const handleTaskStatusChange = (response: ITaskListStatusChangeResponse) => {
if (response.completed_deps === false) {
alertService.error(
'Task is not completed',
@@ -186,11 +183,14 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
}
dispatch(updateTaskStatus(response));
- // dispatch(setTaskStatus(response));
dispatch(deselectAll());
- };
+ },
+ [dispatch]
+ );
- const handleTaskProgress = (data: {
+ // Memoize socket event handlers
+ const handleTaskProgress = useCallback(
+ (data: {
id: string;
status: string;
complete_ratio: number;
@@ -198,6 +198,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
total_tasks_count: number;
parent_task: string;
}) => {
+ if (!data) return;
+
dispatch(
updateTaskProgress({
taskId: data.parent_task || data.id,
@@ -206,190 +208,150 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
completedCount: data.completed_count,
})
);
- };
+ },
+ [dispatch]
+ );
- socket.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
- socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
+ // Memoize socket event handlers
+ const handlePriorityChange = useCallback(
+ (response: ITaskListPriorityChangeResponse) => {
+ if (!response) return;
- return () => {
- socket.off(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
- socket.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
- };
- }, [socket, dispatch]);
-
- // Socket handler for priority updates
- useEffect(() => {
- if (!socket) return;
-
- const handlePriorityChange = (response: ITaskListPriorityChangeResponse) => {
dispatch(updateTaskPriority(response));
dispatch(setTaskPriority(response));
dispatch(deselectAll());
- };
+ },
+ [dispatch]
+ );
- socket.on(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
-
- return () => {
- socket.off(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
- };
- }, [socket, dispatch]);
-
- // Socket handler for due date updates
- useEffect(() => {
- if (!socket) return;
-
- const handleEndDateChange = (task: {
+ // Memoize socket event handlers
+ const handleEndDateChange = useCallback(
+ (task: {
id: string;
parent_task: string | null;
end_date: string;
}) => {
- dispatch(updateTaskEndDate({ task }));
- dispatch(setTaskEndDate(task));
- };
+ if (!task) return;
- socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
+ const taskWithProgress = {
+ ...task,
+ manual_progress: false,
+ } as IProjectTask;
- return () => {
- socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
- };
- }, [socket, dispatch]);
+ dispatch(updateTaskEndDate({ task: taskWithProgress }));
+ dispatch(setTaskEndDate(taskWithProgress));
+ },
+ [dispatch]
+ );
- // Socket handler for task name updates
- useEffect(() => {
- if (!socket) return;
+ // Memoize socket event handlers
+ const handleTaskNameChange = useCallback(
+ (data: { id: string; parent_task: string; name: string }) => {
+ if (!data) return;
- const handleTaskNameChange = (data: { id: string; parent_task: string; name: string }) => {
dispatch(updateTaskName(data));
- };
+ },
+ [dispatch]
+ );
- socket.on(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
+ // Memoize socket event handlers
+ const handlePhaseChange = useCallback(
+ (data: ITaskPhaseChangeResponse) => {
+ if (!data) return;
- return () => {
- socket.off(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
- };
- }, [socket, dispatch]);
-
- // Socket handler for phase updates
- useEffect(() => {
- if (!socket) return;
-
- const handlePhaseChange = (data: ITaskPhaseChangeResponse) => {
dispatch(updateTaskPhase(data));
dispatch(deselectAll());
- };
+ },
+ [dispatch]
+ );
- socket.on(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
-
- return () => {
- socket.off(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
- };
- }, [socket, dispatch]);
-
- // Socket handler for start date updates
- useEffect(() => {
- if (!socket) return;
-
- const handleStartDateChange = (task: {
+ // Memoize socket event handlers
+ const handleStartDateChange = useCallback(
+ (task: {
id: string;
parent_task: string | null;
start_date: string;
}) => {
- dispatch(updateTaskStartDate({ task }));
- dispatch(setStartDate(task));
- };
+ if (!task) return;
- socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
+ const taskWithProgress = {
+ ...task,
+ manual_progress: false,
+ } as IProjectTask;
- return () => {
- socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
- };
- }, [socket, dispatch]);
+ dispatch(updateTaskStartDate({ task: taskWithProgress }));
+ dispatch(setStartDate(taskWithProgress));
+ },
+ [dispatch]
+ );
- // Socket handler for task subscribers updates
- useEffect(() => {
- if (!socket) return;
+ // Memoize socket event handlers
+ const handleTaskSubscribersChange = useCallback(
+ (data: InlineMember[]) => {
+ if (!data) return;
- const handleTaskSubscribersChange = (data: InlineMember[]) => {
dispatch(setTaskSubscribers(data));
- };
+ },
+ [dispatch]
+ );
- socket.on(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
-
- return () => {
- socket.off(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
- };
- }, [socket, dispatch]);
-
- // Socket handler for task estimation updates
- useEffect(() => {
- if (!socket) return;
-
- const handleEstimationChange = (task: {
+ // Memoize socket event handlers
+ const handleEstimationChange = useCallback(
+ (task: {
id: string;
parent_task: string | null;
estimation: number;
}) => {
- dispatch(updateTaskEstimation({ task }));
- };
+ if (!task) return;
- socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
+ const taskWithProgress = {
+ ...task,
+ manual_progress: false,
+ } as IProjectTask;
- return () => {
- socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
- };
- }, [socket, dispatch]);
+ dispatch(updateTaskEstimation({ task: taskWithProgress }));
+ },
+ [dispatch]
+ );
- // Socket handler for task description updates
- useEffect(() => {
- if (!socket) return;
-
- const handleTaskDescriptionChange = (data: {
+ // Memoize socket event handlers
+ const handleTaskDescriptionChange = useCallback(
+ (data: {
id: string;
parent_task: string;
description: string;
}) => {
+ if (!data) return;
+
dispatch(updateTaskDescription(data));
- };
+ },
+ [dispatch]
+ );
- socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
-
- return () => {
- socket.off(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
- };
- }, [socket, dispatch]);
-
- // Socket handler for new task creation
- useEffect(() => {
- if (!socket) return;
-
- const handleNewTaskReceived = (data: IProjectTask) => {
+ // Memoize socket event handlers
+ const handleNewTaskReceived = useCallback(
+ (data: IProjectTask) => {
if (!data) return;
if (data.parent_task_id) {
dispatch(updateSubTasks(data));
}
- };
+ },
+ [dispatch]
+ );
- socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
-
- return () => {
- socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
- };
- }, [socket, dispatch]);
-
- // Socket handler for task progress updates
- useEffect(() => {
- if (!socket) return;
-
- const handleTaskProgressUpdated = (data: {
+ // Memoize socket event handlers
+ const handleTaskProgressUpdated = useCallback(
+ (data: {
task_id: string;
progress_value?: number;
weight?: number;
}) => {
+ if (!data || !taskGroups) return;
+
if (data.progress_value !== undefined) {
- // Find the task in the task groups and update its progress
for (const group of taskGroups) {
- const task = group.tasks.find(task => task.id === data.task_id);
+ const task = group.tasks?.find(task => task.id === data.task_id);
if (task) {
dispatch(
updateTaskProgress({
@@ -403,25 +365,76 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
}
}
}
+ },
+ [dispatch, taskGroups]
+ );
+
+ // Set up socket event listeners
+ useEffect(() => {
+ if (!socket) return;
+
+ const eventHandlers = {
+ [SocketEvents.QUICK_ASSIGNEES_UPDATE.toString()]: handleAssigneesUpdate,
+ [SocketEvents.TASK_LABELS_CHANGE.toString()]: handleLabelsChange,
+ [SocketEvents.CREATE_LABEL.toString()]: handleLabelsChange,
+ [SocketEvents.TASK_STATUS_CHANGE.toString()]: handleTaskStatusChange,
+ [SocketEvents.GET_TASK_PROGRESS.toString()]: handleTaskProgress,
+ [SocketEvents.TASK_PRIORITY_CHANGE.toString()]: handlePriorityChange,
+ [SocketEvents.TASK_END_DATE_CHANGE.toString()]: handleEndDateChange,
+ [SocketEvents.TASK_NAME_CHANGE.toString()]: handleTaskNameChange,
+ [SocketEvents.TASK_PHASE_CHANGE.toString()]: handlePhaseChange,
+ [SocketEvents.TASK_START_DATE_CHANGE.toString()]: handleStartDateChange,
+ [SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString()]: handleTaskSubscribersChange,
+ [SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString()]: handleEstimationChange,
+ [SocketEvents.TASK_DESCRIPTION_CHANGE.toString()]: handleTaskDescriptionChange,
+ [SocketEvents.QUICK_TASK.toString()]: handleNewTaskReceived,
+ [SocketEvents.TASK_PROGRESS_UPDATED.toString()]: handleTaskProgressUpdated,
};
- socket.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated);
+ // Register all event handlers
+ Object.entries(eventHandlers).forEach(([event, handler]) => {
+ if (handler) {
+ socket.on(event, handler);
+ }
+ });
+ // Cleanup function
return () => {
- socket.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated);
+ Object.entries(eventHandlers).forEach(([event, handler]) => {
+ if (handler) {
+ socket.off(event, handler);
+ }
+ });
};
- }, [socket, dispatch, taskGroups]);
+ }, [
+ socket,
+ handleAssigneesUpdate,
+ handleLabelsChange,
+ handleTaskStatusChange,
+ handleTaskProgress,
+ handlePriorityChange,
+ handleEndDateChange,
+ handleTaskNameChange,
+ handlePhaseChange,
+ handleStartDateChange,
+ handleTaskSubscribersChange,
+ handleEstimationChange,
+ handleTaskDescriptionChange,
+ handleNewTaskReceived,
+ handleTaskProgressUpdated,
+ ]);
+ // Memoize drag handlers
const handleDragStart = useCallback(({ active }: DragStartEvent) => {
setActiveId(active.id as string);
- // Add smooth transition to the dragged item
const draggedElement = document.querySelector(`[data-id="${active.id}"]`);
if (draggedElement) {
(draggedElement as HTMLElement).style.transition = 'transform 0.2s ease';
}
}, []);
+ // Memoize drag handlers
const handleDragEnd = useCallback(
async ({ active, over }: DragEndEvent) => {
setActiveId(null);
@@ -440,10 +453,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
if (fromIndex === -1) return;
- // Create a deep clone of the task to avoid reference issues
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
- // Check if task dependencies allow the move
if (activeGroupId !== overGroupId) {
const canContinue = await checkTaskDependencyStatus(task.id, overGroupId);
if (!canContinue) {
@@ -455,7 +466,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
return;
}
- // Update task properties based on target group
switch (groupBy) {
case IGroupBy.STATUS:
task.status = overGroupId;
@@ -468,35 +478,29 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
task.priority_color_dark = targetGroup.color_code_dark;
break;
case IGroupBy.PHASE:
- // Check if ALPHA_CHANNEL is already added
const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL)
- ? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) // Remove ALPHA_CHANNEL
- : targetGroup.color_code; // Use as is if not present
+ ? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length)
+ : targetGroup.color_code;
task.phase_id = overGroupId;
- task.phase_color = baseColor; // Set the cleaned color
+ task.phase_color = baseColor;
break;
}
}
const isTargetGroupEmpty = targetGroup.tasks.length === 0;
-
- // Calculate toIndex - for empty groups, always add at index 0
const toIndex = isTargetGroupEmpty
? 0
: overTaskId
? targetGroup.tasks.findIndex(t => t.id === overTaskId)
: targetGroup.tasks.length;
- // Calculate toPos similar to Angular implementation
const toPos = isTargetGroupEmpty
? -1
: targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
- // Update Redux state
if (activeGroupId === overGroupId) {
- // Same group - move within array
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(fromIndex, 1);
updatedTasks.splice(toIndex, 0, task);
@@ -514,7 +518,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
},
});
} else {
- // Different groups - transfer between arrays
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
const updatedTargetTasks = [...targetGroup.tasks];
@@ -540,7 +543,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
});
}
- // Emit socket event
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
project_id: projectId,
from_index: sourceGroup.tasks[fromIndex].sort_order,
@@ -549,13 +551,11 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
from_group: sourceGroup.id,
to_group: targetGroup.id,
group_by: groupBy,
- task: sourceGroup.tasks[fromIndex], // Send original task to maintain references
+ task: sourceGroup.tasks[fromIndex],
team_id: currentSession?.team_id,
});
- // Reset styles
setTimeout(resetTaskRowStyles, 0);
-
trackMixpanelEvent(evt_project_task_list_drag_and_move);
},
[
@@ -570,6 +570,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
]
);
+ // Memoize drag handlers
const handleDragOver = useCallback(
({ active, over }: DragEndEvent) => {
if (!over) return;
@@ -589,12 +590,9 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
if (fromIndex === -1 || toIndex === -1) return;
- // Create a deep clone of the task to avoid reference issues
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
- // Update Redux state
if (activeGroupId === overGroupId) {
- // Same group - move within array
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(fromIndex, 1);
updatedTasks.splice(toIndex, 0, task);
@@ -612,10 +610,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
},
});
} else {
- // Different groups - transfer between arrays
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
const updatedTargetTasks = [...targetGroup.tasks];
-
updatedTargetTasks.splice(toIndex, 0, task);
dispatch({
@@ -663,7 +659,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
// Handle animation cleanup after drag ends
useIsomorphicLayoutEffect(() => {
if (activeId === null) {
- // Final cleanup after React updates DOM
const timeoutId = setTimeout(resetTaskRowStyles, 50);
return () => clearTimeout(timeoutId);
}
diff --git a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx
index f4c1bf89..7283a40a 100644
--- a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx
+++ b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx
@@ -81,6 +81,22 @@ const MembersTimeSheet = forwardRef((_, ref) => {
display: false,
position: 'top' as const,
},
+ tooltip: {
+ callbacks: {
+ label: function(context: any) {
+ const idx = context.dataIndex;
+ const member = jsonData[idx];
+ const hours = member?.utilized_hours || '0.00';
+ const percent = member?.utilization_percent || '0.00';
+ const overUnder = member?.over_under_utilized_hours || '0.00';
+ return [
+ `${context.dataset.label}: ${hours} h`,
+ `Utilization: ${percent}%`,
+ `Over/Under Utilized: ${overUnder} h`
+ ];
+ }
+ }
+ }
},
backgroundColor: 'black',
indexAxis: 'y' as const,
diff --git a/worklenz-frontend/src/types/reporting/reporting.types.ts b/worklenz-frontend/src/types/reporting/reporting.types.ts
index 91ad7392..aa36069c 100644
--- a/worklenz-frontend/src/types/reporting/reporting.types.ts
+++ b/worklenz-frontend/src/types/reporting/reporting.types.ts
@@ -406,6 +406,9 @@ export interface IRPTTimeMember {
value?: number;
color_code: string;
logged_time?: string;
+ utilized_hours?: string;
+ utilization_percent?: string;
+ over_under_utilized_hours?: string;
}
export interface IMemberTaskStatGroupResonse {
diff --git a/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts
new file mode 100644
index 00000000..190b6e7f
--- /dev/null
+++ b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts
@@ -0,0 +1,37 @@
+export enum ITaskRecurring {
+ Daily = 'daily',
+ Weekly = 'weekly',
+ Monthly = 'monthly',
+ EveryXDays = 'every_x_days',
+ EveryXWeeks = 'every_x_weeks',
+ EveryXMonths = 'every_x_months'
+}
+
+export interface ITaskRecurringSchedule {
+ created_at?: string;
+ day_of_month?: number | null;
+ date_of_month?: number | null;
+ days_of_week?: number[] | null;
+ id?: string; // UUID v4
+ interval_days?: number | null;
+ interval_months?: number | null;
+ interval_weeks?: number | null;
+ schedule_type?: ITaskRecurring;
+ week_of_month?: number | null;
+}
+
+export interface IRepeatOption {
+ value?: ITaskRecurring
+ label?: string
+}
+
+export interface ITaskRecurringScheduleData {
+ task_id?: string,
+ id?: string,
+ schedule_type?: string
+}
+
+export interface IRepeatOption {
+ value?: ITaskRecurring
+ label?: string
+}
diff --git a/worklenz-frontend/src/types/tasks/task.types.ts b/worklenz-frontend/src/types/tasks/task.types.ts
index 9c5da9bf..d155490c 100644
--- a/worklenz-frontend/src/types/tasks/task.types.ts
+++ b/worklenz-frontend/src/types/tasks/task.types.ts
@@ -64,6 +64,7 @@ export interface ITaskViewModel extends ITask {
timer_start_time?: number;
recurring?: boolean;
task_level?: number;
+ schedule_id?: string | null;
}
export interface ITaskTeamMember extends ITeamMember {
| |