Compare commits

...

19 Commits

Author SHA1 Message Date
chamiakJ
c1067d87fe refactor(reporting): update total working hours calculation in allocation controller
- Replaced project-specific hours per day with organization-wide working hours for total working hours calculation.
- Streamlined the SQL query to fetch organization working hours, ensuring accurate reporting based on organizational settings.
2025-05-20 16:49:07 +05:30
chamiakJ
97feef5982 refactor(reporting): improve utilization calculations in allocation controller
- Updated utilization percentage and utilized hours calculations to handle cases where total working hours are zero, providing 'N/A' for utilization percent when applicable.
- Adjusted logic for over/under utilized hours to ensure accurate reporting based on logged time and total working hours.
2025-05-20 16:18:16 +05:30
chamiakJ
76c92b1cc6 refactor(reporting): optimize date handling and organization working days logic
- Simplified date parsing by removing unnecessary start and end of day adjustments.
- Streamlined the fetching of organization working days from the database, consolidating queries for improved performance.
- Updated the calculation of total working hours to utilize project-specific hours per day, enhancing accuracy in reporting.
2025-05-20 15:51:33 +05:30
chamiakJ
67c62fc69b refactor(schedule): streamline organization working days update query
- Simplified the SQL update query for organization working days by removing unnecessary line breaks and improving readability.
- Adjusted the subquery to directly select organization IDs, enhancing clarity and maintainability.
2025-05-20 11:55:29 +05:30
chamiakJ
14d8f43001 refactor(reporting): clarify date parsing in allocation controller and frontend
- Updated comments to specify date parsing format as 'YYYY-MM-DD'.
- Modified date range handling in the frontend to format dates using date-fns for consistency.
2025-05-20 09:23:22 +05:30
chamiakJ
3b59a8560b refactor(reporting): simplify date parsing and improve logging format
- Updated date parsing to remove UTC conversion, maintaining local date context.
- Enhanced console logging to display dates in 'YYYY-MM-DD' format for clarity.
- Adjusted date range clause to directly use formatted dates for improved query accuracy.
2025-05-20 08:08:34 +05:30
chamiakJ
819252cedd refactor(reporting): update date handling and logging in allocation controller
- Removed UTC conversion for start and end dates to maintain local date context.
- Enhanced console logging to reflect local date values for better debugging.
2025-05-20 08:06:05 +05:30
chamiakJ
1dade05f54 feat(reporting): enhance date range handling in reporting allocation
- Added support for 'LAST_7_DAYS' and 'LAST_30_DAYS' date ranges in the reporting allocation logic.
- Updated date parsing to convert input dates to UTC while preserving the intended local date.
- Included console logs for debugging date values during processing.
2025-05-20 07:59:49 +05:30
chamiakJ
34613e5e0c Merge branch 'fix/performance-improvements' of https://github.com/Worklenz/worklenz into feature/member-time-progress-and-utilization 2025-05-20 07:07:28 +05:30
chamikaJ
a8b20680e5 feat: implement organization working days and hours settings
- Added functionality to fetch and update organization working days and hours in the admin center.
- Introduced a form for saving working days and hours, with validation and error handling.
- Updated the reporting allocation logic to utilize organization-specific working hours for accurate calculations.
- Enhanced localization files to support new settings in English, Spanish, and Portuguese.
2025-05-19 16:07:35 +05:30
chamiakJ
f9858fbd4b refactor(task-list): enhance performance with useMemo and useCallback
- Introduced useMemo to optimize loading state and empty state calculations.
- Added useMemo for socket event handler functions to prevent unnecessary re-renders.
- Refactored data fetching logic to improve initial data load handling.
- Improved drag-and-drop functionality with memoized handlers for better performance.
2025-05-18 20:58:20 +05:30
chamiakJ
f3a7fd8be5 refactor(project-view): optimize component with useMemo and useCallback for performance improvements
- Introduced useMemo and useCallback to memoize tab menu items and callback functions, enhancing performance.
- Added resetProjectData function to clean up project state on component unmount.
- Refactored the component to use React.memo for preventing unnecessary re-renders.
2025-05-18 20:15:40 +05:30
chamikaJ
49bdd00dac fix(todo-list): update empty list image source to use relative path 2025-05-16 14:45:47 +05:30
chamikaJ
2e985bd051 feat(recurring-tasks): implement recurring task scheduling and API integration 2025-05-16 14:32:45 +05:30
chamikaJ
8e74f1ddb5 Merge branch 'chore/add-google-analytics' of https://github.com/Worklenz/worklenz into feature/recurring-tasks 2025-05-16 12:43:06 +05:30
Chamika J
753e3be83f Merge pull request #131 from chamikaJ/feature/recurring-tasks
Feature/recurring tasks
2025-05-16 12:36:23 +05:30
chamikaJ
ebd0f66768 feat(task-drawer): integrate socket handling for recurring task updates
Enhance the TaskDrawerRecurringConfig component to include socket communication for handling recurring task changes. This update introduces the use of Redux for managing state updates related to recurring schedules, ensuring real-time synchronization of task configurations. Additionally, the code has been refactored for improved readability and maintainability.
2025-05-16 12:37:02 +05:30
chamiakJ
d333104f43 feat(i18n): add recurring task translation keys
Add new localization entries for the recurring task feature in English, Spanish, and Portuguese. This update includes the addition of the "recurring" key to the task drawer JSON files, enhancing support for recurring task configurations across multiple languages.
2025-05-16 07:21:57 +05:30
chamikaJ
62548e5c37 feat(task-drawer): add recurring task configuration
Add support for configuring recurring tasks in the task drawer. This includes adding a new `schedule_id` field to the task type, creating a new `TaskDrawerRecurringConfig` component, and updating localization files for English, Spanish, and Portuguese. The configuration allows setting repeat intervals, days of the week, and monthly recurrence options.
2025-05-14 15:41:09 +05:30
32 changed files with 1038 additions and 316 deletions

View File

@@ -118,7 +118,7 @@ BEGIN
SELECT SUM(time_spent)
FROM task_work_log
WHERE task_id = t.id
), 0) as logged_minutes
), 0) / 60.0 as logged_minutes
FROM tasks t
WHERE t.id = _task_id
)

View File

@@ -415,15 +415,20 @@ export default class ReportingController extends WorklenzControllerBase {
@HandleExceptions()
public static async getMyTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const selectedTeamId = req.user?.team_id;
if (!selectedTeamId) {
return res.status(400).send(new ServerResponse(false, "No selected team"));
}
const q = `SELECT team_id AS id, name
FROM team_members tm
LEFT JOIN teams ON teams.id = tm.team_id
WHERE tm.user_id = $1
AND tm.team_id = $2
AND role_id IN (SELECT id
FROM roles
WHERE (admin_role IS TRUE OR owner IS TRUE))
ORDER BY name;`;
const result = await db.query(q, [req.user?.id]);
const result = await db.query(q, [req.user?.id, selectedTeamId]);
result.rows.forEach((team: any) => team.selected = true);
return res.status(200).send(new ServerResponse(true, result.rows));
}

View File

@@ -445,27 +445,52 @@ export default class ReportingAllocationController extends ReportingControllerBa
}
}
// Count only weekdays (Mon-Fri) in the period
// Get organization working days
const orgWorkingDaysQuery = `
SELECT monday, tuesday, wednesday, thursday, friday, saturday, sunday
FROM organization_working_days
WHERE organization_id IN (
SELECT t.organization_id
FROM teams t
WHERE t.id IN (${teamIds})
LIMIT 1
);
`;
const orgWorkingDaysResult = await db.query(orgWorkingDaysQuery, []);
const workingDaysConfig = orgWorkingDaysResult.rows[0] || {
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
saturday: false,
sunday: false
};
// Count working days based on organization settings
let workingDays = 0;
let current = startDate.clone();
while (current.isSameOrBefore(endDate, 'day')) {
const day = current.isoWeekday();
if (day >= 1 && day <= 5) workingDays++;
if (
(day === 1 && workingDaysConfig.monday) ||
(day === 2 && workingDaysConfig.tuesday) ||
(day === 3 && workingDaysConfig.wednesday) ||
(day === 4 && workingDaysConfig.thursday) ||
(day === 5 && workingDaysConfig.friday) ||
(day === 6 && workingDaysConfig.saturday) ||
(day === 7 && workingDaysConfig.sunday)
) {
workingDays++;
}
current.add(1, 'day');
}
// Get hours_per_day for all selected projects
const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`;
const projectHoursResult = await db.query(projectHoursQuery, []);
const projectHoursMap: Record<string, number> = {};
for (const row of projectHoursResult.rows) {
projectHoursMap[row.id] = row.hours_per_day || 8;
}
// Sum total working hours for all selected projects
let totalWorkingHours = 0;
for (const pid of Object.keys(projectHoursMap)) {
totalWorkingHours += workingDays * projectHoursMap[pid];
}
// Get organization working hours
const orgWorkingHoursQuery = `SELECT working_hours FROM organizations WHERE id = (SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1)`;
const orgWorkingHoursResult = await db.query(orgWorkingHoursQuery, []);
const orgWorkingHours = orgWorkingHoursResult.rows[0]?.working_hours || 8;
let totalWorkingHours = workingDays * orgWorkingHours;
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
const archivedClause = archived
@@ -490,12 +515,19 @@ export default class ReportingAllocationController extends ReportingControllerBa
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
member.color_code = getColor(member.name);
member.total_working_hours = totalWorkingHours;
member.utilization_percent = (totalWorkingHours > 0 && member.logged_time) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00';
if (totalWorkingHours === 0) {
member.utilization_percent = member.logged_time && parseFloat(member.logged_time) > 0 ? 'N/A' : '0.00';
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
// Over/under utilized hours: all logged time is over-utilized
member.over_under_utilized_hours = member.utilized_hours;
} else {
member.utilization_percent = (member.logged_time && totalWorkingHours > 0) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00';
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
// Over/under utilized hours: utilized_hours - total_working_hours
const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0;
member.over_under_utilized_hours = overUnder.toFixed(2);
}
}
return res.status(200).send(new ServerResponse(true, result.rows));
}

View File

@@ -74,14 +74,9 @@ export default class ScheduleControllerV2 extends WorklenzControllerBase {
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
.join(", ");
const updateQuery = `
UPDATE public.organization_working_days
const updateQuery = `UPDATE public.organization_working_days
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
WHERE organization_id IN (
SELECT organization_id FROM organizations
WHERE user_id = $1
);
`;
WHERE organization_id IN (SELECT id FROM organizations WHERE user_id = $1);`;
await db.query(updateQuery, [req.user?.owner_id]);

View File

@@ -1,7 +1,7 @@
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();

View File

@@ -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

View File

@@ -166,7 +166,9 @@ export const UNMAPPED = "Unmapped";
export const DATE_RANGES = {
YESTERDAY: "YESTERDAY",
LAST_7_DAYS: "LAST_7_DAYS",
LAST_WEEK: "LAST_WEEK",
LAST_30_DAYS: "LAST_30_DAYS",
LAST_MONTH: "LAST_MONTH",
LAST_QUARTER: "LAST_QUARTER",
ALL_TIME: "ALL_TIME"

View File

@@ -4,5 +4,19 @@
"owner": "Organization Owner",
"admins": "Organization Admins",
"contactNumber": "Add Contact Number",
"edit": "Edit"
"edit": "Edit",
"organizationWorkingDaysAndHours": "Organization Working Days & Hours",
"workingDays": "Working Days",
"workingHours": "Working Hours",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"hours": "hours",
"saveButton": "Save",
"saved": "Saved successfully!",
"errorSaving": "Error saving settings."
}

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -4,5 +4,19 @@
"owner": "Propietario de la Organización",
"admins": "Administradores de la Organización",
"contactNumber": "Agregar Número de Contacto",
"edit": "Editar"
"edit": "Editar",
"organizationWorkingDaysAndHours": "Días y Horas Laborales de la Organización",
"workingDays": "Días Laborales",
"workingHours": "Horas Laborales",
"monday": "Lunes",
"tuesday": "Martes",
"wednesday": "Miércoles",
"thursday": "Jueves",
"friday": "Viernes",
"saturday": "Sábado",
"sunday": "Domingo",
"hours": "horas",
"saveButton": "Guardar",
"saved": "¡Guardado exitosamente!",
"errorSaving": "Error al guardar la configuración."
}

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -4,5 +4,19 @@
"owner": "Proprietário da Organização",
"admins": "Administradores da Organização",
"contactNumber": "Adicione o Número de Contato",
"edit": "Editar"
"edit": "Editar",
"organizationWorkingDaysAndHours": "Dias e Horas de Trabalho da Organização",
"workingDays": "Dias de Trabalho",
"workingHours": "Horas de Trabalho",
"monday": "Segunda-feira",
"tuesday": "Terça-feira",
"wednesday": "Quarta-feira",
"thursday": "Quinta-feira",
"friday": "Sexta-feira",
"saturday": "Sábado",
"sunday": "Domingo",
"hours": "horas",
"saveButton": "Salvar",
"saved": "Salvo com sucesso!",
"errorSaving": "Erro ao salvar as configurações."
}

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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<IServerResponse<ITaskRecurringSchedule>> => {
const response = await apiClient.get(`${rootUrl}/${schedule_id}`);
return response.data;
},
updateTaskRecurringData: async (schedule_id: string, body: any): Promise<IServerResponse<ITaskRecurringSchedule>> => {
return apiClient.put(`${rootUrl}/${schedule_id}`, body);
}
}

View File

@@ -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<IRepeatOption>({});
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<ITaskRecurringSchedule>({});
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 (
<div>
<Form.Item className="w-100 mb-2 align-form-item" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Switch checked={recurring} onChange={handleChange} />
&nbsp;
{recurring && (
<Popover
title="Recurring task configuration"
content={
<Skeleton loading={loadingData} active>
<Form layout="vertical">
<Form.Item label="Repeats">
<Select
value={repeatOption.value}
onChange={val => {
const option = repeatOptions.find(opt => opt.value === val);
if (option) {
setRepeatOption(option);
}
}}
options={repeatOptions}
style={{ width: 200 }}
/>
</Form.Item>
{repeatOption.value === ITaskRecurring.Weekly && (
<Form.Item label="Select Days of the Week">
<Checkbox.Group
options={daysOfWeek}
value={selectedDays}
onChange={handleDayCheckboxChange}
style={{ width: '100%' }}
>
<Row>
{daysOfWeek.map(day => (
<Col span={8} key={day.value}>
<Checkbox value={day.value}>{day.label}</Checkbox>
</Col>
))}
</Row>
</Checkbox.Group>
</Form.Item>
)}
{isMonthlySelected && (
<>
<Form.Item label="Monthly repeat type">
<Radio.Group
value={monthlyOption}
onChange={e => setMonthlyOption(e.target.value)}
>
<Radio.Button value="date">On a specific date</Radio.Button>
<Radio.Button value="day">On a specific day</Radio.Button>
</Radio.Group>
</Form.Item>
{monthlyOption === 'date' && (
<Form.Item label="Date of the month">
<Select
value={selectedMonthlyDate}
onChange={setSelectedMonthlyDate}
options={monthlyDateOptions.map(date => ({
label: date.toString(),
value: date,
}))}
style={{ width: 120 }}
/>
</Form.Item>
)}
{monthlyOption === 'day' && (
<>
<Form.Item label="Week of the month">
<Select
value={selectedMonthlyWeek}
onChange={setSelectedMonthlyWeek}
options={weekOptions}
style={{ width: 150 }}
/>
</Form.Item>
<Form.Item label="Day of the week">
<Select
value={selectedMonthlyDay}
onChange={setSelectedMonthlyDay}
options={dayOptions}
style={{ width: 150 }}
/>
</Form.Item>
</>
)}
</>
)}
{repeatOption.value === ITaskRecurring.EveryXDays && (
<Form.Item label="Interval (days)">
<InputNumber
min={1}
value={intervalDays}
onChange={value => value && setIntervalDays(value)}
/>
</Form.Item>
)}
{repeatOption.value === ITaskRecurring.EveryXWeeks && (
<Form.Item label="Interval (weeks)">
<InputNumber
min={1}
value={intervalWeeks}
onChange={value => value && setIntervalWeeks(value)}
/>
</Form.Item>
)}
{repeatOption.value === ITaskRecurring.EveryXMonths && (
<Form.Item label="Interval (months)">
<InputNumber
min={1}
value={intervalMonths}
onChange={value => value && setIntervalMonths(value)}
/>
</Form.Item>
)}
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Button
type="primary"
size="small"
loading={updatingData}
onClick={handleSave}
>
Save Changes
</Button>
</Form.Item>
</Form>
</Skeleton>
}
overlayStyle={{ width: 510 }}
open={showConfig}
onOpenChange={configVisibleChange}
trigger="click"
>
<Button type="link" loading={loadingData} style={{ padding: 0 }}>
{repeatOption.label} <SettingOutlined />
</Button>
</Popover>
)}
</div>
</Form.Item>
</div>
);
};
export default TaskDrawerRecurringConfig;

View File

@@ -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) =>
<TaskDrawerBillable task={taskFormViewModel?.task as ITaskViewModel} />
</Form.Item>
{/* <Form.Item name="recurring" label={t('taskInfoTab.details.recurring')}>
<TaskDrawerRecurringConfig task={taskFormViewModel?.task as ITaskViewModel} />
</Form.Item> */}
<Form.Item name="notify" label={t('taskInfoTab.details.notify')}>
<NotifyMemberSelector task={taskFormViewModel?.task as ITaskViewModel} t={t} />
</Form.Item>

View File

@@ -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;

View File

@@ -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<ITaskRecurringScheduleData>) => {
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;

View File

@@ -1,6 +1,6 @@
import { EditOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons';
import { PageHeader } from '@ant-design/pro-components';
import { Button, Card, Input, Space, Tooltip, Typography } from 'antd';
import { Button, Card, Input, Space, Tooltip, Typography, Checkbox, Col, Form, Row, message } from 'antd';
import React, { useEffect, useState } from 'react';
import OrganizationAdminsTable from '@/components/admin-center/overview/organization-admins-table/organization-admins-table';
import { useAppSelector } from '@/hooks/useAppSelector';
@@ -12,6 +12,8 @@ import { adminCenterApiService } from '@/api/admin-center/admin-center.api.servi
import { IOrganization, IOrganizationAdmin } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { tr } from 'date-fns/locale';
import { scheduleAPIService } from '@/api/schedule/schedule.api.service';
import { Settings } from '@/types/schedule/schedule-v2.types';
const { Text } = Typography;
@@ -19,6 +21,10 @@ const Overview: React.FC = () => {
const [organization, setOrganization] = useState<IOrganization | null>(null);
const [organizationAdmins, setOrganizationAdmins] = useState<IOrganizationAdmin[] | null>(null);
const [loadingAdmins, setLoadingAdmins] = useState(false);
const [workingDays, setWorkingDays] = useState<Settings['workingDays']>([]);
const [workingHours, setWorkingHours] = useState<Settings['workingHours']>(8);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm();
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
const { t } = useTranslation('admin-center/overview');
@@ -34,6 +40,19 @@ const Overview: React.FC = () => {
}
};
const getOrgWorkingSettings = async () => {
try {
const res = await scheduleAPIService.fetchScheduleSettings();
if (res && res.done) {
setWorkingDays(res.body.workingDays || ['Monday','Tuesday','Wednesday','Thursday','Friday']);
setWorkingHours(res.body.workingHours || 8);
form.setFieldsValue({ workingDays: res.body.workingDays || ['Monday','Tuesday','Wednesday','Thursday','Friday'], workingHours: res.body.workingHours || 8 });
}
} catch (error) {
logger.error('Error getting organization working settings', error);
}
};
const getOrganizationAdmins = async () => {
setLoadingAdmins(true);
try {
@@ -48,8 +67,30 @@ const Overview: React.FC = () => {
}
};
const handleSave = async (values: any) => {
setSaving(true);
try {
const res = await scheduleAPIService.updateScheduleSettings({
workingDays: values.workingDays,
workingHours: values.workingHours,
});
if (res && res.done) {
message.success(t('saved'));
setWorkingDays(values.workingDays);
setWorkingHours(values.workingHours);
getOrgWorkingSettings();
}
} catch (error) {
logger.error('Error updating organization working days/hours', error);
message.error(t('errorSaving'));
} finally {
setSaving(false);
}
};
useEffect(() => {
getOrganizationDetails();
getOrgWorkingSettings();
getOrganizationAdmins();
}, []);
@@ -72,6 +113,37 @@ const Overview: React.FC = () => {
refetch={getOrganizationDetails}
/>
<Card>
<Typography.Title level={5} style={{ margin: 0 }}>{t('organizationWorkingDaysAndHours') || 'Organization Working Days & Hours'}</Typography.Title>
<Form
layout="vertical"
form={form}
initialValues={{ workingDays, workingHours }}
onFinish={handleSave}
style={{ marginTop: 16 }}
>
<Form.Item label={t('workingDays')} name="workingDays">
<Checkbox.Group>
<Row>
<Col span={8}><Checkbox value="Monday">{t('monday')}</Checkbox></Col>
<Col span={8}><Checkbox value="Tuesday">{t('tuesday')}</Checkbox></Col>
<Col span={8}><Checkbox value="Wednesday">{t('wednesday')}</Checkbox></Col>
<Col span={8}><Checkbox value="Thursday">{t('thursday')}</Checkbox></Col>
<Col span={8}><Checkbox value="Friday">{t('friday')}</Checkbox></Col>
<Col span={8}><Checkbox value="Saturday">{t('saturday')}</Checkbox></Col>
<Col span={8}><Checkbox value="Sunday">{t('sunday')}</Checkbox></Col>
</Row>
</Checkbox.Group>
</Form.Item>
<Form.Item label={t('workingHours')} name="workingHours">
<Input type="number" min={1} max={24} suffix={t('hours')} width={100} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={saving}>{t('saveButton') || 'Save'}</Button>
</Form.Item>
</Form>
</Card>
<Card>
<Typography.Title level={5} style={{ margin: 0 }}>
{t('admins')}

View File

@@ -147,7 +147,7 @@ const TodoList = () => {
<div style={{ maxHeight: 420, overflow: 'auto' }}>
{data?.body.length === 0 ? (
<EmptyListPlaceholder
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
imageSrc="/src/assets/images/empty-box.webp"
text={t('home:todoList.noTasks')}
/>
) : (

View File

@@ -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<string>(searchParams.get('pinned_tab') || '');
const [taskid, setTaskId] = useState<string>(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: (
<Flex align="center" style={{ color: colors.skyBlue }}>
@@ -144,21 +156,17 @@ const ProjectView = () => {
</Flex>
),
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(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
</>
), []);
return (
<div style={{ marginBlockStart: 80, marginBlockEnd: 24, minHeight: '80vh' }}>
@@ -170,33 +178,11 @@ const ProjectView = () => {
items={tabMenuItems}
tabBarStyle={{ paddingInline: 0 }}
destroyInactiveTabPane={true}
// tabBarExtraContent={
// <div>
// <span style={{ position: 'relative', top: '-10px' }}>
// <Tooltip title="Members who are active on this project will be displayed here.">
// <QuestionCircleOutlined />
// </Tooltip>
// </span>
// <span
// style={{
// position: 'relative',
// right: '20px',
// top: '10px',
// }}
// >
// <Badge status="success" dot className="profile-badge" />
// </span>
// </div>
// }
/>
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
{portalElements}
</div>
);
};
export default ProjectView;
export default React.memo(ProjectView);

View File

@@ -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 = [];
const fetchInitialData = async () => {
if (!projectId || !groupBy || initialLoadComplete) return;
if (!loadingColumns) promises.push(dispatch(fetchTaskListColumns(projectId)));
if (!loadingPhases) promises.push(dispatch(fetchPhasesByProjectId(projectId)));
if (!loadingGroups && projectView === 'list') {
promises.push(dispatch(fetchTaskGroups(projectId)));
}
if (!statusCategories.length) {
promises.push(dispatch(fetchStatusesCategories()));
}
// Wait for all data to load
await Promise.all(promises);
try {
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 (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
<TaskListFilters position="list" />
{(taskGroups && taskGroups.length === 0 && !isLoading) ? (
{isEmptyState ? (
<Empty description="No tasks group found" />
) : (
<Skeleton active loading={isLoading} className='mt-4 p-4'>

View File

@@ -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<HTMLElement>('.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]);
// Memoize socket event handlers
const handleLabelsChange = useCallback(
async (labels: ILabelsChangeResponse) => {
if (!labels) return;
// Socket handler for label updates
useEffect(() => {
if (!socket) return;
const handleLabelsChange = async (labels: ILabelsChangeResponse) => {
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);
}

View File

@@ -16,6 +16,7 @@ import { reportingTimesheetApiService } from '@/api/reporting/reporting.timeshee
import { IRPTTimeMember } from '@/types/reporting/reporting.types';
import logger from '@/utils/errorLogger';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { format } from 'date-fns';
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
@@ -141,12 +142,18 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
const selectedProjects = filterProjects.filter(project => project.selected);
const selectedCategories = categories.filter(category => category.selected);
// Format dates using date-fns
const formattedDateRange = dateRange ? [
format(new Date(dateRange[0]), 'yyyy-MM-dd'),
format(new Date(dateRange[1]), 'yyyy-MM-dd')
] : undefined;
const body = {
teams: selectedTeams.map(t => t.id),
projects: selectedProjects.map(project => project.id),
categories: selectedCategories.map(category => category.id),
duration,
date_range: dateRange,
date_range: formattedDateRange,
billable,
};

View File

@@ -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
}

View File

@@ -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 {