-
-
-
+
+
+
+
-
-
-
-
-
- 
-
- |
-
-
-
+
-
-
-
-
-
-
- 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 86abad6e..ba93ca2c 100644
--- a/worklenz-frontend/index.html
+++ b/worklenz-frontend/index.html
@@ -1,24 +1,64 @@
-
-
-
-
-
-
-
-
- Worklenz
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Worklenz
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json
index a16827cb..46d0126f 100644
--- a/worklenz-frontend/package-lock.json
+++ b/worklenz-frontend/package-lock.json
@@ -23,12 +23,12 @@
"@tanstack/react-virtual": "^3.11.2",
"@tinymce/tinymce-react": "^5.1.1",
"antd": "^5.24.9",
- "axios": "^1.7.9",
+ "axios": "^1.9.0",
"chart.js": "^4.4.7",
"chartjs-plugin-datalabels": "^2.2.0",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
- "dompurify": "^3.2.4",
+ "dompurify": "^3.2.5",
"gantt-task-react": "^0.3.9",
"html2canvas": "^1.4.1",
"i18next": "^23.16.8",
@@ -2852,9 +2852,9 @@
}
},
"node_modules/antd": {
- "version": "5.25.3",
- "resolved": "https://registry.npmjs.org/antd/-/antd-5.25.3.tgz",
- "integrity": "sha512-tBBcAFRjmWM3sitxrL/FEbQL+MTQntYY5bGa5c1ZZZHXWCynkhS3Ch/gy25mGMUY1M/9Uw3pH029v/RGht1x3w==",
+ "version": "5.24.9",
+ "resolved": "https://registry.npmjs.org/antd/-/antd-5.24.9.tgz",
+ "integrity": "sha512-liB+Y/JwD5/KSKbK1Z1EVAbWcoWYvWJ1s97AbbT+mOdigpJQuWwH7kG8IXNEljI7onvj0DdD43TXhSRLUu9AMA==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^7.2.1",
diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json
index 123d6f81..9eaa43ff 100644
--- a/worklenz-frontend/package.json
+++ b/worklenz-frontend/package.json
@@ -27,12 +27,12 @@
"@tanstack/react-virtual": "^3.11.2",
"@tinymce/tinymce-react": "^5.1.1",
"antd": "^5.24.9",
- "axios": "^1.7.9",
+ "axios": "^1.9.0",
"chart.js": "^4.4.7",
"chartjs-plugin-datalabels": "^2.2.0",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
- "dompurify": "^3.2.4",
+ "dompurify": "^3.2.5",
"gantt-task-react": "^0.3.9",
"html2canvas": "^1.4.1",
"i18next": "^23.16.8",
diff --git a/worklenz-frontend/path/to/members-reports-drawer.tsx b/worklenz-frontend/path/to/members-reports-drawer.tsx
deleted file mode 100644
index b9671dc1..00000000
--- a/worklenz-frontend/path/to/members-reports-drawer.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import MembersReportsTimeLogsTab from './members-reports-time-logs-tab';
-
-type MembersReportsDrawerProps = {
- memberId: string | null;
- exportTimeLogs: () => void;
-};
-
-const MembersReportsDrawer = ({ memberId, exportTimeLogs }: MembersReportsDrawerProps) => {
- return (
-
-
- {selectedMember.name}
-
-
-
-
-
- } iconPosition="end">
- {t('exportButton')}
-
-
-
-
- )
- }
- >
- {selectedMember && }
- {selectedMember && }
- {selectedMember && }
-
- );
-};
-
-export default MembersReportsDrawer;
\ No newline at end of file
diff --git a/worklenz-frontend/path/to/members-reports-time-logs-tab.tsx b/worklenz-frontend/path/to/members-reports-time-logs-tab.tsx
deleted file mode 100644
index a86c66ba..00000000
--- a/worklenz-frontend/path/to/members-reports-time-logs-tab.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import React, { useState } from 'react';
-import { Flex, Skeleton } from 'antd';
-import { useTranslation } from 'react-i18next';
-import { useTimeLogs } from '../contexts/TimeLogsContext';
-import { BillableFilter } from './BillableFilter';
-import { TimeLogCard } from './TimeLogCard';
-import { EmptyListPlaceholder } from './EmptyListPlaceholder';
-import { TaskDrawer } from './TaskDrawer';
-import MembersReportsDrawer from './members-reports-drawer';
-
-const MembersReportsTimeLogsTab: React.FC = () => {
- const { t } = useTranslation();
- const { timeLogsData, billable, setBillable, exportTimeLogs, exporting } = useTimeLogs();
-
- return (
-
-
-
-
-
-
- {timeLogsData.length > 0 ? (
-
- {timeLogsData.map((logs, index) => (
-
- ))}
-
- ) : (
-
- )}
-
-
- {createPortal(, document.body)}
-
-
- );
-};
-
-export default MembersReportsTimeLogsTab;
\ No newline at end of file
diff --git a/worklenz-frontend/public/locales/en/admin-center/current-bill.json b/worklenz-frontend/public/locales/en/admin-center/current-bill.json
index e5846b79..a4f39319 100644
--- a/worklenz-frontend/public/locales/en/admin-center/current-bill.json
+++ b/worklenz-frontend/public/locales/en/admin-center/current-bill.json
@@ -109,5 +109,13 @@
"expiredDaysAgo": "{{days}} days ago",
"continueWith": "Continue with {{plan}}",
- "changeToPlan": "Change to {{plan}}"
+ "changeToPlan": "Change to {{plan}}",
+ "creditPlan": "Credit Plan",
+ "customPlan": "Custom Plan",
+ "planValidTill": "Your plan is valid till {{date}}",
+ "purchaseSeatsText": "To continue, you'll need to purchase additional seats.",
+ "currentSeatsText": "You currently have {{seats}} seats available.",
+ "selectSeatsText": "Please select the number of additional seats to purchase.",
+ "purchase": "Purchase",
+ "contactSales": "Contact sales"
}
diff --git a/worklenz-frontend/public/locales/en/admin-center/teams.json b/worklenz-frontend/public/locales/en/admin-center/teams.json
index e03f8515..bf829a87 100644
--- a/worklenz-frontend/public/locales/en/admin-center/teams.json
+++ b/worklenz-frontend/public/locales/en/admin-center/teams.json
@@ -29,5 +29,7 @@
"role": "Role",
"owner": "Owner",
"admin": "Admin",
- "member": "Member"
+ "member": "Member",
+ "cannotChangeOwnerRole": "Owner role cannot be changed",
+ "pendingInvitation": "Pending invitation"
}
diff --git a/worklenz-frontend/public/locales/en/project-drawer.json b/worklenz-frontend/public/locales/en/project-drawer.json
index d72138d6..c9d89238 100644
--- a/worklenz-frontend/public/locales/en/project-drawer.json
+++ b/worklenz-frontend/public/locales/en/project-drawer.json
@@ -38,5 +38,14 @@
"createClient": "Create client",
"searchInputPlaceholder": "Search by name or email",
"hoursPerDayValidationMessage": "Hours per day must be a number between 1 and 24",
- "noPermission": "No permission"
+ "workingDaysValidationMessage": "Working days must be a positive number",
+ "manDaysValidationMessage": "Man days must be a positive number",
+ "noPermission": "No permission",
+ "progressSettings": "Progress Settings",
+ "manualProgress": "Manual Progress",
+ "manualProgressTooltip": "Allow manual progress updates for tasks without subtasks",
+ "weightedProgress": "Weighted Progress",
+ "weightedProgressTooltip": "Calculate progress based on subtask weights",
+ "timeProgress": "Time-based Progress",
+ "timeProgressTooltip": "Calculate progress based on estimated time"
}
diff --git a/worklenz-frontend/public/locales/en/settings/appearance.json b/worklenz-frontend/public/locales/en/settings/appearance.json
new file mode 100644
index 00000000..76fb246f
--- /dev/null
+++ b/worklenz-frontend/public/locales/en/settings/appearance.json
@@ -0,0 +1,5 @@
+{
+ "title": "Appearance",
+ "darkMode": "Dark Mode",
+ "darkModeDescription": "Switch between light and dark mode to customize your viewing experience."
+}
\ No newline at end of file
diff --git a/worklenz-frontend/public/locales/en/settings/sidebar.json b/worklenz-frontend/public/locales/en/settings/sidebar.json
index 41bc3e0f..d0b64829 100644
--- a/worklenz-frontend/public/locales/en/settings/sidebar.json
+++ b/worklenz-frontend/public/locales/en/settings/sidebar.json
@@ -10,5 +10,6 @@
"team-members": "Team Members",
"teams": "Teams",
"change-password": "Change Password",
- "language-and-region": "Language and Region"
+ "language-and-region": "Language and Region",
+ "appearance": "Appearance"
}
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 003fa112..06575ee1 100644
--- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json
+++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json
@@ -22,7 +22,16 @@
"hide-start-date": "Hide Start Date",
"show-start-date": "Show Start Date",
"hours": "Hours",
- "minutes": "Minutes"
+ "minutes": "Minutes",
+ "progressValue": "Progress Value",
+ "progressValueTooltip": "Set the progress percentage (0-100%)",
+ "progressValueRequired": "Please enter a progress value",
+ "progressValueRange": "Progress must be between 0 and 100",
+ "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",
+ "recurring": "Recurring"
},
"labels": {
"labelInputPlaceholder": "Search or create",
@@ -74,5 +83,11 @@
},
"taskActivityLogTab": {
"title": "Activity Log"
+ },
+ "taskProgress": {
+ "markAsDoneTitle": "Mark Task as Done?",
+ "confirmMarkAsDone": "Yes, mark as done",
+ "cancelMarkAsDone": "No, keep current status",
+ "markAsDoneDescription": "You've set the progress to 100%. Would you like to update the task status to \"Done\"?"
}
}
diff --git a/worklenz-frontend/public/locales/es/admin-center/current-bill.json b/worklenz-frontend/public/locales/es/admin-center/current-bill.json
index 9278dfe1..5af54652 100644
--- a/worklenz-frontend/public/locales/es/admin-center/current-bill.json
+++ b/worklenz-frontend/public/locales/es/admin-center/current-bill.json
@@ -101,5 +101,13 @@
"expirestoday": "hoy",
"expirestomorrow": "mañana",
- "expiredDaysAgo": "hace {{days}} días"
+ "expiredDaysAgo": "hace {{days}} días",
+ "creditPlan": "Plan de Crédito",
+ "customPlan": "Plan Personalizado",
+ "planValidTill": "Su plan es válido hasta {{date}}",
+ "purchaseSeatsText": "Para continuar, deberá comprar asientos adicionales.",
+ "currentSeatsText": "Actualmente tiene {{seats}} asientos disponibles.",
+ "selectSeatsText": "Seleccione el número de asientos adicionales a comprar.",
+ "purchase": "Comprar",
+ "contactSales": "Contactar ventas"
}
diff --git a/worklenz-frontend/public/locales/es/admin-center/teams.json b/worklenz-frontend/public/locales/es/admin-center/teams.json
index 98e3b188..13453656 100644
--- a/worklenz-frontend/public/locales/es/admin-center/teams.json
+++ b/worklenz-frontend/public/locales/es/admin-center/teams.json
@@ -29,5 +29,7 @@
"role": "Rol",
"owner": "Propietario",
"admin": "Administrador",
- "member": "Miembro"
+ "member": "Miembro",
+ "cannotChangeOwnerRole": "El rol de Propietario no puede ser cambiado",
+ "pendingInvitation": "Invitación pendiente"
}
diff --git a/worklenz-frontend/public/locales/es/project-drawer.json b/worklenz-frontend/public/locales/es/project-drawer.json
index 2dc114cc..abe5a856 100644
--- a/worklenz-frontend/public/locales/es/project-drawer.json
+++ b/worklenz-frontend/public/locales/es/project-drawer.json
@@ -38,5 +38,14 @@
"createClient": "Crear cliente",
"searchInputPlaceholder": "Busca por nombre o email",
"hoursPerDayValidationMessage": "Las horas por día deben ser un número entre 1 y 24",
- "noPermission": "Sin permiso"
+ "workingDaysValidationMessage": "Los días de trabajo deben ser un número positivo",
+ "manDaysValidationMessage": "Los días hombre deben ser un número positivo",
+ "noPermission": "Sin permiso",
+ "progressSettings": "Configuración de Progreso",
+ "manualProgress": "Progreso Manual",
+ "manualProgressTooltip": "Permitir actualizaciones manuales de progreso para tareas sin subtareas",
+ "weightedProgress": "Progreso Ponderado",
+ "weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas",
+ "timeProgress": "Progreso Basado en Tiempo",
+ "timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado"
}
diff --git a/worklenz-frontend/public/locales/es/settings/appearance.json b/worklenz-frontend/public/locales/es/settings/appearance.json
new file mode 100644
index 00000000..a4c168a4
--- /dev/null
+++ b/worklenz-frontend/public/locales/es/settings/appearance.json
@@ -0,0 +1,5 @@
+{
+ "title": "Apariencia",
+ "darkMode": "Modo Oscuro",
+ "darkModeDescription": "Cambia entre el modo claro y oscuro para personalizar tu experiencia visual."
+}
\ No newline at end of file
diff --git a/worklenz-frontend/public/locales/es/settings/sidebar.json b/worklenz-frontend/public/locales/es/settings/sidebar.json
index 32d529ea..3793e77f 100644
--- a/worklenz-frontend/public/locales/es/settings/sidebar.json
+++ b/worklenz-frontend/public/locales/es/settings/sidebar.json
@@ -10,5 +10,6 @@
"team-members": "Miembros del equipo",
"teams": "Equipos",
"change-password": "Cambiar contraseña",
- "language-and-region": "Idioma y región"
+ "language-and-region": "Idioma y región",
+ "appearance": "Apariencia"
}
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 387968e9..c3980da8 100644
--- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json
+++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json
@@ -22,7 +22,16 @@
"hide-start-date": "Ocultar fecha de inicio",
"show-start-date": "Mostrar fecha de inicio",
"hours": "Horas",
- "minutes": "Minutos"
+ "minutes": "Minutos",
+ "progressValue": "Valor de Progreso",
+ "progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)",
+ "progressValueRequired": "Por favor, introduce un valor de progreso",
+ "progressValueRange": "El progreso debe estar entre 0 y 100",
+ "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",
+ "recurring": "Recurrente"
},
"labels": {
"labelInputPlaceholder": "Buscar o crear",
@@ -74,5 +83,11 @@
},
"taskActivityLogTab": {
"title": "Registro de actividad"
+ },
+ "taskProgress": {
+ "markAsDoneTitle": "¿Marcar Tarea como Completada?",
+ "confirmMarkAsDone": "Sí, marcar como completada",
+ "cancelMarkAsDone": "No, mantener estado actual",
+ "markAsDoneDescription": "Has establecido el progreso al 100%. ¿Quieres actualizar el estado de la tarea a \"Completada\"?"
}
}
\ No newline at end of file
diff --git a/worklenz-frontend/public/locales/pt/admin-center/current-bill.json b/worklenz-frontend/public/locales/pt/admin-center/current-bill.json
index c4bc4126..063fc9c8 100644
--- a/worklenz-frontend/public/locales/pt/admin-center/current-bill.json
+++ b/worklenz-frontend/public/locales/pt/admin-center/current-bill.json
@@ -101,5 +101,13 @@
"expirestoday": "hoje",
"expirestomorrow": "amanhã",
- "expiredDaysAgo": "há {{days}} dias"
+ "expiredDaysAgo": "há {{days}} dias",
+ "creditPlan": "Plano de Crédito",
+ "customPlan": "Plano Personalizado",
+ "planValidTill": "Seu plano é válido até {{date}}",
+ "purchaseSeatsText": "Para continuar, você precisará comprar assentos adicionais.",
+ "currentSeatsText": "Atualmente você tem {{seats}} assentos disponíveis.",
+ "selectSeatsText": "Selecione o número de assentos adicionais para comprar.",
+ "purchase": "Comprar",
+ "contactSales": "Fale com vendas"
}
diff --git a/worklenz-frontend/public/locales/pt/admin-center/teams.json b/worklenz-frontend/public/locales/pt/admin-center/teams.json
index fea4c874..6a71b491 100644
--- a/worklenz-frontend/public/locales/pt/admin-center/teams.json
+++ b/worklenz-frontend/public/locales/pt/admin-center/teams.json
@@ -29,5 +29,7 @@
"role": "Rol",
"owner": "Propietario",
"admin": "Administrador",
- "member": "Miembro"
+ "member": "Miembro",
+ "cannotChangeOwnerRole": "A função de Proprietário não pode ser alterada",
+ "pendingInvitation": "Convite pendente"
}
diff --git a/worklenz-frontend/public/locales/pt/project-drawer.json b/worklenz-frontend/public/locales/pt/project-drawer.json
index 55022c4e..b7ff40be 100644
--- a/worklenz-frontend/public/locales/pt/project-drawer.json
+++ b/worklenz-frontend/public/locales/pt/project-drawer.json
@@ -38,5 +38,14 @@
"createClient": "Criar cliente",
"searchInputPlaceholder": "Pesquise por nome ou email",
"hoursPerDayValidationMessage": "As horas por dia devem ser um número entre 1 e 24",
- "noPermission": "Sem permissão"
+ "workingDaysValidationMessage": "Os dias de trabalho devem ser um número positivo",
+ "manDaysValidationMessage": "Os dias de homem devem ser um número positivo",
+ "noPermission": "Sem permissão",
+ "progressSettings": "Configurações de Progresso",
+ "manualProgress": "Progresso Manual",
+ "manualProgressTooltip": "Permitir atualizações manuais de progresso para tarefas sem subtarefas",
+ "weightedProgress": "Progresso Ponderado",
+ "weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas",
+ "timeProgress": "Progresso Baseado em Tempo",
+ "timeProgressTooltip": "Calcular o progresso com base no tempo estimado"
}
diff --git a/worklenz-frontend/public/locales/pt/settings/appearance.json b/worklenz-frontend/public/locales/pt/settings/appearance.json
new file mode 100644
index 00000000..eaffbb32
--- /dev/null
+++ b/worklenz-frontend/public/locales/pt/settings/appearance.json
@@ -0,0 +1,5 @@
+{
+ "title": "Aparência",
+ "darkMode": "Modo Escuro",
+ "darkModeDescription": "Alterne entre o modo claro e escuro para personalizar sua experiência de visualização."
+}
\ No newline at end of file
diff --git a/worklenz-frontend/public/locales/pt/settings/sidebar.json b/worklenz-frontend/public/locales/pt/settings/sidebar.json
index b9047fae..67fac9dc 100644
--- a/worklenz-frontend/public/locales/pt/settings/sidebar.json
+++ b/worklenz-frontend/public/locales/pt/settings/sidebar.json
@@ -10,5 +10,6 @@
"team-members": "Membros da Equipe",
"teams": "Equipes",
"change-password": "Alterar Senha",
- "language-and-region": "Idioma e Região"
+ "language-and-region": "Idioma e Região",
+ "appearance": "Aparência"
}
\ No newline at end of file
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 d6e8fef6..6288af92 100644
--- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json
+++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json
@@ -22,7 +22,16 @@
"hide-start-date": "Ocultar data de início",
"show-start-date": "Mostrar data de início",
"hours": "Horas",
- "minutes": "Minutos"
+ "minutes": "Minutos",
+ "progressValue": "Valor de Progresso",
+ "progressValueTooltip": "Definir a porcentagem de progresso (0-100%)",
+ "progressValueRequired": "Por favor, insira um valor de progresso",
+ "progressValueRange": "O progresso deve estar entre 0 e 100",
+ "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",
+ "recurring": "Recorrente"
},
"labels": {
"labelInputPlaceholder": "Pesquisar ou criar",
@@ -74,5 +83,11 @@
},
"taskActivityLogTab": {
"title": "Registro de atividade"
+ },
+ "taskProgress": {
+ "markAsDoneTitle": "Marcar Tarefa como Concluída?",
+ "confirmMarkAsDone": "Sim, marcar como concluída",
+ "cancelMarkAsDone": "Não, manter status atual",
+ "markAsDoneDescription": "Você definiu o progresso como 100%. Deseja atualizar o status da tarefa para \"Concluída\"?"
}
}
\ No newline at end of file
diff --git a/worklenz-frontend/public/unregister-sw.js b/worklenz-frontend/public/unregister-sw.js
new file mode 100644
index 00000000..02c9bc86
--- /dev/null
+++ b/worklenz-frontend/public/unregister-sw.js
@@ -0,0 +1,23 @@
+if ('serviceWorker' in navigator) {
+ // Check if we've already attempted to unregister in this session
+ if (!sessionStorage.getItem('swUnregisterAttempted')) {
+ navigator.serviceWorker.getRegistrations().then(function(registrations) {
+ const ngswWorker = registrations.find(reg => reg.active?.scriptURL.includes('ngsw-worker'));
+
+ if (ngswWorker) {
+ // Mark that we've attempted to unregister
+ sessionStorage.setItem('swUnregisterAttempted', 'true');
+ // Unregister the ngsw-worker
+ ngswWorker.unregister().then(() => {
+ // Reload the page after unregistering
+ window.location.reload(true);
+ });
+ } else {
+ // If no ngsw-worker is found, unregister any other service workers
+ for(let registration of registrations) {
+ registration.unregister();
+ }
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx
index 13abc6f8..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,11 +36,17 @@ 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/admin-center/admin-center.api.service.ts b/worklenz-frontend/src/api/admin-center/admin-center.api.service.ts
index 857efb91..4d45b222 100644
--- a/worklenz-frontend/src/api/admin-center/admin-center.api.service.ts
+++ b/worklenz-frontend/src/api/admin-center/admin-center.api.service.ts
@@ -112,11 +112,11 @@ export const adminCenterApiService = {
async updateTeam(
team_id: string,
- team_members: IOrganizationUser[]
+ body: {name: string, teamMembers: IOrganizationUser[]}
): Promise> {
const response = await apiClient.put>(
`${rootUrl}/organization/team/${team_id}`,
- team_members
+ body
);
return response.data;
},
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.api.service.ts b/worklenz-frontend/src/api/projects/projects.api.service.ts
index a817e76e..0297dd22 100644
--- a/worklenz-frontend/src/api/projects/projects.api.service.ts
+++ b/worklenz-frontend/src/api/projects/projects.api.service.ts
@@ -10,6 +10,11 @@ import { IProjectManager } from '@/types/project/projectManager.types';
const rootUrl = `${API_BASE_URL}/projects`;
+interface UpdateProjectPayload {
+ id: string;
+ [key: string]: any;
+}
+
export const projectsApiService = {
getProjects: async (
index: number,
@@ -78,13 +83,11 @@ export const projectsApiService = {
return response.data;
},
- updateProject: async (
- id: string,
- project: IProjectViewModel
- ): Promise> => {
+ updateProject: async (payload: UpdateProjectPayload): Promise> => {
+ const { id, ...data } = payload;
const q = toQueryString({ current_project_id: id });
- const url = `${rootUrl}/${id}${q}`;
- const response = await apiClient.put>(`${url}`, project);
+ const url = `${API_BASE_URL}/projects/${id}${q}`;
+ const response = await apiClient.patch>(url, data);
return response.data;
},
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/app/routes/main-routes.tsx b/worklenz-frontend/src/app/routes/main-routes.tsx
index 52826bf3..8647fac2 100644
--- a/worklenz-frontend/src/app/routes/main-routes.tsx
+++ b/worklenz-frontend/src/app/routes/main-routes.tsx
@@ -34,6 +34,7 @@ const mainRoutes: RouteObject[] = [
path: '/worklenz',
element: ,
children: [
+ { index: true, element: },
{ path: 'home', element: },
{ path: 'projects', element: },
{
diff --git a/worklenz-frontend/src/assets/images/empty-box.webp b/worklenz-frontend/src/assets/images/empty-box.webp
new file mode 100644
index 00000000..a23c97bd
Binary files /dev/null and b/worklenz-frontend/src/assets/images/empty-box.webp differ
diff --git a/worklenz-frontend/src/assets/images/worklenz-dark-mode.png b/worklenz-frontend/src/assets/images/worklenz-dark-mode.png
new file mode 100644
index 00000000..3a22e238
Binary files /dev/null and b/worklenz-frontend/src/assets/images/worklenz-dark-mode.png differ
diff --git a/worklenz-frontend/src/assets/images/worklenz-light-mode.png b/worklenz-frontend/src/assets/images/worklenz-light-mode.png
new file mode 100644
index 00000000..cf0d3251
Binary files /dev/null and b/worklenz-frontend/src/assets/images/worklenz-light-mode.png differ
diff --git a/worklenz-frontend/src/components/AuthPageHeader.tsx b/worklenz-frontend/src/components/AuthPageHeader.tsx
index a94d5fa5..e48b8ce8 100644
--- a/worklenz-frontend/src/components/AuthPageHeader.tsx
+++ b/worklenz-frontend/src/components/AuthPageHeader.tsx
@@ -1,6 +1,6 @@
import { Flex, Typography } from 'antd';
-import logo from '../assets/images/logo.png';
-import logoDark from '@/assets/images/logo-dark-mode.png';
+import logo from '@/assets/images/worklenz-light-mode.png';
+import logoDark from '@/assets/images/worklenz-dark-mode.png';
import { useAppSelector } from '@/hooks/useAppSelector';
type AuthPageHeaderProp = {
diff --git a/worklenz-frontend/src/components/EmptyListPlaceholder.tsx b/worklenz-frontend/src/components/EmptyListPlaceholder.tsx
index 4953f202..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 = '/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/PreferenceSelector.tsx b/worklenz-frontend/src/components/PreferenceSelector.tsx
index 9bf3c324..b908c247 100644
--- a/worklenz-frontend/src/components/PreferenceSelector.tsx
+++ b/worklenz-frontend/src/components/PreferenceSelector.tsx
@@ -1,7 +1,7 @@
import { FloatButton, Space, Tooltip } from 'antd';
import { FormatPainterOutlined } from '@ant-design/icons';
-import LanguageSelector from '../features/i18n/language-selector';
-import ThemeSelector from '../features/theme/ThemeSelector';
+// import LanguageSelector from '../features/i18n/language-selector';
+// import ThemeSelector from '../features/theme/ThemeSelector';
const PreferenceSelector = () => {
return (
@@ -17,7 +17,7 @@ const PreferenceSelector = () => {
justifyContent: 'center',
}}
>
-
+ {/* */}
diff --git a/worklenz-frontend/src/components/admin-center/billing/current-plan-details/current-plan-details.tsx b/worklenz-frontend/src/components/admin-center/billing/current-plan-details/current-plan-details.tsx
index 42e1a449..8111bc56 100644
--- a/worklenz-frontend/src/components/admin-center/billing/current-plan-details/current-plan-details.tsx
+++ b/worklenz-frontend/src/components/admin-center/billing/current-plan-details/current-plan-details.tsx
@@ -246,7 +246,7 @@ const CurrentPlanDetails = () => {
const renderFreePlan = () => (
- Free Plan
+ {t('freePlan')}
-{' '}
{freePlanSettings?.team_member_limit === 0
@@ -309,16 +309,16 @@ const CurrentPlanDetails = () => {
const renderCreditSubscriptionInfo = () => {
return
- Credit Plan
+ {t('creditPlan','Credit Plan')}
- };
+ };
const renderCustomSubscriptionInfo = () => {
return
- Custom Plan
- Your plan is valid till {billingInfo?.valid_till_date}
+ {t('customPlan','Custom Plan')}
+ {t('planValidTill','Your plan is valid till {{date}}',{date: billingInfo?.valid_till_date})}
- };
+ };
return (
{
>
- To continue, you'll need to purchase additional seats.
+ {t('purchaseSeatsText','To continue, you\'ll need to purchase additional seats.')}
- You currently have {billingInfo?.total_seats} seats available.
+ {t('currentSeatsText','You currently have {{seats}} seats available.',{seats: billingInfo?.total_seats})}
- Please select the number of additional seats to purchase.
+ {t('selectSeatsText','Please select the number of additional seats to purchase.')}
@@ -416,14 +416,14 @@ const CurrentPlanDetails = () => {
borderRadius: '2px'
}}
>
- Purchase
+ {t('purchase','Purchase')}
) : (
)}
diff --git a/worklenz-frontend/src/components/admin-center/teams/settings-drawer/settings-drawer.tsx b/worklenz-frontend/src/components/admin-center/teams/settings-drawer/settings-drawer.tsx
index 8a1efc35..ed2d850f 100644
--- a/worklenz-frontend/src/components/admin-center/teams/settings-drawer/settings-drawer.tsx
+++ b/worklenz-frontend/src/components/admin-center/teams/settings-drawer/settings-drawer.tsx
@@ -10,22 +10,17 @@ import {
Table,
TableProps,
Typography,
+ Tooltip,
} from 'antd';
import React, { useState } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
-import { toggleSettingDrawer, updateTeam } from '@/features/teams/teamSlice';
-import { TeamsType } from '@/types/admin-center/team.types';
import './settings-drawer.css';
-import CustomAvatar from '@/components/CustomAvatar';
-import { teamsApiService } from '@/api/teams/teams.api.service';
import logger from '@/utils/errorLogger';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import {
IOrganizationTeam,
IOrganizationTeamMember,
} from '@/types/admin-center/admin-center.types';
-import Avatars from '@/components/avatars/avatars';
-import { AvatarNamesMap } from '@/shared/constants';
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
import { useTranslation } from 'react-i18next';
@@ -68,26 +63,30 @@ const SettingTeamDrawer: React.FC = ({
};
const handleFormSubmit = async (values: any) => {
- console.log(values);
- // const newTeam: TeamsType = {
- // teamId: teamId,
- // teamName: values.name,
- // membersCount: team?.membersCount || 1,
- // members: team?.members || ['Raveesha Dilanka'],
- // owner: values.name,
- // created: team?.created || new Date(),
- // isActive: false,
- // };
- // dispatch(updateTeam(newTeam));
- // dispatch(toggleSettingDrawer());
- // form.resetFields();
- // message.success('Team updated!');
+ try {
+ setUpdatingTeam(true);
+
+ const body = {
+ name: values.name,
+ teamMembers: teamData?.team_members || []
+ };
+
+ const response = await adminCenterApiService.updateTeam(teamId, body);
+
+ if (response.done) {
+ setIsSettingDrawerOpen(false);
+ }
+ } catch (error) {
+ logger.error('Error updating team', error);
+ } finally {
+ setUpdatingTeam(false);
+ }
};
const roleOptions = [
- { value: 'Admin', label: t('admin') },
- { value: 'Member', label: t('member') },
- { value: 'Owner', label: t('owner') },
+ { key: 'Admin', value: 'Admin', label: t('admin') },
+ { key: 'Member', value: 'Member', label: t('member') },
+ { key: 'Owner', value: 'Owner', label: t('owner'), disabled: true },
];
const columns: TableProps['columns'] = [
@@ -104,16 +103,57 @@ const SettingTeamDrawer: React.FC = ({
{
title: t('role'),
key: 'role',
- render: (_, record: IOrganizationTeamMember) => (
-
+ render: (_, record: IOrganizationTeamMember) => {
+ const handleRoleChange = (value: string) => {
+ if (value === 'Owner') {
+ return;
+ }
+
+ // Update the team member's role in teamData
+ if (teamData && teamData.team_members) {
+ const updatedMembers = teamData.team_members.map(member => {
+ if (member.id === record.id) {
+ return { ...member, role_name: value };
+ }
+ return member;
+ });
+
+ setTeamData({
+ ...teamData,
+ team_members: updatedMembers
+ });
+ }
+ };
+
+ const isDisabled = record.role_name === 'Owner' || record.pending_invitation;
+ const tooltipTitle = record.role_name === 'Owner'
+ ? t('cannotChangeOwnerRole')
+ : record.pending_invitation
+ ? t('pendingInvitation')
+ : '';
+
+ const selectComponent = (
- ),
+ );
+
+ return (
+
+ {isDisabled ? (
+
+ {selectComponent}
+
+ ) : (
+ selectComponent
+ )}
+
+ );
+ },
},
];
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/projects/project-drawer/project-drawer.tsx b/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx
index 35732ac3..7bae3717 100644
--- a/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx
+++ b/worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx
@@ -14,6 +14,7 @@ import {
Popconfirm,
Skeleton,
Space,
+ Switch,
Tooltip,
Typography,
} from 'antd';
@@ -46,7 +47,11 @@ import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
import logger from '@/utils/errorLogger';
-import { setProjectData, toggleProjectDrawer, setProjectId as setDrawerProjectId } from '@/features/project/project-drawer.slice';
+import {
+ setProjectData,
+ toggleProjectDrawer,
+ setProjectId as setDrawerProjectId,
+} from '@/features/project/project-drawer.slice';
import useIsProjectManager from '@/hooks/useIsProjectManager';
import { useAuthService } from '@/hooks/useAuth';
import { evt_projects_create } from '@/shared/worklenz-analytics-events';
@@ -60,7 +65,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(true);
const currentSession = useAuthService().getCurrentSession();
-
+
// State
const [editMode, setEditMode] = useState(false);
const [selectedProjectManager, setSelectedProjectManager] = useState(
@@ -96,6 +101,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
working_days: project?.working_days || 0,
man_days: project?.man_days || 0,
hours_per_day: project?.hours_per_day || 8,
+ use_manual_progress: project?.use_manual_progress || false,
+ use_weighted_progress: project?.use_weighted_progress || false,
+ use_time_progress: project?.use_time_progress || false,
}),
[project, projectStatuses, projectHealths]
);
@@ -155,6 +163,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
man_days: parseInt(values.man_days),
hours_per_day: parseInt(values.hours_per_day),
project_manager: selectedProjectManager,
+ use_manual_progress: values.use_manual_progress || false,
+ use_weighted_progress: values.use_weighted_progress || false,
+ use_time_progress: values.use_time_progress || false,
};
const action =
@@ -169,7 +180,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
dispatch(toggleProjectDrawer());
if (!editMode) {
trackMixpanelEvent(evt_projects_create);
- navigate(`/worklenz/projects/${response.data.body.id}?tab=tasks-list&pinned_tab=tasks-list`);
+ navigate(
+ `/worklenz/projects/${response.data.body.id}?tab=tasks-list&pinned_tab=tasks-list`
+ );
}
refetchProjects();
window.location.reload(); // Refresh the page
@@ -184,8 +197,17 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
logger.error('Error saving project', error);
}
};
- const calculateWorkingDays = (startDate: dayjs.Dayjs | null, endDate: dayjs.Dayjs | null): number => {
- if (!startDate || !endDate || !startDate.isValid() || !endDate.isValid() || startDate.isAfter(endDate)) {
+ const calculateWorkingDays = (
+ startDate: dayjs.Dayjs | null,
+ endDate: dayjs.Dayjs | null
+ ): number => {
+ if (
+ !startDate ||
+ !endDate ||
+ !startDate.isValid() ||
+ !endDate.isValid() ||
+ startDate.isAfter(endDate)
+ ) {
return 0;
}
@@ -213,7 +235,16 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
...project,
start_date: project.start_date ? dayjs(project.start_date) : null,
end_date: project.end_date ? dayjs(project.end_date) : null,
- working_days: form.getFieldValue('start_date') && form.getFieldValue('end_date') ? calculateWorkingDays(form.getFieldValue('start_date'), form.getFieldValue('end_date')) : project.working_days || 0,
+ working_days:
+ form.getFieldValue('start_date') && form.getFieldValue('end_date')
+ ? calculateWorkingDays(
+ form.getFieldValue('start_date'),
+ form.getFieldValue('end_date')
+ )
+ : project.working_days || 0,
+ use_manual_progress: project.use_manual_progress || false,
+ use_weighted_progress: project.use_weighted_progress || false,
+ use_time_progress: project.use_time_progress || false,
});
setSelectedProjectManager(project.project_manager || null);
setLoading(false);
@@ -235,7 +266,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
setLoading(true);
resetForm();
dispatch(setProjectData({} as IProjectViewModel));
- dispatch(setProjectId(null));
+ // dispatch(setProjectId(null));
dispatch(setDrawerProjectId(null));
dispatch(toggleProjectDrawer());
onClose();
@@ -284,6 +315,49 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
setIsFormValid(isValid);
};
+ // Progress calculation method handlers
+ const handleManualProgressChange = (checked: boolean) => {
+ if (checked) {
+ form.setFieldsValue({
+ use_manual_progress: true,
+ use_weighted_progress: false,
+ use_time_progress: false,
+ });
+ } else {
+ form.setFieldsValue({
+ use_manual_progress: false,
+ });
+ }
+ };
+
+ const handleWeightedProgressChange = (checked: boolean) => {
+ if (checked) {
+ form.setFieldsValue({
+ use_manual_progress: false,
+ use_weighted_progress: true,
+ use_time_progress: false,
+ });
+ } else {
+ form.setFieldsValue({
+ use_weighted_progress: false,
+ });
+ }
+ };
+
+ const handleTimeProgressChange = (checked: boolean) => {
+ if (checked) {
+ form.setFieldsValue({
+ use_manual_progress: false,
+ use_weighted_progress: false,
+ use_time_progress: true,
+ });
+ } else {
+ form.setFieldsValue({
+ use_time_progress: false,
+ });
+ }
+ };
+
return (
void }) => {
}
>
{!isEditable && (
-
+
)}
-
+
{
+ onChange={date => {
const endDate = form.getFieldValue('end_date');
if (date && endDate) {
const days = calculateWorkingDays(date, endDate);
@@ -411,14 +477,11 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
}}
/>
-
+
{
+ onChange={date => {
const startDate = form.getFieldValue('start_date');
if (startDate && date) {
const days = calculateWorkingDays(startDate, date);
@@ -429,22 +492,51 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
- {/* {
+ if (value === undefined || value >= 0) {
+ return Promise.resolve();
+ }
+ return Promise.reject(new Error(t('workingDaysValidationMessage', { min: 0 })));
+ },
+ },
+ ]}
+ >
+
+
+
+ {
+ if (value === undefined || value >= 0) {
+ return Promise.resolve();
+ }
+ return Promise.reject(new Error(t('manDaysValidationMessage', { min: 0 })));
+ },
+ },
+ ]}
>
{
+ const value = parseInt(e.target.value, 10);
+ if (value < 0) {
+ form.setFieldsValue({ man_days: 0 });
+ }
+ }}
/>
- */}
+
-
-
-
-
-
-
void }) => {
if (value === undefined || (value >= 0 && value <= 24)) {
return Promise.resolve();
}
- return Promise.reject(new Error(t('hoursPerDayValidationMessage', { min: 0, max: 24 })));
+ return Promise.reject(
+ new Error(t('hoursPerDayValidationMessage', { min: 0, max: 24 }))
+ );
},
},
]}
>
-
+ {
+ const value = parseInt(e.target.value, 10);
+ if (value < 0) {
+ form.setFieldsValue({ hours_per_day: 8 });
+ }
+ }}
+ />
+
+
+ {t('progressSettings')}
+
+
+ {t('manualProgress')}
+
+
+
+ }
+ valuePropName="checked"
+ >
+
+
+
+
+ {t('weightedProgress')}
+
+
+
+ }
+ valuePropName="checked"
+ >
+
+
+
+
+ {t('timeProgress')}
+
+
+
+ }
+ valuePropName="checked"
+ >
+
diff --git a/worklenz-frontend/src/components/task-drawer/shared/activity-log/task-drawer-activity-log.tsx b/worklenz-frontend/src/components/task-drawer/shared/activity-log/task-drawer-activity-log.tsx
index da198294..a36ed339 100644
--- a/worklenz-frontend/src/components/task-drawer/shared/activity-log/task-drawer-activity-log.tsx
+++ b/worklenz-frontend/src/components/task-drawer/shared/activity-log/task-drawer-activity-log.tsx
@@ -111,6 +111,32 @@ const TaskDrawerActivityLog = () => {
);
+
+ case IActivityLogAttributeTypes.PROGRESS:
+ return (
+
+
+ {activity.previous || '0'}%
+
+
+
+ {activity.current || '0'}%
+
+
+ );
+
+ case IActivityLogAttributeTypes.WEIGHT:
+ return (
+
+
+ Weight: {activity.previous || '100'}
+
+
+
+ Weight: {activity.current || '100'}
+
+
+ );
default:
return (
diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx
new file mode 100644
index 00000000..df1ce2ea
--- /dev/null
+++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx
@@ -0,0 +1,320 @@
+import { Form, InputNumber, Tooltip, Modal } from 'antd';
+import { useTranslation } from 'react-i18next';
+import { QuestionCircleOutlined } from '@ant-design/icons';
+import { useAppSelector } from '@/hooks/useAppSelector';
+import { ITaskViewModel } from '@/types/tasks/task.types';
+import Flex from 'antd/lib/flex';
+import { SocketEvents } from '@/shared/socket-events';
+import { useState, useEffect } from 'react';
+import { useSocket } from '@/socket/socketContext';
+import { useAuthService } from '@/hooks/useAuth';
+import logger from '@/utils/errorLogger';
+import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types';
+import { setTaskStatus } from '@/features/task-drawer/task-drawer.slice';
+import { useAppDispatch } from '@/hooks/useAppDispatch';
+import { updateBoardTaskStatus } from '@/features/board/board-slice';
+import { updateTaskProgress, updateTaskStatus } from '@/features/tasks/tasks.slice';
+import useTabSearchParam from '@/hooks/useTabSearchParam';
+
+interface TaskDrawerProgressProps {
+ task: ITaskViewModel;
+ form: any;
+}
+
+const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
+ const { t } = useTranslation('task-drawer/task-drawer');
+ const dispatch = useAppDispatch();
+ const { tab } = useTabSearchParam();
+
+ const { project } = useAppSelector(state => state.projectReducer);
+ const { socket, connected } = useSocket();
+ const [isCompletionModalVisible, setIsCompletionModalVisible] = useState(false);
+ const currentSession = useAuthService().getCurrentSession();
+
+ const isSubTask = !!task?.parent_task_id;
+ // Safe handling of sub_tasks_count which might be undefined in some cases
+ const hasSubTasks = (task?.sub_tasks_count || 0) > 0;
+
+ // HIGHEST PRIORITY CHECK: Never show progress inputs for parent tasks with subtasks
+ if (hasSubTasks) {
+ return null;
+ }
+
+ // Never show manual progress input for parent tasks (tasks with subtasks)
+ // Only show progress input for tasks without subtasks
+ const showManualProgressInput = !hasSubTasks;
+
+ // Only show weight input for subtasks in weighted progress mode
+ const showTaskWeightInput = project?.use_weighted_progress && isSubTask && !hasSubTasks;
+
+ useEffect(() => {
+ // Listen for progress updates from the server
+ const handleProgressUpdate = (data: any) => {
+ if (data.task_id === task.id) {
+ if (data.progress_value !== undefined) {
+ form.setFieldsValue({ progress_value: data.progress_value });
+ }
+ if (data.weight !== undefined) {
+ form.setFieldsValue({ weight: data.weight });
+ }
+
+ // Check if we should prompt the user to mark the task as done
+ if (data.should_prompt_for_done) {
+ setIsCompletionModalVisible(true);
+ }
+ }
+ };
+
+ socket?.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleProgressUpdate);
+
+ // When the component mounts, explicitly request the latest progress for this task
+ if (connected && task.id) {
+ socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
+ }
+
+ return () => {
+ socket?.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleProgressUpdate);
+ };
+ }, [socket, connected, task.id, form]);
+
+ // One last check before rendering
+ if (hasSubTasks) {
+ return null;
+ }
+
+ const handleProgressChange = (value: number | null) => {
+ if (connected && task.id && value !== null && !hasSubTasks) {
+ // Check if progress is set to 100% to show completion confirmation
+ if (value === 100) {
+ setIsCompletionModalVisible(true);
+ }
+
+ // Ensure parent_task_id is not undefined
+ const parent_task_id = task.parent_task_id || null;
+
+ socket?.emit(
+ SocketEvents.UPDATE_TASK_PROGRESS.toString(),
+ JSON.stringify({
+ task_id: task.id,
+ progress_value: value,
+ parent_task_id: parent_task_id,
+ })
+ );
+
+ socket?.once(SocketEvents.GET_TASK_PROGRESS.toString(), (data: any) => {
+ dispatch(
+ updateTaskProgress({
+ taskId: task.id,
+ progress: data.complete_ratio,
+ totalTasksCount: data.total_tasks_count,
+ completedCount: data.completed_count,
+ })
+ );
+ });
+
+ if (task.id) {
+ setTimeout(() => {
+ socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
+ }, 500);
+ }
+
+ // If this is a subtask, request the parent's progress to be updated in UI
+ if (parent_task_id) {
+ setTimeout(() => {
+ socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), parent_task_id);
+ }, 500);
+ }
+ }
+ };
+
+ const handleWeightChange = (value: number | null) => {
+ if (connected && task.id && value !== null && !hasSubTasks) {
+ // Ensure parent_task_id is not undefined
+ const parent_task_id = task.parent_task_id || null;
+
+ socket?.emit(
+ SocketEvents.UPDATE_TASK_WEIGHT.toString(),
+ JSON.stringify({
+ task_id: task.id,
+ weight: value,
+ parent_task_id: parent_task_id,
+ })
+ );
+
+ // If this is a subtask, request the parent's progress to be updated in UI
+ if (parent_task_id) {
+ setTimeout(() => {
+ socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), parent_task_id);
+ }, 100);
+ }
+ }
+ };
+
+ const handleMarkTaskAsComplete = () => {
+ // Close the modal
+ setIsCompletionModalVisible(false);
+
+ // Find a "Done" status for this project
+ if (connected && task.id) {
+ // Emit socket event to get "done" category statuses
+ socket?.emit(
+ SocketEvents.GET_DONE_STATUSES.toString(),
+ task.project_id,
+ (doneStatuses: any[]) => {
+ if (doneStatuses && doneStatuses.length > 0) {
+ // Use the first "done" status
+ const doneStatusId = doneStatuses[0].id;
+
+ // Emit socket event to update the task status
+ socket?.emit(
+ SocketEvents.TASK_STATUS_CHANGE.toString(),
+ JSON.stringify({
+ task_id: task.id,
+ status_id: doneStatusId,
+ project_id: task.project_id,
+ team_id: currentSession?.team_id,
+ parent_task: task.parent_task_id || null,
+ })
+ );
+ socket?.once(
+ SocketEvents.TASK_STATUS_CHANGE.toString(),
+ (data: ITaskListStatusChangeResponse) => {
+ dispatch(setTaskStatus(data));
+
+ if (tab === 'tasks-list') {
+ dispatch(updateTaskStatus(data));
+ }
+ if (tab === 'board') {
+ dispatch(updateBoardTaskStatus(data));
+ }
+ if (data.parent_task)
+ socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), data.parent_task);
+ }
+ );
+ } else {
+ logger.error(`No "done" statuses found for project ${task.project_id}`);
+ }
+ }
+ );
+ }
+ };
+
+ const percentFormatter = (value: number | undefined) => (value ? `${value}%` : '0%');
+ const percentParser = (value: string | undefined) => {
+ const parsed = parseInt(value?.replace('%', '') || '0', 10);
+ return isNaN(parsed) ? 0 : parsed;
+ };
+
+ if (!showManualProgressInput && !showTaskWeightInput) {
+ return null; // Don't show any progress inputs if not applicable
+ }
+
+ // Final safety check
+ if (hasSubTasks) {
+ return null;
+ }
+
+ return (
+ <>
+ {showTaskWeightInput && (
+
+ {t('taskInfoTab.details.taskWeight')}
+
+
+
+
+ }
+ rules={[
+ {
+ type: 'number',
+ min: 0,
+ max: 100,
+ message: t('taskInfoTab.details.taskWeightRange'),
+ },
+ ]}
+ >
+ {
+ let value = percentParser(e.target.value);
+ // Ensure value doesn't exceed 100
+ if (value > 100) {
+ value = 100;
+ form.setFieldsValue({ weight: 100 });
+ }
+ handleWeightChange(value);
+ }}
+ onChange={value => {
+ if (value !== null && value > 100) {
+ form.setFieldsValue({ weight: 100 });
+ handleWeightChange(100);
+ }
+ }}
+ />
+
+ )}
+ {showManualProgressInput && (
+
+ {t('taskInfoTab.details.progressValue')}
+
+
+
+
+ }
+ rules={[
+ {
+ type: 'number',
+ min: 0,
+ max: 100,
+ message: t('taskInfoTab.details.progressValueRange'),
+ },
+ ]}
+ >
+ {
+ let value = percentParser(e.target.value);
+ // Ensure value doesn't exceed 100
+ if (value > 100) {
+ value = 100;
+ form.setFieldsValue({ progress_value: 100 });
+ }
+ handleProgressChange(value);
+ }}
+ onChange={value => {
+ if (value !== null && value > 100) {
+ form.setFieldsValue({ progress_value: 100 });
+ handleProgressChange(100);
+ }
+ }}
+ />
+
+ )}
+
+ setIsCompletionModalVisible(false)}
+ okText={t('taskProgress.confirmMarkAsDone', 'Yes, mark as done')}
+ cancelText={t('taskProgress.cancelMarkAsDone', 'No, keep current status')}
+ >
+ {t('taskProgress.markAsDoneDescription', 'You\'ve set the progress to 100%. Would you like to update the task status to "Done"?')}
+
+ >
+ );
+};
+
+export default TaskDrawerProgress;
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/subtask-table.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx
index 79f3dae8..5d63c177 100644
--- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx
+++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/subtask-table.tsx
@@ -210,10 +210,11 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
icon={}
okText="Yes"
cancelText="No"
- onConfirm={() => handleDeleteSubTask(record.id)}
+ onPopupClick={(e) => e.stopPropagation()}
+ onConfirm={(e) => {handleDeleteSubTask(record.id)}}
>
- } size="small" />
+ } size="small" onClick={(e)=> e.stopPropagation()} />
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 1f824943..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
@@ -26,14 +26,59 @@ import TaskDrawerDueDate from './details/task-drawer-due-date/task-drawer-due-da
import TaskDrawerEstimation from './details/task-drawer-estimation/task-drawer-estimation';
import TaskDrawerPrioritySelector from './details/task-drawer-priority-selector/task-drawer-priority-selector';
import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billable';
+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;
}
+// Custom wrapper that enforces stricter rules for displaying progress input
+interface ConditionalProgressInputProps {
+ task: ITaskViewModel;
+ form: any; // Using any for the form as the exact type may be complex
+}
+
+const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps) => {
+ const { project } = useAppSelector(state => state.projectReducer);
+ const hasSubTasks = task?.sub_tasks_count > 0;
+ const isSubTask = !!task?.parent_task_id;
+
+ // 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 (
+
+ );
+ } else if (project?.use_manual_progress) {
+ // In manual mode, show progress input ONLY for tasks without subtasks
+ return (
+
+ );
+ } else if (project?.use_weighted_progress && isSubTask) {
+ // In weighted mode, show weight input for subtasks
+ return (
+
+ );
+ }
+
+ return null;
+};
+
const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => {
const { t } = useTranslation('task-drawer/task-drawer');
const [form] = Form.useForm();
+ const { project } = useAppSelector(state => state.projectReducer);
useEffect(() => {
if (!taskFormViewModel) {
@@ -53,6 +98,8 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
labels: task?.labels || [],
billable: task?.billable || false,
notify: [],
+ progress_value: task?.progress_value || null,
+ weight: task?.weight || null,
});
}, [taskFormViewModel, form]);
@@ -89,6 +136,8 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
hours: 0,
minutes: 0,
billable: false,
+ progress_value: null,
+ weight: null,
}}
onFinish={handleSubmit}
>
@@ -103,7 +152,13 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
-
+
@@ -114,6 +169,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
+ {taskFormViewModel?.task && (
+
+ )}
+
@@ -124,6 +183,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
+
+
+
+
diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-drawer-info-tab.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-drawer-info-tab.tsx
index c6f8acee..e382e052 100644
--- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-drawer-info-tab.tsx
+++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-drawer-info-tab.tsx
@@ -125,7 +125,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
}
- onClick={(e) => {
+ onClick={e => {
e.stopPropagation(); // Prevent click from bubbling up
fetchSubTasks();
}}
@@ -182,19 +182,15 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
label: {t('taskInfoTab.comments.title')},
style: panelStyle,
className: 'custom-task-drawer-info-collapse',
- children: (
-
- ),
+ children: ,
},
];
- // Filter out the 'subTasks' item if this task is a subtask
- const infoItems = taskFormViewModel?.task?.parent_task_id
- ? allInfoItems.filter(item => item.key !== 'subTasks')
- : allInfoItems;
+ // Filter out the 'subTasks' item if this task is more than level 2
+ const infoItems =
+ (taskFormViewModel?.task?.task_level ?? 0) >= 2
+ ? allInfoItems.filter(item => item.key !== 'subTasks')
+ : allInfoItems;
const fetchSubTasks = async () => {
if (!selectedTaskId || loadingSubTasks) return;
@@ -281,7 +277,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
defaultActiveKey={[
'details',
'description',
- ...(taskFormViewModel?.task?.parent_task_id ? [] : ['subTasks']),
+ 'subTasks',
'dependencies',
'attachments',
'comments',
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();
@@ -46,6 +46,7 @@ const TaskDrawerStatusDropdown = ({ statuses, task, teamId }: TaskDrawerStatusDr
SocketEvents.TASK_STATUS_CHANGE.toString(),
(data: ITaskListStatusChangeResponse) => {
dispatch(setTaskStatus(data));
+ socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
if (tab === 'tasks-list') {
dispatch(updateTaskStatus(data));
@@ -65,7 +66,6 @@ const TaskDrawerStatusDropdown = ({ statuses, task, teamId }: TaskDrawerStatusDr
);
}
}
-
};
const options = useMemo(
diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx
index c1cb25bf..bbec5479 100644
--- a/worklenz-frontend/src/components/task-drawer/task-drawer.tsx
+++ b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx
@@ -32,7 +32,7 @@ const TaskDrawer = () => {
const [refreshTimeLogTrigger, setRefreshTimeLogTrigger] = useState(0);
const { showTaskDrawer, timeLogEditing } = useAppSelector(state => state.taskDrawerReducer);
-
+ const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
const taskNameInputRef = useRef(null);
const isClosingManually = useRef(false);
@@ -47,20 +47,32 @@ const TaskDrawer = () => {
const dispatch = useAppDispatch();
- const handleOnClose = () => {
- // Set flag to indicate we're manually closing the drawer
- isClosingManually.current = true;
- setActiveTab('info');
-
- // Explicitly clear the task parameter from URL
- clearTaskFromUrl();
-
- // Update the Redux state
+ const resetTaskState = () => {
dispatch(setShowTaskDrawer(false));
dispatch(setSelectedTaskId(null));
dispatch(setTaskFormViewModel({}));
dispatch(setTaskSubscribers([]));
+ };
+ const handleOnClose = (
+ e?: React.MouseEvent | React.KeyboardEvent
+ ) => {
+ // Set flag to indicate we're manually closing the drawer
+ isClosingManually.current = true;
+ setActiveTab('info');
+ clearTaskFromUrl();
+
+ const isClickOutsideDrawer =
+ e?.target && (e.target as HTMLElement).classList.contains('ant-drawer-mask');
+
+ if (isClickOutsideDrawer || !taskFormViewModel?.task?.is_sub_task) {
+ resetTaskState();
+ } else {
+ dispatch(setSelectedTaskId(null));
+ dispatch(setTaskFormViewModel({}));
+ dispatch(setTaskSubscribers([]));
+ dispatch(setSelectedTaskId(taskFormViewModel?.task?.parent_task_id || null));
+ }
// Reset the flag after a short delay
setTimeout(() => {
isClosingManually.current = false;
@@ -176,8 +188,8 @@ const TaskDrawer = () => {
// Get conditional body style
const getBodyStyle = () => {
const baseStyle = {
- padding: '24px',
- overflow: 'auto'
+ padding: '24px',
+ overflow: 'auto',
};
if (activeTab === 'timeLog' && timeLogEditing.isEditing) {
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-logo.tsx b/worklenz-frontend/src/features/navbar/navbar-logo.tsx
index 273c3214..afa8b1c0 100644
--- a/worklenz-frontend/src/features/navbar/navbar-logo.tsx
+++ b/worklenz-frontend/src/features/navbar/navbar-logo.tsx
@@ -1,8 +1,8 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import logo from '@/assets/images/logo.png';
-import logoDark from '@/assets/images/logo-dark-mode.png';
+import logo from '@/assets/images/worklenz-light-mode.png';
+import logoDark from '@/assets/images/worklenz-dark-mode.png';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useSelector } from 'react-redux';
@@ -20,23 +20,6 @@ const NavbarLogo = () => {
alt={t('logoAlt')}
style={{ width: '100%', maxWidth: 140 }}
/>
-
- Beta
-
);
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/project/project.slice.ts b/worklenz-frontend/src/features/project/project.slice.ts
index 1c302f4c..b2799e15 100644
--- a/worklenz-frontend/src/features/project/project.slice.ts
+++ b/worklenz-frontend/src/features/project/project.slice.ts
@@ -55,10 +55,9 @@ const initialState: TaskListState = {
export const getProject = createAsyncThunk(
'project/getProject',
- async (projectId: string, { rejectWithValue, dispatch }) => {
+ async (projectId: string, { rejectWithValue }) => {
try {
const response = await projectsApiService.getProject(projectId);
- dispatch(setProject(response.body));
return response.body;
} catch (error) {
return rejectWithValue(error instanceof Error ? error.message : 'Failed to fetch project');
diff --git a/worklenz-frontend/src/features/projects/projects.slice.ts b/worklenz-frontend/src/features/projects/projects.slice.ts
new file mode 100644
index 00000000..afe28743
--- /dev/null
+++ b/worklenz-frontend/src/features/projects/projects.slice.ts
@@ -0,0 +1,42 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { projectsApiService } from '@/api/projects/projects.api.service';
+
+interface UpdateProjectPayload {
+ id: string;
+ [key: string]: any;
+}
+
+export const projectsSlice = createSlice({
+ name: 'projects',
+ initialState: {
+ loading: false,
+ error: null,
+ },
+ reducers: {
+ setLoading: (state, action: PayloadAction) => {
+ state.loading = action.payload;
+ },
+ setError: (state, action: PayloadAction) => {
+ state.error = action.payload;
+ },
+ },
+});
+
+// Export actions
+export const { setLoading, setError } = projectsSlice.actions;
+
+// Async thunks
+export const updateProject = (payload: UpdateProjectPayload) => async (dispatch: any) => {
+ try {
+ dispatch(setLoading(true));
+ const response = await projectsApiService.updateProject(payload);
+ dispatch(setLoading(false));
+ return response;
+ } catch (error) {
+ dispatch(setError((error as Error).message));
+ dispatch(setLoading(false));
+ throw error;
+ }
+};
+
+export default projectsSlice.reducer;
\ No newline at end of file
diff --git a/worklenz-frontend/src/features/reporting/membersReports/membersReportsSlice.ts b/worklenz-frontend/src/features/reporting/membersReports/membersReportsSlice.ts
index 157f8d7e..2dcd9fe1 100644
--- a/worklenz-frontend/src/features/reporting/membersReports/membersReportsSlice.ts
+++ b/worklenz-frontend/src/features/reporting/membersReports/membersReportsSlice.ts
@@ -107,6 +107,10 @@ const membersReportsSlice = createSlice({
setDateRange: (state, action) => {
state.dateRange = action.payload;
},
+ setPagination: (state, action) => {
+ state.index = action.payload.index;
+ state.pageSize = action.payload.pageSize;
+ },
},
extraReducers: builder => {
builder
@@ -139,5 +143,6 @@ export const {
setOrder,
setDuration,
setDateRange,
+ setPagination,
} = membersReportsSlice.actions;
export default membersReportsSlice.reducer;
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 dbc2f955..49c85e28 100644
--- a/worklenz-frontend/src/features/tasks/tasks.slice.ts
+++ b/worklenz-frontend/src/features/tasks/tasks.slice.ts
@@ -21,6 +21,8 @@ import { ITaskLabel, ITaskLabelFilter } from '@/types/tasks/taskLabel.types';
import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-response';
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',
@@ -192,6 +194,20 @@ export const fetchSubTasks = createAsyncThunk(
return [];
}
+ // Request subtask progress data when expanding the task
+ // This will trigger the socket to emit TASK_PROGRESS_UPDATED events for all subtasks
+ try {
+ // Get access to the socket from the state
+ const socket = (getState() as any).socketReducer?.socket;
+ if (socket?.connected) {
+ // Request subtask count and progress information
+ socket.emit(SocketEvents.GET_TASK_SUBTASKS_COUNT.toString(), taskId);
+ }
+ } catch (error) {
+ console.error('Error requesting subtask progress:', error);
+ // Non-critical error, continue with fetching subtasks
+ }
+
const selectedMembers = taskReducer.taskAssignees
.filter(member => member.selected)
.map(member => member.id)
@@ -572,14 +588,30 @@ const taskSlice = createSlice({
) => {
const { taskId, progress, totalTasksCount, completedCount } = action.payload;
- for (const group of state.taskGroups) {
- const task = group.tasks.find(task => task.id === taskId);
- if (task) {
- task.complete_ratio = progress;
- task.total_tasks_count = totalTasksCount;
- task.completed_count = completedCount;
- break;
+ // Helper function to find and update a task at any nesting level
+ const findAndUpdateTask = (tasks: IProjectTask[]) => {
+ for (const task of tasks) {
+ if (task.id === taskId) {
+ task.complete_ratio = progress;
+ task.progress_value = progress;
+ task.total_tasks_count = totalTasksCount;
+ task.completed_count = completedCount;
+ return true;
+ }
+
+ // Check subtasks if they exist
+ if (task.sub_tasks && task.sub_tasks.length > 0) {
+ const found = findAndUpdateTask(task.sub_tasks);
+ if (found) return true;
+ }
}
+ return false;
+ };
+
+ // Try to find and update the task in any task group
+ for (const group of state.taskGroups) {
+ const found = findAndUpdateTask(group.tasks);
+ if (found) break;
}
},
@@ -975,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 => {
@@ -1134,6 +1175,7 @@ export const {
updateSubTasks,
updateCustomColumnValue,
updateCustomColumnPinned,
+ updateRecurringChange
} = taskSlice.actions;
export default taskSlice.reducer;
diff --git a/worklenz-frontend/src/features/theme/ThemeSelector.tsx b/worklenz-frontend/src/features/theme/ThemeSelector.tsx
deleted file mode 100644
index ea26b286..00000000
--- a/worklenz-frontend/src/features/theme/ThemeSelector.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-// ThemeSelector.tsx
-import { Button } from 'antd';
-import React from 'react';
-import { useAppDispatch } from '@/hooks/useAppDispatch';
-import { useAppSelector } from '@/hooks/useAppSelector';
-import { toggleTheme } from './themeSlice';
-import { MoonOutlined, SunOutlined } from '@ant-design/icons';
-
-const ThemeSelector = () => {
- const themeMode = useAppSelector(state => state.themeReducer.mode);
- const dispatch = useAppDispatch();
-
- const handleDarkModeToggle = () => {
- dispatch(toggleTheme());
- };
-
- return (
- : }
- shape="circle"
- onClick={handleDarkModeToggle}
- className="transition-all duration-300" // Optional: add smooth transition
- />
- );
-};
-
-export default ThemeSelector;
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/lib/settings/settings-constants.ts b/worklenz-frontend/src/lib/settings/settings-constants.ts
index ad72be2b..7f9beb2a 100644
--- a/worklenz-frontend/src/lib/settings/settings-constants.ts
+++ b/worklenz-frontend/src/lib/settings/settings-constants.ts
@@ -11,6 +11,7 @@ import {
TeamOutlined,
UserOutlined,
UserSwitchOutlined,
+ BulbOutlined,
} from '@ant-design/icons';
import React, { ReactNode } from 'react';
import ProfileSettings from '../../pages/settings/profile/profile-settings';
@@ -25,6 +26,7 @@ import TeamMembersSettings from '@/pages/settings/team-members/team-members-sett
import TeamsSettings from '../../pages/settings/teams/teams-settings';
import ChangePassword from '@/pages/settings/change-password/change-password';
import LanguageAndRegionSettings from '@/pages/settings/language-and-region/language-and-region-settings';
+import AppearanceSettings from '@/pages/settings/appearance/appearance-settings';
// type of menu item in settings sidebar
type SettingMenuItems = {
@@ -52,6 +54,13 @@ export const settingsItems: SettingMenuItems[] = [
icon: React.createElement(NotificationOutlined),
element: React.createElement(NotificationsSettings),
},
+ {
+ key: 'appearance',
+ name: 'appearance',
+ endpoint: 'appearance',
+ icon: React.createElement(BulbOutlined),
+ element: React.createElement(AppearanceSettings),
+ },
{
key: 'change-password',
name: 'change-password',
@@ -99,14 +108,14 @@ export const settingsItems: SettingMenuItems[] = [
element: React.createElement(CategoriesSettings),
adminOnly: true,
},
- // {
- // key: 'project-templates',
- // name: 'project-templates',
- // endpoint: 'project-templates',
- // icon: React.createElement(FileZipOutlined),
- // element: React.createElement(ProjectTemplatesSettings),
- // adminOnly: true,
- // },
+ {
+ key: 'project-templates',
+ name: 'project-templates',
+ endpoint: 'project-templates',
+ icon: React.createElement(FileZipOutlined),
+ element: React.createElement(ProjectTemplatesSettings),
+ adminOnly: true,
+ },
{
key: 'task-templates',
name: 'task-templates',
diff --git a/worklenz-frontend/src/pages/account-setup/account-setup.tsx b/worklenz-frontend/src/pages/account-setup/account-setup.tsx
index 9e8bb20c..84496fe9 100644
--- a/worklenz-frontend/src/pages/account-setup/account-setup.tsx
+++ b/worklenz-frontend/src/pages/account-setup/account-setup.tsx
@@ -24,8 +24,8 @@ import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import { getUserSession, setSession } from '@/utils/session-helper';
import { validateEmail } from '@/utils/validateEmail';
import { sanitizeInput } from '@/utils/sanitizeInput';
-import logo from '@/assets/images/logo.png';
-import logoDark from '@/assets/images/logo-dark-mode.png';
+import logo from '@/assets/images/worklenz-light-mode.png';
+import logoDark from '@/assets/images/worklenz-dark-mode.png';
import './account-setup.css';
import { IAccountSetupRequest } from '@/types/project-templates/project-templates.types';
diff --git a/worklenz-frontend/src/pages/auth/login-page.tsx b/worklenz-frontend/src/pages/auth/login-page.tsx
index 097e4e65..7e16f1a5 100644
--- a/worklenz-frontend/src/pages/auth/login-page.tsx
+++ b/worklenz-frontend/src/pages/auth/login-page.tsx
@@ -77,6 +77,18 @@ const LoginPage: React.FC = () => {
};
useEffect(() => {
+ // Check and unregister ngsw-worker if present
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.getRegistrations().then(function(registrations) {
+ const ngswWorker = registrations.find(reg => reg.active?.scriptURL.includes('ngsw-worker'));
+ if (ngswWorker) {
+ ngswWorker.unregister().then(() => {
+ window.location.reload();
+ });
+ }
+ });
+ }
+
trackMixpanelEvent(evt_login_page_visit);
if (currentSession && !currentSession?.setup_completed) {
navigate('/worklenz/setup');
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/board/project-view-board.tsx b/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx
index 0ec238a8..2b1a7604 100644
--- a/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useRef } from 'react';
+import { useEffect, useState, useRef, useMemo } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
import { Flex, Skeleton } from 'antd';
@@ -35,7 +35,6 @@ import { evt_project_board_visit, evt_project_task_list_drag_and_move } from '@/
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import logger from '@/utils/errorLogger';
-import { tasksApiService } from '@/api/tasks/tasks.api.service';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
const ProjectViewBoard = () => {
@@ -45,7 +44,10 @@ const ProjectViewBoard = () => {
const authService = useAuthService();
const currentSession = authService.getCurrentSession();
const { trackMixpanelEvent } = useMixpanelTracking();
- const [ currentTaskIndex, setCurrentTaskIndex] = useState(-1);
+ const [currentTaskIndex, setCurrentTaskIndex] = useState(-1);
+ // Add local loading state to immediately show skeleton
+ const [isLoading, setIsLoading] = useState(true);
+
const { projectId } = useAppSelector(state => state.projectReducer);
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
@@ -56,14 +58,34 @@ const ProjectViewBoard = () => {
// Store the original source group ID when drag starts
const originalSourceGroupIdRef = useRef (null);
+ // Update loading state based on all loading conditions
useEffect(() => {
- if (projectId && groupBy && projectView === 'kanban') {
- if (!loadingGroups) {
- dispatch(fetchBoardTaskGroups(projectId));
+ setIsLoading(loadingGroups || loadingStatusCategories);
+ }, [loadingGroups, loadingStatusCategories]);
+
+ // Load data efficiently with async/await and Promise.all
+ useEffect(() => {
+ const loadData = async () => {
+ if (projectId && groupBy && projectView === 'kanban') {
+ const promises = [];
+
+ if (!loadingGroups) {
+ promises.push(dispatch(fetchBoardTaskGroups(projectId)));
+ }
+
+ if (!statusCategories.length) {
+ promises.push(dispatch(fetchStatusesCategories()));
+ }
+
+ // Wait for all data to load
+ await Promise.all(promises);
}
- }
+ };
+
+ loadData();
}, [dispatch, projectId, groupBy, projectView, search, archived]);
+ // Create sensors with memoization to prevent unnecessary re-renders
const sensors = useSensors(
useSensor(MouseSensor, {
// Require the mouse to move by 10 pixels before activating
@@ -394,18 +416,16 @@ const ProjectViewBoard = () => {
};
}, [socket]);
+ // Track analytics event on component mount
useEffect(() => {
trackMixpanelEvent(evt_project_board_visit);
- if (!statusCategories.length && projectId) {
- dispatch(fetchStatusesCategories());
- }
- }, [dispatch, projectId]);
+ }, []);
return (
-
+
{
{
key: 'overLoggedTime',
title: 'Over Logged Time',
- render: (record: IInsightTasks) => (
- {record.overlogged_time}
+ render: (_, record: IInsightTasks) => (
+ {record.overlogged_time_string}
),
},
];
diff --git a/worklenz-frontend/src/pages/projects/projectView/insights/project-view-insights.tsx b/worklenz-frontend/src/pages/projects/projectView/insights/project-view-insights.tsx
index d986220d..55d9df48 100644
--- a/worklenz-frontend/src/pages/projects/projectView/insights/project-view-insights.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/insights/project-view-insights.tsx
@@ -17,7 +17,7 @@ import {
import { format } from 'date-fns';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
-import logo from '@/assets/images/logo.png';
+import logo from '@/assets/images/worklenz-light-mode.png';
import { evt_project_insights_members_visit, evt_project_insights_overview_visit, evt_project_insights_tasks_visit } from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
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-header.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx
index 81ff318d..5b5d32ff 100644
--- a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx
@@ -22,7 +22,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth';
import { useSocket } from '@/socket/socketContext';
-import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp } from '@features/project/project.slice';
+import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp, getProject } from '@features/project/project.slice';
import { addTask, fetchTaskGroups, fetchTaskListColumns, IGroupBy } from '@features/tasks/tasks.slice';
import ProjectStatusIcon from '@/components/common/project-status-icon/project-status-icon';
import { formatDate } from '@/utils/timeUtils';
@@ -70,6 +70,7 @@ const ProjectViewHeader = () => {
const handleRefresh = () => {
if (!projectId) return;
+ dispatch(getProject(projectId));
switch (tab) {
case 'tasks-list':
dispatch(fetchTaskListColumns(projectId));
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 dbf0f242..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 } 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,48 +17,99 @@ const ProjectViewTaskList = () => {
const dispatch = useAppDispatch();
const { projectView } = useTabSearchParam();
const [searchParams, setSearchParams] = useSearchParams();
+ 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(() => {
- if (projectId && groupBy) {
- if (!loadingColumns) dispatch(fetchTaskListColumns(projectId));
- if (!loadingPhases) dispatch(fetchPhasesByProjectId(projectId));
- if (!loadingGroups && projectView === 'list') {
- dispatch(fetchTaskGroups(projectId));
+ const fetchInitialData = async () => {
+ if (!projectId || !groupBy || initialLoadComplete) return;
+
+ 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
}
- }
- if (!statusCategories.length) {
- dispatch(fetchStatusesCategories());
- }
- }, [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.length === 0 && !loadingGroups) ? (
+ {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 6038a388..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,187 +208,233 @@ 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]
+ );
+
+ // 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) {
+ for (const group of taskGroups) {
+ const task = group.tasks?.find(task => 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]
+ );
+
+ // 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.QUICK_TASK.toString(), handleNewTaskReceived);
+ // Register all event handlers
+ Object.entries(eventHandlers).forEach(([event, handler]) => {
+ if (handler) {
+ socket.on(event, handler);
+ }
+ });
+ // Cleanup function
return () => {
- socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
+ Object.entries(eventHandlers).forEach(([event, handler]) => {
+ if (handler) {
+ socket.off(event, handler);
+ }
+ });
};
- }, [socket, dispatch]);
+ }, [
+ 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);
@@ -405,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) {
@@ -420,7 +466,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
return;
}
- // Update task properties based on target group
switch (groupBy) {
case IGroupBy.STATUS:
task.status = overGroupId;
@@ -433,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);
@@ -479,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];
@@ -505,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,
@@ -514,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);
},
[
@@ -535,6 +570,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
]
);
+ // Memoize drag handlers
const handleDragOver = useCallback(
({ active, over }: DragEndEvent) => {
if (!over) return;
@@ -554,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);
@@ -577,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({
@@ -628,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/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx
index 4589e5aa..96d7b05b 100644
--- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx
@@ -1,20 +1,54 @@
+import React from 'react';
import { Progress, Tooltip } from 'antd';
import './task-list-progress-cell.css';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
+import { useAppSelector } from '@/hooks/useAppSelector';
type TaskListProgressCellProps = {
task: IProjectTask;
};
const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => {
- return task.is_sub_task ? null : (
-
+ const { project } = useAppSelector(state => state.projectReducer);
+ const isManualProgressEnabled = (task.project_use_manual_progress || task.project_use_weighted_progress || task.project_use_time_progress);;
+ const isSubtask = task.is_sub_task;
+ const hasManualProgress = task.manual_progress;
+
+ // Handle different cases:
+ // 1. For subtasks when manual progress is enabled, show the progress
+ // 2. For parent tasks, always show progress
+ // 3. For subtasks when manual progress is not enabled, don't show progress (null)
+
+ if (isSubtask && !isManualProgressEnabled) {
+ return null; // Don't show progress for subtasks when manual progress is disabled
+ }
+
+ // For parent tasks, show completion ratio with task count tooltip
+ if (!isSubtask) {
+ return (
+
+
+ );
+ }
+
+ // For subtasks with manual progress enabled, show the progress
+ return (
+
);
diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-task-cell/task-list-task-cell.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-task-cell/task-list-task-cell.tsx
index 4eec792f..8b41e9d3 100644
--- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-task-cell/task-list-task-cell.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-task-cell/task-list-task-cell.tsx
@@ -86,7 +86,7 @@ const TaskListTaskCell = ({
isSubTask: boolean,
subTasksCount: number
) => {
- if (subTasksCount > 0) {
+ if (subTasksCount > 0 && !isSubTask) {
return (
| |