diff --git a/worklenz-backend/src/cron_jobs/index.ts b/worklenz-backend/src/cron_jobs/index.ts index f13ec2e8..20bd4f62 100644 --- a/worklenz-backend/src/cron_jobs/index.ts +++ b/worklenz-backend/src/cron_jobs/index.ts @@ -1,11 +1,11 @@ import {startDailyDigestJob} from "./daily-digest-job"; import {startNotificationsJob} from "./notifications-job"; import {startProjectDigestJob} from "./project-digest-job"; -import { startRecurringTasksJob } from "./recurring-tasks"; +import {startRecurringTasksJob} from "./recurring-tasks"; export function startCronJobs() { startNotificationsJob(); startDailyDigestJob(); startProjectDigestJob(); - // startRecurringTasksJob(); + startRecurringTasksJob(); } diff --git a/worklenz-backend/src/cron_jobs/recurring-tasks.ts b/worklenz-backend/src/cron_jobs/recurring-tasks.ts index a9ae7847..16854c7e 100644 --- a/worklenz-backend/src/cron_jobs/recurring-tasks.ts +++ b/worklenz-backend/src/cron_jobs/recurring-tasks.ts @@ -7,7 +7,7 @@ import TasksController from "../controllers/tasks-controller"; // At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday. // const TIME = "0 11 */1 * 1-5"; -const TIME = "*/2 * * * *"; +const TIME = "*/2 * * * *"; // runs every 2 minutes - for testing purposes const TIME_FORMAT = "YYYY-MM-DD"; // const TIME = "0 0 * * *"; // Runs at midnight every day 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..f1d0301d --- /dev/null +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,33 @@ +{ + "recurring": "Recurring", + "recurringTaskConfiguration": "Recurring task configuration", + "repeats": "Repeats", + "weekly": "Weekly", + "everyXDays": "Every X Days", + "everyXWeeks": "Every X Weeks", + "everyXMonths": "Every X Months", + "monthly": "Monthly", + "selectDaysOfWeek": "Select Days of the Week", + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat", + "sun": "Sun", + "monthlyRepeatType": "Monthly repeat type", + "onSpecificDate": "On a specific date", + "onSpecificDay": "On a specific day", + "dateOfMonth": "Date of the month", + "weekOfMonth": "Week of the month", + "dayOfWeek": "Day of the week", + "first": "First", + "second": "Second", + "third": "Third", + "fourth": "Fourth", + "last": "Last", + "intervalDays": "Interval (days)", + "intervalWeeks": "Interval (weeks)", + "intervalMonths": "Interval (months)", + "saveChanges": "Save Changes" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json index e013b4f2..06575ee1 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json @@ -30,7 +30,8 @@ "taskWeight": "Task Weight", "taskWeightTooltip": "Set the weight of this subtask (percentage)", "taskWeightRequired": "Please enter a task weight", - "taskWeightRange": "Weight must be between 0 and 100" + "taskWeightRange": "Weight must be between 0 and 100", + "recurring": "Recurring" }, "labels": { "labelInputPlaceholder": "Search or create", diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json index 58c5715e..cdafd81c 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json @@ -15,7 +15,8 @@ "hide-start-date": "Ocultar fecha de inicio", "show-start-date": "Mostrar fecha de inicio", "hours": "Horas", - "minutes": "Minutos" + "minutes": "Minutos", + "recurring": "Recurrente" }, "description": { "title": "Descripción", diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..d9c711a5 --- /dev/null +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,33 @@ +{ + "recurring": "Recurrente", + "recurringTaskConfiguration": "Configuración de tarea recurrente", + "repeats": "Repeticiones", + "weekly": "Semanal", + "everyXDays": "Cada X días", + "everyXWeeks": "Cada X semanas", + "everyXMonths": "Cada X meses", + "monthly": "Mensual", + "selectDaysOfWeek": "Seleccionar días de la semana", + "mon": "Lun", + "tue": "Mar", + "wed": "Mié", + "thu": "Jue", + "fri": "Vie", + "sat": "Sáb", + "sun": "Dom", + "monthlyRepeatType": "Tipo de repetición mensual", + "onSpecificDate": "En una fecha específica", + "onSpecificDay": "En un día específico", + "dateOfMonth": "Fecha del mes", + "weekOfMonth": "Semana del mes", + "dayOfWeek": "Día de la semana", + "first": "Primero", + "second": "Segundo", + "third": "Tercero", + "fourth": "Cuarto", + "last": "Último", + "intervalDays": "Intervalo (días)", + "intervalWeeks": "Intervalo (semanas)", + "intervalMonths": "Intervalo (meses)", + "saveChanges": "Guardar cambios" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json index 8b3ef220..c3980da8 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json @@ -30,7 +30,8 @@ "taskWeight": "Peso de la Tarea", "taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)", "taskWeightRequired": "Por favor, introduce un peso para la tarea", - "taskWeightRange": "El peso debe estar entre 0 y 100" + "taskWeightRange": "El peso debe estar entre 0 y 100", + "recurring": "Recurrente" }, "labels": { "labelInputPlaceholder": "Buscar o crear", diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json index 48922a52..fde2215a 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json @@ -15,7 +15,8 @@ "hide-start-date": "Ocultar data de início", "show-start-date": "Mostrar data de início", "hours": "Horas", - "minutes": "Minutos" + "minutes": "Minutos", + "recurring": "Recorrente" }, "description": { "title": "Descrição", diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..5619884b --- /dev/null +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,33 @@ +{ + "recurring": "Recorrente", + "recurringTaskConfiguration": "Configuração de tarefa recorrente", + "repeats": "Repete", + "weekly": "Semanal", + "everyXDays": "A cada X dias", + "everyXWeeks": "A cada X semanas", + "everyXMonths": "A cada X meses", + "monthly": "Mensal", + "selectDaysOfWeek": "Selecionar dias da semana", + "mon": "Seg", + "tue": "Ter", + "wed": "Qua", + "thu": "Qui", + "fri": "Sex", + "sat": "Sáb", + "sun": "Dom", + "monthlyRepeatType": "Tipo de repetição mensal", + "onSpecificDate": "Em uma data específica", + "onSpecificDay": "Em um dia específico", + "dateOfMonth": "Data do mês", + "weekOfMonth": "Semana do mês", + "dayOfWeek": "Dia da semana", + "first": "Primeira", + "second": "Segunda", + "third": "Terceira", + "fourth": "Quarta", + "last": "Última", + "intervalDays": "Intervalo (dias)", + "intervalWeeks": "Intervalo (semanas)", + "intervalMonths": "Intervalo (meses)", + "saveChanges": "Salvar alterações" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json index 7a3933f2..6288af92 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json @@ -30,7 +30,8 @@ "taskWeight": "Peso da Tarefa", "taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)", "taskWeightRequired": "Por favor, insira um peso para a tarefa", - "taskWeightRange": "O peso deve estar entre 0 e 100" + "taskWeightRange": "O peso deve estar entre 0 e 100", + "recurring": "Recorrente" }, "labels": { "labelInputPlaceholder": "Pesquisar ou criar", diff --git a/worklenz-frontend/src/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/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..608fc321 --- /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,375 @@ +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 repeatOptions: IRepeatOption[] = [ + { label: 'Daily', value: ITaskRecurring.Daily }, + { label: 'Weekly', value: ITaskRecurring.Weekly }, + { label: 'Every X Days', value: ITaskRecurring.EveryXDays }, + { label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks }, + { label: 'Every X Months', value: ITaskRecurring.EveryXMonths }, + { label: 'Monthly', value: ITaskRecurring.Monthly }, +]; + +const daysOfWeek = [ + { label: 'Sunday', value: 0, checked: false }, + { label: 'Monday', value: 1, checked: false }, + { label: 'Tuesday', value: 2, checked: false }, + { label: 'Wednesday', value: 3, checked: false }, + { label: 'Thursday', value: 4, checked: false }, + { label: 'Friday', value: 5, checked: false }, + { label: 'Saturday', value: 6, checked: false } +]; + +const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1); +const weekOptions = [ + { label: 'First', value: 1 }, + { label: 'Second', value: 2 }, + { label: 'Third', value: 3 }, + { label: 'Fourth', value: 4 }, + { label: 'Last', value: 5 } +]; +const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value })); + +const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { + const { socket, connected } = useSocket(); + const dispatch = useAppDispatch(); + const { t } = useTranslation('task-drawer/task-drawer-recurring-config'); + + 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: string[]) => { + setSelectedDays(checkedValues as unknown as string[]); + }; + + 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) { + setShowConfig(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 (recurring) void getScheduleData(); + socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse); + }, [task]); + + return ( +
+ +
+ +   + {recurring && ( + +
+ + ({ + label: date.toString(), + value: date, + }))} + style={{ width: 120 }} + /> + + )} + {monthlyOption === 'day' && ( + <> + + + + + )} + + )} + + {repeatOption.value === ITaskRecurring.EveryXDays && ( + + value && setIntervalDays(value)} + /> + + )} + {repeatOption.value === ITaskRecurring.EveryXWeeks && ( + + value && setIntervalWeeks(value)} + /> + + )} + {repeatOption.value === ITaskRecurring.EveryXMonths && ( + + value && setIntervalMonths(value)} + /> + + )} + + + +
+ + } + overlayStyle={{ width: 510 }} + open={showConfig} + onOpenChange={configVisibleChange} + trigger="click" + > + +
+ )} +
+
+
+ ); +}; + +export default TaskDrawerRecurringConfig; diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx index f9792485..a2dcaef1 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx @@ -29,6 +29,7 @@ import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billa import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progress'; import { useAppSelector } from '@/hooks/useAppSelector'; import logger from '@/utils/errorLogger'; +import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/task-drawer-recurring-config'; interface TaskDetailsFormProps { taskFormViewModel?: ITaskFormViewModel | null; @@ -175,6 +176,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => + + + + diff --git a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts index 9654a2d0..74ba350c 100644 --- a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts +++ b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts @@ -105,6 +105,15 @@ const taskDrawerSlice = createSlice({ }>) => { state.timeLogEditing = action.payload; }, + setTaskRecurringSchedule: (state, action: PayloadAction<{ + schedule_id: string; + task_id: string; + }>) => { + const { schedule_id, task_id } = action.payload; + if (state.taskFormViewModel?.task && state.taskFormViewModel.task.id === task_id) { + state.taskFormViewModel.task.schedule_id = schedule_id; + } + }, }, extraReducers: builder => { builder.addCase(fetchTask.pending, state => { @@ -133,5 +142,6 @@ export const { setTaskLabels, setTaskSubscribers, setTimeLogEditing, + setTaskRecurringSchedule } = taskDrawerSlice.actions; export default taskDrawerSlice.reducer; diff --git a/worklenz-frontend/src/features/tasks/tasks.slice.ts b/worklenz-frontend/src/features/tasks/tasks.slice.ts index cd443dbf..49c85e28 100644 --- a/worklenz-frontend/src/features/tasks/tasks.slice.ts +++ b/worklenz-frontend/src/features/tasks/tasks.slice.ts @@ -22,6 +22,7 @@ import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-respon import { produce } from 'immer'; import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service'; import { SocketEvents } from '@/shared/socket-events'; +import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; export enum IGroupBy { STATUS = 'status', @@ -1006,6 +1007,15 @@ const taskSlice = createSlice({ column.pinned = isVisible; } }, + + updateRecurringChange: (state, action: PayloadAction) => { + const {id, schedule_type, task_id} = action.payload; + const taskInfo = findTaskInGroups(state.taskGroups, task_id as string); + if (!taskInfo) return; + + const { task } = taskInfo; + task.schedule_id = id; + } }, extraReducers: builder => { @@ -1165,6 +1175,7 @@ export const { updateSubTasks, updateCustomColumnValue, updateCustomColumnPinned, + updateRecurringChange } = taskSlice.actions; export default taskSlice.reducer; diff --git a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx index 9fe5c59c..a27d2ac0 100644 --- a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx +++ b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx @@ -147,7 +147,7 @@ const TodoList = () => {
{data?.body.length === 0 ? ( ) : ( diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index 91c1d636..d1ff8b9d 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 (
@@ -170,33 +178,11 @@ const ProjectView = () => { items={tabMenuItems} tabBarStyle={{ paddingInline: 0 }} destroyInactiveTabPane={true} - // tabBarExtraContent={ - //
- // - // - // - // - // - // - // - // - //
- // } /> - {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/project-view-task-list.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx index fcd4931a..410644fb 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,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import Flex from 'antd/es/flex'; import Skeleton from 'antd/es/skeleton'; import { useSearchParams } from 'react-router-dom'; @@ -17,8 +17,8 @@ const ProjectViewTaskList = () => { const dispatch = useAppDispatch(); const { projectView } = useTabSearchParam(); const [searchParams, setSearchParams] = useSearchParams(); - // Add local loading state to immediately show skeleton const [isLoading, setIsLoading] = useState(true); + const [initialLoadComplete, setInitialLoadComplete] = useState(false); const { projectId } = useAppSelector(state => state.projectReducer); const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector( @@ -30,47 +30,73 @@ const ProjectViewTaskList = () => { const { loadingPhases } = useAppSelector(state => state.phaseReducer); const { loadingColumns } = useAppSelector(state => state.taskReducer); + // Memoize the loading state calculation - ignoring task list filter loading + const isLoadingState = useMemo(() => + loadingGroups || loadingPhases || loadingStatusCategories, + [loadingGroups, loadingPhases, loadingStatusCategories] + ); + + // Memoize the empty state check + const isEmptyState = useMemo(() => + taskGroups && taskGroups.length === 0 && !isLoadingState, + [taskGroups, isLoadingState] + ); + + // 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]); + // Update loading state useEffect(() => { - // Set loading state based on all loading conditions - setIsLoading(loadingGroups || loadingColumns || loadingPhases || loadingStatusCategories); - }, [loadingGroups, loadingColumns, loadingPhases, loadingStatusCategories]); + setIsLoading(isLoadingState); + }, [isLoadingState]); + // Fetch initial data only once useEffect(() => { - const loadData = async () => { - if (projectId && groupBy) { - const promises = []; - - if (!loadingColumns) promises.push(dispatch(fetchTaskListColumns(projectId))); - if (!loadingPhases) promises.push(dispatch(fetchPhasesByProjectId(projectId))); - if (!loadingGroups && projectView === 'list') { - promises.push(dispatch(fetchTaskGroups(projectId))); - } - if (!statusCategories.length) { - promises.push(dispatch(fetchStatusesCategories())); - } - - // Wait for all data to load - await Promise.all(promises); + const fetchInitialData = async () => { + if (!projectId || !groupBy || initialLoadComplete) return; + + try { + await Promise.all([ + dispatch(fetchTaskListColumns(projectId)), + dispatch(fetchPhasesByProjectId(projectId)), + dispatch(fetchStatusesCategories()) + ]); + setInitialLoadComplete(true); + } catch (error) { + console.error('Error fetching initial data:', error); } }; - - loadData(); - }, [dispatch, projectId, groupBy, fields, search, archived]); + + fetchInitialData(); + }, [projectId, groupBy, dispatch, initialLoadComplete]); + + // Fetch task groups + 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]); return ( - {(taskGroups && taskGroups.length === 0 && !isLoading) ? ( + {isEmptyState ? ( ) : ( diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx index f619f20a..6a0e9374 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx @@ -2,7 +2,7 @@ import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useSocket } from '@/socket/socketContext'; import { useAuthService } from '@/hooks/useAuth'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import Flex from 'antd/es/flex'; import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect'; @@ -87,16 +87,22 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees); const { projectId } = useAppSelector(state => state.projectReducer); - const sensors = useSensors( - useSensor(PointerSensor, { + // Move useSensors to top level and memoize its configuration + const sensorConfig = useMemo( + () => ({ activationConstraint: { distance: 8 }, - }) + }), + [] ); + const pointerSensor = useSensor(PointerSensor, sensorConfig); + const sensors = useSensors(pointerSensor); + useEffect(() => { setGroups(taskGroups); }, [taskGroups]); + // Memoize resetTaskRowStyles to prevent unnecessary re-renders const resetTaskRowStyles = useCallback(() => { document.querySelectorAll('.task-row').forEach(row => { row.style.transition = 'transform 0.2s ease, opacity 0.2s ease'; @@ -106,21 +112,18 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }); }, []); - // Socket handler for assignee updates - useEffect(() => { - if (!socket) return; - - const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => { + // Memoize socket event handlers + const handleAssigneesUpdate = useCallback( + (data: ITaskAssigneesUpdateResponse) => { if (!data) return; - const updatedAssignees = data.assignees.map(assignee => ({ + const updatedAssignees = data.assignees?.map(assignee => ({ ...assignee, selected: true, - })); + })) || []; - // Find the group that contains the task or its subtasks - const groupId = groups.find(group => - group.tasks.some( + const groupId = groups?.find(group => + group.tasks?.some( task => task.id === data.id || (task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id)) @@ -136,47 +139,41 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }) ); - dispatch(setTaskAssignee(data)); + dispatch( + setTaskAssignee({ + ...data, + manual_progress: false, + } as IProjectTask) + ); if (currentSession?.team_id && !loadingAssignees) { dispatch(fetchTaskAssignees(currentSession.team_id)); } } - }; + }, + [groups, dispatch, currentSession?.team_id, loadingAssignees] + ); - socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate); - return () => { - socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate); - }; - }, [socket, currentSession?.team_id, loadingAssignees, groups, dispatch]); - - // Socket handler for label updates - useEffect(() => { - if (!socket) return; - - const handleLabelsChange = async (labels: ILabelsChangeResponse) => { + // Memoize socket event handlers + const handleLabelsChange = useCallback( + async (labels: ILabelsChangeResponse) => { + if (!labels) return; + await Promise.all([ dispatch(updateTaskLabel(labels)), dispatch(setTaskLabels(labels)), dispatch(fetchLabels()), projectId && dispatch(fetchLabelsByProject(projectId)), ]); - }; + }, + [dispatch, projectId] + ); - socket.on(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange); - socket.on(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange); + // Memoize socket event handlers + const handleTaskStatusChange = useCallback( + (response: ITaskListStatusChangeResponse) => { + if (!response) return; - return () => { - socket.off(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange); - socket.off(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange); - }; - }, [socket, dispatch, projectId]); - - // Socket handler for status updates - useEffect(() => { - if (!socket) return; - - const handleTaskStatusChange = (response: ITaskListStatusChangeResponse) => { if (response.completed_deps === false) { alertService.error( 'Task is not completed', @@ -186,11 +183,14 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { } dispatch(updateTaskStatus(response)); - // dispatch(setTaskStatus(response)); dispatch(deselectAll()); - }; + }, + [dispatch] + ); - const handleTaskProgress = (data: { + // Memoize socket event handlers + const handleTaskProgress = useCallback( + (data: { id: string; status: string; complete_ratio: number; @@ -198,6 +198,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { total_tasks_count: number; parent_task: string; }) => { + if (!data) return; + dispatch( updateTaskProgress({ taskId: data.parent_task || data.id, @@ -206,190 +208,150 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { completedCount: data.completed_count, }) ); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange); - socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress); + // Memoize socket event handlers + const handlePriorityChange = useCallback( + (response: ITaskListPriorityChangeResponse) => { + if (!response) return; - return () => { - socket.off(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange); - socket.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress); - }; - }, [socket, dispatch]); - - // Socket handler for priority updates - useEffect(() => { - if (!socket) return; - - const handlePriorityChange = (response: ITaskListPriorityChangeResponse) => { dispatch(updateTaskPriority(response)); dispatch(setTaskPriority(response)); dispatch(deselectAll()); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange); - - return () => { - socket.off(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange); - }; - }, [socket, dispatch]); - - // Socket handler for due date updates - useEffect(() => { - if (!socket) return; - - const handleEndDateChange = (task: { + // Memoize socket event handlers + const handleEndDateChange = useCallback( + (task: { id: string; parent_task: string | null; end_date: string; }) => { - dispatch(updateTaskEndDate({ task })); - dispatch(setTaskEndDate(task)); - }; + if (!task) return; - socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange); + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; - return () => { - socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange); - }; - }, [socket, dispatch]); + dispatch(updateTaskEndDate({ task: taskWithProgress })); + dispatch(setTaskEndDate(taskWithProgress)); + }, + [dispatch] + ); - // Socket handler for task name updates - useEffect(() => { - if (!socket) return; + // Memoize socket event handlers + const handleTaskNameChange = useCallback( + (data: { id: string; parent_task: string; name: string }) => { + if (!data) return; - const handleTaskNameChange = (data: { id: string; parent_task: string; name: string }) => { dispatch(updateTaskName(data)); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange); + // Memoize socket event handlers + const handlePhaseChange = useCallback( + (data: ITaskPhaseChangeResponse) => { + if (!data) return; - return () => { - socket.off(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange); - }; - }, [socket, dispatch]); - - // Socket handler for phase updates - useEffect(() => { - if (!socket) return; - - const handlePhaseChange = (data: ITaskPhaseChangeResponse) => { dispatch(updateTaskPhase(data)); dispatch(deselectAll()); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange); - - return () => { - socket.off(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange); - }; - }, [socket, dispatch]); - - // Socket handler for start date updates - useEffect(() => { - if (!socket) return; - - const handleStartDateChange = (task: { + // Memoize socket event handlers + const handleStartDateChange = useCallback( + (task: { id: string; parent_task: string | null; start_date: string; }) => { - dispatch(updateTaskStartDate({ task })); - dispatch(setStartDate(task)); - }; + if (!task) return; - socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange); + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; - return () => { - socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange); - }; - }, [socket, dispatch]); + dispatch(updateTaskStartDate({ task: taskWithProgress })); + dispatch(setStartDate(taskWithProgress)); + }, + [dispatch] + ); - // Socket handler for task subscribers updates - useEffect(() => { - if (!socket) return; + // Memoize socket event handlers + const handleTaskSubscribersChange = useCallback( + (data: InlineMember[]) => { + if (!data) return; - const handleTaskSubscribersChange = (data: InlineMember[]) => { dispatch(setTaskSubscribers(data)); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange); - - return () => { - socket.off(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange); - }; - }, [socket, dispatch]); - - // Socket handler for task estimation updates - useEffect(() => { - if (!socket) return; - - const handleEstimationChange = (task: { + // Memoize socket event handlers + const handleEstimationChange = useCallback( + (task: { id: string; parent_task: string | null; estimation: number; }) => { - dispatch(updateTaskEstimation({ task })); - }; + if (!task) return; - socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange); + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; - return () => { - socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange); - }; - }, [socket, dispatch]); + dispatch(updateTaskEstimation({ task: taskWithProgress })); + }, + [dispatch] + ); - // Socket handler for task description updates - useEffect(() => { - if (!socket) return; - - const handleTaskDescriptionChange = (data: { + // Memoize socket event handlers + const handleTaskDescriptionChange = useCallback( + (data: { id: string; parent_task: string; description: string; }) => { + if (!data) return; + dispatch(updateTaskDescription(data)); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange); - - return () => { - socket.off(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange); - }; - }, [socket, dispatch]); - - // Socket handler for new task creation - useEffect(() => { - if (!socket) return; - - const handleNewTaskReceived = (data: IProjectTask) => { + // Memoize socket event handlers + const handleNewTaskReceived = useCallback( + (data: IProjectTask) => { if (!data) return; if (data.parent_task_id) { dispatch(updateSubTasks(data)); } - }; + }, + [dispatch] + ); - socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived); - - return () => { - socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived); - }; - }, [socket, dispatch]); - - // Socket handler for task progress updates - useEffect(() => { - if (!socket) return; - - const handleTaskProgressUpdated = (data: { + // Memoize socket event handlers + const handleTaskProgressUpdated = useCallback( + (data: { task_id: string; progress_value?: number; weight?: number; }) => { + if (!data || !taskGroups) return; + if (data.progress_value !== undefined) { - // Find the task in the task groups and update its progress for (const group of taskGroups) { - const task = group.tasks.find(task => task.id === data.task_id); + const task = group.tasks?.find(task => task.id === data.task_id); if (task) { dispatch( updateTaskProgress({ @@ -403,25 +365,76 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { } } } + }, + [dispatch, taskGroups] + ); + + // Set up socket event listeners + useEffect(() => { + if (!socket) return; + + const eventHandlers = { + [SocketEvents.QUICK_ASSIGNEES_UPDATE.toString()]: handleAssigneesUpdate, + [SocketEvents.TASK_LABELS_CHANGE.toString()]: handleLabelsChange, + [SocketEvents.CREATE_LABEL.toString()]: handleLabelsChange, + [SocketEvents.TASK_STATUS_CHANGE.toString()]: handleTaskStatusChange, + [SocketEvents.GET_TASK_PROGRESS.toString()]: handleTaskProgress, + [SocketEvents.TASK_PRIORITY_CHANGE.toString()]: handlePriorityChange, + [SocketEvents.TASK_END_DATE_CHANGE.toString()]: handleEndDateChange, + [SocketEvents.TASK_NAME_CHANGE.toString()]: handleTaskNameChange, + [SocketEvents.TASK_PHASE_CHANGE.toString()]: handlePhaseChange, + [SocketEvents.TASK_START_DATE_CHANGE.toString()]: handleStartDateChange, + [SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString()]: handleTaskSubscribersChange, + [SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString()]: handleEstimationChange, + [SocketEvents.TASK_DESCRIPTION_CHANGE.toString()]: handleTaskDescriptionChange, + [SocketEvents.QUICK_TASK.toString()]: handleNewTaskReceived, + [SocketEvents.TASK_PROGRESS_UPDATED.toString()]: handleTaskProgressUpdated, }; - socket.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated); + // Register all event handlers + Object.entries(eventHandlers).forEach(([event, handler]) => { + if (handler) { + socket.on(event, handler); + } + }); + // Cleanup function return () => { - socket.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated); + Object.entries(eventHandlers).forEach(([event, handler]) => { + if (handler) { + socket.off(event, handler); + } + }); }; - }, [socket, dispatch, taskGroups]); + }, [ + socket, + handleAssigneesUpdate, + handleLabelsChange, + handleTaskStatusChange, + handleTaskProgress, + handlePriorityChange, + handleEndDateChange, + handleTaskNameChange, + handlePhaseChange, + handleStartDateChange, + handleTaskSubscribersChange, + handleEstimationChange, + handleTaskDescriptionChange, + handleNewTaskReceived, + handleTaskProgressUpdated, + ]); + // Memoize drag handlers const handleDragStart = useCallback(({ active }: DragStartEvent) => { setActiveId(active.id as string); - // Add smooth transition to the dragged item const draggedElement = document.querySelector(`[data-id="${active.id}"]`); if (draggedElement) { (draggedElement as HTMLElement).style.transition = 'transform 0.2s ease'; } }, []); + // Memoize drag handlers const handleDragEnd = useCallback( async ({ active, over }: DragEndEvent) => { setActiveId(null); @@ -440,10 +453,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId); if (fromIndex === -1) return; - // Create a deep clone of the task to avoid reference issues const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex])); - // Check if task dependencies allow the move if (activeGroupId !== overGroupId) { const canContinue = await checkTaskDependencyStatus(task.id, overGroupId); if (!canContinue) { @@ -455,7 +466,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { return; } - // Update task properties based on target group switch (groupBy) { case IGroupBy.STATUS: task.status = overGroupId; @@ -468,35 +478,29 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { task.priority_color_dark = targetGroup.color_code_dark; break; case IGroupBy.PHASE: - // Check if ALPHA_CHANNEL is already added const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL) - ? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) // Remove ALPHA_CHANNEL - : targetGroup.color_code; // Use as is if not present + ? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) + : targetGroup.color_code; task.phase_id = overGroupId; - task.phase_color = baseColor; // Set the cleaned color + task.phase_color = baseColor; break; } } const isTargetGroupEmpty = targetGroup.tasks.length === 0; - - // Calculate toIndex - for empty groups, always add at index 0 const toIndex = isTargetGroupEmpty ? 0 : overTaskId ? targetGroup.tasks.findIndex(t => t.id === overTaskId) : targetGroup.tasks.length; - // Calculate toPos similar to Angular implementation const toPos = isTargetGroupEmpty ? -1 : targetGroup.tasks[toIndex]?.sort_order || targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order || -1; - // Update Redux state if (activeGroupId === overGroupId) { - // Same group - move within array const updatedTasks = [...sourceGroup.tasks]; updatedTasks.splice(fromIndex, 1); updatedTasks.splice(toIndex, 0, task); @@ -514,7 +518,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }, }); } else { - // Different groups - transfer between arrays const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex); const updatedTargetTasks = [...targetGroup.tasks]; @@ -540,7 +543,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }); } - // Emit socket event socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { project_id: projectId, from_index: sourceGroup.tasks[fromIndex].sort_order, @@ -549,13 +551,11 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { from_group: sourceGroup.id, to_group: targetGroup.id, group_by: groupBy, - task: sourceGroup.tasks[fromIndex], // Send original task to maintain references + task: sourceGroup.tasks[fromIndex], team_id: currentSession?.team_id, }); - // Reset styles setTimeout(resetTaskRowStyles, 0); - trackMixpanelEvent(evt_project_task_list_drag_and_move); }, [ @@ -570,6 +570,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { ] ); + // Memoize drag handlers const handleDragOver = useCallback( ({ active, over }: DragEndEvent) => { if (!over) return; @@ -589,12 +590,9 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { if (fromIndex === -1 || toIndex === -1) return; - // Create a deep clone of the task to avoid reference issues const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex])); - // Update Redux state if (activeGroupId === overGroupId) { - // Same group - move within array const updatedTasks = [...sourceGroup.tasks]; updatedTasks.splice(fromIndex, 1); updatedTasks.splice(toIndex, 0, task); @@ -612,10 +610,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }, }); } else { - // Different groups - transfer between arrays const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex); const updatedTargetTasks = [...targetGroup.tasks]; - updatedTargetTasks.splice(toIndex, 0, task); dispatch({ @@ -663,7 +659,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { // Handle animation cleanup after drag ends useIsomorphicLayoutEffect(() => { if (activeId === null) { - // Final cleanup after React updates DOM const timeoutId = setTimeout(resetTaskRowStyles, 50); return () => clearTimeout(timeoutId); } diff --git a/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts new file mode 100644 index 00000000..190b6e7f --- /dev/null +++ b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts @@ -0,0 +1,37 @@ +export enum ITaskRecurring { + Daily = 'daily', + Weekly = 'weekly', + Monthly = 'monthly', + EveryXDays = 'every_x_days', + EveryXWeeks = 'every_x_weeks', + EveryXMonths = 'every_x_months' +} + +export interface ITaskRecurringSchedule { + created_at?: string; + day_of_month?: number | null; + date_of_month?: number | null; + days_of_week?: number[] | null; + id?: string; // UUID v4 + interval_days?: number | null; + interval_months?: number | null; + interval_weeks?: number | null; + schedule_type?: ITaskRecurring; + week_of_month?: number | null; +} + +export interface IRepeatOption { + value?: ITaskRecurring + label?: string +} + +export interface ITaskRecurringScheduleData { + task_id?: string, + id?: string, + schedule_type?: string +} + +export interface IRepeatOption { + value?: ITaskRecurring + label?: string +} diff --git a/worklenz-frontend/src/types/tasks/task.types.ts b/worklenz-frontend/src/types/tasks/task.types.ts index 9c5da9bf..d155490c 100644 --- a/worklenz-frontend/src/types/tasks/task.types.ts +++ b/worklenz-frontend/src/types/tasks/task.types.ts @@ -64,6 +64,7 @@ export interface ITaskViewModel extends ITask { timer_start_time?: number; recurring?: boolean; task_level?: number; + schedule_id?: string | null; } export interface ITaskTeamMember extends ITeamMember {