Compare commits
19 Commits
chore/add-
...
feature/me
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1067d87fe | ||
|
|
97feef5982 | ||
|
|
76c92b1cc6 | ||
|
|
67c62fc69b | ||
|
|
14d8f43001 | ||
|
|
3b59a8560b | ||
|
|
819252cedd | ||
|
|
1dade05f54 | ||
|
|
34613e5e0c | ||
|
|
a8b20680e5 | ||
|
|
f9858fbd4b | ||
|
|
f3a7fd8be5 | ||
|
|
49bdd00dac | ||
|
|
2e985bd051 | ||
|
|
8e74f1ddb5 | ||
|
|
753e3be83f | ||
|
|
ebd0f66768 | ||
|
|
d333104f43 | ||
|
|
62548e5c37 |
@@ -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
|
||||
)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,11 +515,18 @@ 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';
|
||||
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);
|
||||
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));
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
{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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<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'>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
37
worklenz-frontend/src/types/tasks/task-recurring-schedule.ts
Normal file
37
worklenz-frontend/src/types/tasks/task-recurring-schedule.ts
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user