Compare commits
19 Commits
main
...
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)
|
SELECT SUM(time_spent)
|
||||||
FROM task_work_log
|
FROM task_work_log
|
||||||
WHERE task_id = t.id
|
WHERE task_id = t.id
|
||||||
), 0) as logged_minutes
|
), 0) / 60.0 as logged_minutes
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
WHERE t.id = _task_id
|
WHERE t.id = _task_id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -415,15 +415,20 @@ export default class ReportingController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async getMyTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
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
|
const q = `SELECT team_id AS id, name
|
||||||
FROM team_members tm
|
FROM team_members tm
|
||||||
LEFT JOIN teams ON teams.id = tm.team_id
|
LEFT JOIN teams ON teams.id = tm.team_id
|
||||||
WHERE tm.user_id = $1
|
WHERE tm.user_id = $1
|
||||||
|
AND tm.team_id = $2
|
||||||
AND role_id IN (SELECT id
|
AND role_id IN (SELECT id
|
||||||
FROM roles
|
FROM roles
|
||||||
WHERE (admin_role IS TRUE OR owner IS TRUE))
|
WHERE (admin_role IS TRUE OR owner IS TRUE))
|
||||||
ORDER BY name;`;
|
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);
|
result.rows.forEach((team: any) => team.selected = true);
|
||||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
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 workingDays = 0;
|
||||||
let current = startDate.clone();
|
let current = startDate.clone();
|
||||||
while (current.isSameOrBefore(endDate, 'day')) {
|
while (current.isSameOrBefore(endDate, 'day')) {
|
||||||
const day = current.isoWeekday();
|
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');
|
current.add(1, 'day');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get hours_per_day for all selected projects
|
// Get organization working hours
|
||||||
const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`;
|
const orgWorkingHoursQuery = `SELECT working_hours FROM organizations WHERE id = (SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1)`;
|
||||||
const projectHoursResult = await db.query(projectHoursQuery, []);
|
const orgWorkingHoursResult = await db.query(orgWorkingHoursQuery, []);
|
||||||
const projectHoursMap: Record<string, number> = {};
|
const orgWorkingHours = orgWorkingHoursResult.rows[0]?.working_hours || 8;
|
||||||
for (const row of projectHoursResult.rows) {
|
let totalWorkingHours = workingDays * orgWorkingHours;
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||||
const archivedClause = archived
|
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.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
|
||||||
member.color_code = getColor(member.name);
|
member.color_code = getColor(member.name);
|
||||||
member.total_working_hours = totalWorkingHours;
|
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.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
|
member.utilization_percent = member.logged_time && parseFloat(member.logged_time) > 0 ? 'N/A' : '0.00';
|
||||||
// Over/under utilized hours: utilized_hours - total_working_hours
|
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
|
||||||
const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0;
|
// Over/under utilized hours: all logged time is over-utilized
|
||||||
member.over_under_utilized_hours = overUnder.toFixed(2);
|
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));
|
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)}`)
|
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
const updateQuery = `
|
const updateQuery = `UPDATE public.organization_working_days
|
||||||
UPDATE public.organization_working_days
|
|
||||||
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
|
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE organization_id IN (
|
WHERE organization_id IN (SELECT id FROM organizations WHERE user_id = $1);`;
|
||||||
SELECT organization_id FROM organizations
|
|
||||||
WHERE user_id = $1
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
await db.query(updateQuery, [req.user?.owner_id]);
|
await db.query(updateQuery, [req.user?.owner_id]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {startDailyDigestJob} from "./daily-digest-job";
|
import {startDailyDigestJob} from "./daily-digest-job";
|
||||||
import {startNotificationsJob} from "./notifications-job";
|
import {startNotificationsJob} from "./notifications-job";
|
||||||
import {startProjectDigestJob} from "./project-digest-job";
|
import {startProjectDigestJob} from "./project-digest-job";
|
||||||
import { startRecurringTasksJob } from "./recurring-tasks";
|
import {startRecurringTasksJob} from "./recurring-tasks";
|
||||||
|
|
||||||
export function startCronJobs() {
|
export function startCronJobs() {
|
||||||
startNotificationsJob();
|
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.
|
// 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 = "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_FORMAT = "YYYY-MM-DD";
|
||||||
// const TIME = "0 0 * * *"; // Runs at midnight every day
|
// const TIME = "0 0 * * *"; // Runs at midnight every day
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,9 @@ export const UNMAPPED = "Unmapped";
|
|||||||
|
|
||||||
export const DATE_RANGES = {
|
export const DATE_RANGES = {
|
||||||
YESTERDAY: "YESTERDAY",
|
YESTERDAY: "YESTERDAY",
|
||||||
|
LAST_7_DAYS: "LAST_7_DAYS",
|
||||||
LAST_WEEK: "LAST_WEEK",
|
LAST_WEEK: "LAST_WEEK",
|
||||||
|
LAST_30_DAYS: "LAST_30_DAYS",
|
||||||
LAST_MONTH: "LAST_MONTH",
|
LAST_MONTH: "LAST_MONTH",
|
||||||
LAST_QUARTER: "LAST_QUARTER",
|
LAST_QUARTER: "LAST_QUARTER",
|
||||||
ALL_TIME: "ALL_TIME"
|
ALL_TIME: "ALL_TIME"
|
||||||
|
|||||||
@@ -4,5 +4,19 @@
|
|||||||
"owner": "Organization Owner",
|
"owner": "Organization Owner",
|
||||||
"admins": "Organization Admins",
|
"admins": "Organization Admins",
|
||||||
"contactNumber": "Add Contact Number",
|
"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",
|
"hide-start-date": "Hide Start Date",
|
||||||
"show-start-date": "Show Start Date",
|
"show-start-date": "Show Start Date",
|
||||||
"hours": "Hours",
|
"hours": "Hours",
|
||||||
"minutes": "Minutes"
|
"minutes": "Minutes",
|
||||||
|
"recurring": "Recurring"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"title": "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",
|
"taskWeight": "Task Weight",
|
||||||
"taskWeightTooltip": "Set the weight of this subtask (percentage)",
|
"taskWeightTooltip": "Set the weight of this subtask (percentage)",
|
||||||
"taskWeightRequired": "Please enter a task weight",
|
"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": {
|
"labels": {
|
||||||
"labelInputPlaceholder": "Search or create",
|
"labelInputPlaceholder": "Search or create",
|
||||||
|
|||||||
@@ -4,5 +4,19 @@
|
|||||||
"owner": "Propietario de la Organización",
|
"owner": "Propietario de la Organización",
|
||||||
"admins": "Administradores de la Organización",
|
"admins": "Administradores de la Organización",
|
||||||
"contactNumber": "Agregar Número de Contacto",
|
"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",
|
"hide-start-date": "Ocultar fecha de inicio",
|
||||||
"show-start-date": "Mostrar fecha de inicio",
|
"show-start-date": "Mostrar fecha de inicio",
|
||||||
"hours": "Horas",
|
"hours": "Horas",
|
||||||
"minutes": "Minutos"
|
"minutes": "Minutos",
|
||||||
|
"recurring": "Recurrente"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"title": "Descripción",
|
"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",
|
"taskWeight": "Peso de la Tarea",
|
||||||
"taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)",
|
"taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)",
|
||||||
"taskWeightRequired": "Por favor, introduce un peso para la tarea",
|
"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": {
|
"labels": {
|
||||||
"labelInputPlaceholder": "Buscar o crear",
|
"labelInputPlaceholder": "Buscar o crear",
|
||||||
|
|||||||
@@ -4,5 +4,19 @@
|
|||||||
"owner": "Proprietário da Organização",
|
"owner": "Proprietário da Organização",
|
||||||
"admins": "Administradores da Organização",
|
"admins": "Administradores da Organização",
|
||||||
"contactNumber": "Adicione o Número de Contato",
|
"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",
|
"hide-start-date": "Ocultar data de início",
|
||||||
"show-start-date": "Mostrar data de início",
|
"show-start-date": "Mostrar data de início",
|
||||||
"hours": "Horas",
|
"hours": "Horas",
|
||||||
"minutes": "Minutos"
|
"minutes": "Minutos",
|
||||||
|
"recurring": "Recorrente"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"title": "Descrição",
|
"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",
|
"taskWeight": "Peso da Tarefa",
|
||||||
"taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)",
|
"taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)",
|
||||||
"taskWeightRequired": "Por favor, insira um peso para a tarefa",
|
"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": {
|
"labels": {
|
||||||
"labelInputPlaceholder": "Pesquisar ou criar",
|
"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 TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progress';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
|
import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/task-drawer-recurring-config';
|
||||||
|
|
||||||
interface TaskDetailsFormProps {
|
interface TaskDetailsFormProps {
|
||||||
taskFormViewModel?: ITaskFormViewModel | null;
|
taskFormViewModel?: ITaskFormViewModel | null;
|
||||||
@@ -175,6 +176,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
|||||||
<TaskDrawerBillable task={taskFormViewModel?.task as ITaskViewModel} />
|
<TaskDrawerBillable task={taskFormViewModel?.task as ITaskViewModel} />
|
||||||
</Form.Item>
|
</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')}>
|
<Form.Item name="notify" label={t('taskInfoTab.details.notify')}>
|
||||||
<NotifyMemberSelector task={taskFormViewModel?.task as ITaskViewModel} t={t} />
|
<NotifyMemberSelector task={taskFormViewModel?.task as ITaskViewModel} t={t} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -105,6 +105,15 @@ const taskDrawerSlice = createSlice({
|
|||||||
}>) => {
|
}>) => {
|
||||||
state.timeLogEditing = action.payload;
|
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 => {
|
extraReducers: builder => {
|
||||||
builder.addCase(fetchTask.pending, state => {
|
builder.addCase(fetchTask.pending, state => {
|
||||||
@@ -133,5 +142,6 @@ export const {
|
|||||||
setTaskLabels,
|
setTaskLabels,
|
||||||
setTaskSubscribers,
|
setTaskSubscribers,
|
||||||
setTimeLogEditing,
|
setTimeLogEditing,
|
||||||
|
setTaskRecurringSchedule
|
||||||
} = taskDrawerSlice.actions;
|
} = taskDrawerSlice.actions;
|
||||||
export default taskDrawerSlice.reducer;
|
export default taskDrawerSlice.reducer;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-respon
|
|||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
|
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule';
|
||||||
|
|
||||||
export enum IGroupBy {
|
export enum IGroupBy {
|
||||||
STATUS = 'status',
|
STATUS = 'status',
|
||||||
@@ -1006,6 +1007,15 @@ const taskSlice = createSlice({
|
|||||||
column.pinned = isVisible;
|
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 => {
|
extraReducers: builder => {
|
||||||
@@ -1165,6 +1175,7 @@ export const {
|
|||||||
updateSubTasks,
|
updateSubTasks,
|
||||||
updateCustomColumnValue,
|
updateCustomColumnValue,
|
||||||
updateCustomColumnPinned,
|
updateCustomColumnPinned,
|
||||||
|
updateRecurringChange
|
||||||
} = taskSlice.actions;
|
} = taskSlice.actions;
|
||||||
|
|
||||||
export default taskSlice.reducer;
|
export default taskSlice.reducer;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EditOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons';
|
import { EditOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons';
|
||||||
import { PageHeader } from '@ant-design/pro-components';
|
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 React, { useEffect, useState } from 'react';
|
||||||
import OrganizationAdminsTable from '@/components/admin-center/overview/organization-admins-table/organization-admins-table';
|
import OrganizationAdminsTable from '@/components/admin-center/overview/organization-admins-table/organization-admins-table';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
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 { IOrganization, IOrganizationAdmin } from '@/types/admin-center/admin-center.types';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { tr } from 'date-fns/locale';
|
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;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -19,6 +21,10 @@ const Overview: React.FC = () => {
|
|||||||
const [organization, setOrganization] = useState<IOrganization | null>(null);
|
const [organization, setOrganization] = useState<IOrganization | null>(null);
|
||||||
const [organizationAdmins, setOrganizationAdmins] = useState<IOrganizationAdmin[] | null>(null);
|
const [organizationAdmins, setOrganizationAdmins] = useState<IOrganizationAdmin[] | null>(null);
|
||||||
const [loadingAdmins, setLoadingAdmins] = useState(false);
|
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 themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
||||||
const { t } = useTranslation('admin-center/overview');
|
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 () => {
|
const getOrganizationAdmins = async () => {
|
||||||
setLoadingAdmins(true);
|
setLoadingAdmins(true);
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
getOrganizationDetails();
|
getOrganizationDetails();
|
||||||
|
getOrgWorkingSettings();
|
||||||
getOrganizationAdmins();
|
getOrganizationAdmins();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -72,6 +113,37 @@ const Overview: React.FC = () => {
|
|||||||
refetch={getOrganizationDetails}
|
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>
|
<Card>
|
||||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||||
{t('admins')}
|
{t('admins')}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ const TodoList = () => {
|
|||||||
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
||||||
{data?.body.length === 0 ? (
|
{data?.body.length === 0 ? (
|
||||||
<EmptyListPlaceholder
|
<EmptyListPlaceholder
|
||||||
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
|
imageSrc="/src/assets/images/empty-box.webp"
|
||||||
text={t('home:todoList.noTasks')}
|
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 { PushpinFilled, PushpinOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd';
|
import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd';
|
||||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
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 [pinnedTab, setPinnedTab] = useState<string>(searchParams.get('pinned_tab') || '');
|
||||||
const [taskid, setTaskId] = useState<string>(searchParams.get('task') || '');
|
const [taskid, setTaskId] = useState<string>(searchParams.get('task') || '');
|
||||||
|
|
||||||
|
const resetProjectData = useCallback(() => {
|
||||||
|
dispatch(setProjectId(null));
|
||||||
|
dispatch(resetStatuses());
|
||||||
|
dispatch(deselectAll());
|
||||||
|
dispatch(resetTaskListData());
|
||||||
|
dispatch(resetBoardData());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
dispatch(setProjectId(projectId));
|
dispatch(setProjectId(projectId));
|
||||||
@@ -59,9 +67,13 @@ const ProjectView = () => {
|
|||||||
dispatch(setSelectedTaskId(taskid || ''));
|
dispatch(setSelectedTaskId(taskid || ''));
|
||||||
dispatch(setShowTaskDrawer(true));
|
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;
|
if (!itemKey || !projectId) return;
|
||||||
|
|
||||||
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
|
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
|
||||||
@@ -88,9 +100,9 @@ const ProjectView = () => {
|
|||||||
}).toString(),
|
}).toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, [projectId, activeTab, navigate]);
|
||||||
|
|
||||||
const handleTabChange = (key: string) => {
|
const handleTabChange = useCallback((key: string) => {
|
||||||
setActiveTab(key);
|
setActiveTab(key);
|
||||||
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
|
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
|
||||||
navigate({
|
navigate({
|
||||||
@@ -100,9 +112,9 @@ const ProjectView = () => {
|
|||||||
pinned_tab: pinnedTab,
|
pinned_tab: pinnedTab,
|
||||||
}).toString(),
|
}).toString(),
|
||||||
});
|
});
|
||||||
};
|
}, [dispatch, location.pathname, navigate, pinnedTab]);
|
||||||
|
|
||||||
const tabMenuItems = tabItems.map(item => ({
|
const tabMenuItems = useMemo(() => tabItems.map(item => ({
|
||||||
key: item.key,
|
key: item.key,
|
||||||
label: (
|
label: (
|
||||||
<Flex align="center" style={{ color: colors.skyBlue }}>
|
<Flex align="center" style={{ color: colors.skyBlue }}>
|
||||||
@@ -144,21 +156,17 @@ const ProjectView = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
),
|
),
|
||||||
children: item.element,
|
children: item.element,
|
||||||
}));
|
})), [pinnedTab, pinToDefaultTab]);
|
||||||
|
|
||||||
const resetProjectData = () => {
|
const portalElements = useMemo(() => (
|
||||||
dispatch(setProjectId(null));
|
<>
|
||||||
dispatch(resetStatuses());
|
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
|
||||||
dispatch(deselectAll());
|
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
|
||||||
dispatch(resetTaskListData());
|
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
|
||||||
dispatch(resetBoardData());
|
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
|
||||||
};
|
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
|
||||||
|
</>
|
||||||
useEffect(() => {
|
), []);
|
||||||
return () => {
|
|
||||||
resetProjectData();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBlockStart: 80, marginBlockEnd: 24, minHeight: '80vh' }}>
|
<div style={{ marginBlockStart: 80, marginBlockEnd: 24, minHeight: '80vh' }}>
|
||||||
@@ -170,33 +178,11 @@ const ProjectView = () => {
|
|||||||
items={tabMenuItems}
|
items={tabMenuItems}
|
||||||
tabBarStyle={{ paddingInline: 0 }}
|
tabBarStyle={{ paddingInline: 0 }}
|
||||||
destroyInactiveTabPane={true}
|
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')}
|
{portalElements}
|
||||||
{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')}
|
|
||||||
</div>
|
</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 Flex from 'antd/es/flex';
|
||||||
import Skeleton from 'antd/es/skeleton';
|
import Skeleton from 'antd/es/skeleton';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
@@ -17,8 +17,8 @@ const ProjectViewTaskList = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { projectView } = useTabSearchParam();
|
const { projectView } = useTabSearchParam();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
// Add local loading state to immediately show skeleton
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||||
|
|
||||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||||
const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector(
|
const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector(
|
||||||
@@ -30,47 +30,73 @@ const ProjectViewTaskList = () => {
|
|||||||
const { loadingPhases } = useAppSelector(state => state.phaseReducer);
|
const { loadingPhases } = useAppSelector(state => state.phaseReducer);
|
||||||
const { loadingColumns } = useAppSelector(state => state.taskReducer);
|
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(() => {
|
useEffect(() => {
|
||||||
// Set default view to list if projectView is not list or board
|
|
||||||
if (projectView !== 'list' && projectView !== 'board') {
|
if (projectView !== 'list' && projectView !== 'board') {
|
||||||
searchParams.set('tab', 'tasks-list');
|
const newParams = new URLSearchParams(searchParams);
|
||||||
searchParams.set('pinned_tab', 'tasks-list');
|
newParams.set('tab', 'tasks-list');
|
||||||
setSearchParams(searchParams);
|
newParams.set('pinned_tab', 'tasks-list');
|
||||||
|
setSearchParams(newParams);
|
||||||
}
|
}
|
||||||
}, [projectView, searchParams, setSearchParams]);
|
}, [projectView, setSearchParams]);
|
||||||
|
|
||||||
|
// Update loading state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Set loading state based on all loading conditions
|
setIsLoading(isLoadingState);
|
||||||
setIsLoading(loadingGroups || loadingColumns || loadingPhases || loadingStatusCategories);
|
}, [isLoadingState]);
|
||||||
}, [loadingGroups, loadingColumns, loadingPhases, loadingStatusCategories]);
|
|
||||||
|
|
||||||
|
// Fetch initial data only once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const fetchInitialData = async () => {
|
||||||
if (projectId && groupBy) {
|
if (!projectId || !groupBy || initialLoadComplete) return;
|
||||||
const promises = [];
|
|
||||||
|
try {
|
||||||
if (!loadingColumns) promises.push(dispatch(fetchTaskListColumns(projectId)));
|
await Promise.all([
|
||||||
if (!loadingPhases) promises.push(dispatch(fetchPhasesByProjectId(projectId)));
|
dispatch(fetchTaskListColumns(projectId)),
|
||||||
if (!loadingGroups && projectView === 'list') {
|
dispatch(fetchPhasesByProjectId(projectId)),
|
||||||
promises.push(dispatch(fetchTaskGroups(projectId)));
|
dispatch(fetchStatusesCategories())
|
||||||
}
|
]);
|
||||||
if (!statusCategories.length) {
|
setInitialLoadComplete(true);
|
||||||
promises.push(dispatch(fetchStatusesCategories()));
|
} catch (error) {
|
||||||
}
|
console.error('Error fetching initial data:', error);
|
||||||
|
|
||||||
// Wait for all data to load
|
|
||||||
await Promise.all(promises);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
fetchInitialData();
|
||||||
}, [dispatch, projectId, groupBy, fields, search, archived]);
|
}, [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 (
|
return (
|
||||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||||
<TaskListFilters position="list" />
|
<TaskListFilters position="list" />
|
||||||
|
|
||||||
{(taskGroups && taskGroups.length === 0 && !isLoading) ? (
|
{isEmptyState ? (
|
||||||
<Empty description="No tasks group found" />
|
<Empty description="No tasks group found" />
|
||||||
) : (
|
) : (
|
||||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
|
|||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import Flex from 'antd/es/flex';
|
import Flex from 'antd/es/flex';
|
||||||
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
|
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
|
||||||
@@ -87,16 +87,22 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees);
|
const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees);
|
||||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||||
|
|
||||||
const sensors = useSensors(
|
// Move useSensors to top level and memoize its configuration
|
||||||
useSensor(PointerSensor, {
|
const sensorConfig = useMemo(
|
||||||
|
() => ({
|
||||||
activationConstraint: { distance: 8 },
|
activationConstraint: { distance: 8 },
|
||||||
})
|
}),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pointerSensor = useSensor(PointerSensor, sensorConfig);
|
||||||
|
const sensors = useSensors(pointerSensor);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGroups(taskGroups);
|
setGroups(taskGroups);
|
||||||
}, [taskGroups]);
|
}, [taskGroups]);
|
||||||
|
|
||||||
|
// Memoize resetTaskRowStyles to prevent unnecessary re-renders
|
||||||
const resetTaskRowStyles = useCallback(() => {
|
const resetTaskRowStyles = useCallback(() => {
|
||||||
document.querySelectorAll<HTMLElement>('.task-row').forEach(row => {
|
document.querySelectorAll<HTMLElement>('.task-row').forEach(row => {
|
||||||
row.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
|
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
|
// Memoize socket event handlers
|
||||||
useEffect(() => {
|
const handleAssigneesUpdate = useCallback(
|
||||||
if (!socket) return;
|
(data: ITaskAssigneesUpdateResponse) => {
|
||||||
|
|
||||||
const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
|
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
const updatedAssignees = data.assignees.map(assignee => ({
|
const updatedAssignees = data.assignees?.map(assignee => ({
|
||||||
...assignee,
|
...assignee,
|
||||||
selected: true,
|
selected: true,
|
||||||
}));
|
})) || [];
|
||||||
|
|
||||||
// Find the group that contains the task or its subtasks
|
const groupId = groups?.find(group =>
|
||||||
const groupId = groups.find(group =>
|
group.tasks?.some(
|
||||||
group.tasks.some(
|
|
||||||
task =>
|
task =>
|
||||||
task.id === data.id ||
|
task.id === data.id ||
|
||||||
(task.sub_tasks && task.sub_tasks.some(subtask => subtask.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) {
|
if (currentSession?.team_id && !loadingAssignees) {
|
||||||
dispatch(fetchTaskAssignees(currentSession.team_id));
|
dispatch(fetchTaskAssignees(currentSession.team_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[groups, dispatch, currentSession?.team_id, loadingAssignees]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
// Memoize socket event handlers
|
||||||
return () => {
|
const handleLabelsChange = useCallback(
|
||||||
socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
async (labels: ILabelsChangeResponse) => {
|
||||||
};
|
if (!labels) return;
|
||||||
}, [socket, currentSession?.team_id, loadingAssignees, groups, dispatch]);
|
|
||||||
|
|
||||||
// Socket handler for label updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleLabelsChange = async (labels: ILabelsChangeResponse) => {
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
dispatch(updateTaskLabel(labels)),
|
dispatch(updateTaskLabel(labels)),
|
||||||
dispatch(setTaskLabels(labels)),
|
dispatch(setTaskLabels(labels)),
|
||||||
dispatch(fetchLabels()),
|
dispatch(fetchLabels()),
|
||||||
projectId && dispatch(fetchLabelsByProject(projectId)),
|
projectId && dispatch(fetchLabelsByProject(projectId)),
|
||||||
]);
|
]);
|
||||||
};
|
},
|
||||||
|
[dispatch, projectId]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
|
// Memoize socket event handlers
|
||||||
socket.on(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
|
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) {
|
if (response.completed_deps === false) {
|
||||||
alertService.error(
|
alertService.error(
|
||||||
'Task is not completed',
|
'Task is not completed',
|
||||||
@@ -186,11 +183,14 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispatch(updateTaskStatus(response));
|
dispatch(updateTaskStatus(response));
|
||||||
// dispatch(setTaskStatus(response));
|
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAll());
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTaskProgress = (data: {
|
// Memoize socket event handlers
|
||||||
|
const handleTaskProgress = useCallback(
|
||||||
|
(data: {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
complete_ratio: number;
|
complete_ratio: number;
|
||||||
@@ -198,6 +198,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
total_tasks_count: number;
|
total_tasks_count: number;
|
||||||
parent_task: string;
|
parent_task: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
updateTaskProgress({
|
updateTaskProgress({
|
||||||
taskId: data.parent_task || data.id,
|
taskId: data.parent_task || data.id,
|
||||||
@@ -206,190 +208,150 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
completedCount: data.completed_count,
|
completedCount: data.completed_count,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
|
// Memoize socket event handlers
|
||||||
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
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(updateTaskPriority(response));
|
||||||
dispatch(setTaskPriority(response));
|
dispatch(setTaskPriority(response));
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAll());
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
|
// Memoize socket event handlers
|
||||||
|
const handleEndDateChange = useCallback(
|
||||||
return () => {
|
(task: {
|
||||||
socket.off(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
|
|
||||||
};
|
|
||||||
}, [socket, dispatch]);
|
|
||||||
|
|
||||||
// Socket handler for due date updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleEndDateChange = (task: {
|
|
||||||
id: string;
|
id: string;
|
||||||
parent_task: string | null;
|
parent_task: string | null;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
}) => {
|
}) => {
|
||||||
dispatch(updateTaskEndDate({ task }));
|
if (!task) return;
|
||||||
dispatch(setTaskEndDate(task));
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
const taskWithProgress = {
|
||||||
|
...task,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask;
|
||||||
|
|
||||||
return () => {
|
dispatch(updateTaskEndDate({ task: taskWithProgress }));
|
||||||
socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
dispatch(setTaskEndDate(taskWithProgress));
|
||||||
};
|
},
|
||||||
}, [socket, dispatch]);
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
// Socket handler for task name updates
|
// Memoize socket event handlers
|
||||||
useEffect(() => {
|
const handleTaskNameChange = useCallback(
|
||||||
if (!socket) return;
|
(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(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(updateTaskPhase(data));
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAll());
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
|
// Memoize socket event handlers
|
||||||
|
const handleStartDateChange = useCallback(
|
||||||
return () => {
|
(task: {
|
||||||
socket.off(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
|
|
||||||
};
|
|
||||||
}, [socket, dispatch]);
|
|
||||||
|
|
||||||
// Socket handler for start date updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleStartDateChange = (task: {
|
|
||||||
id: string;
|
id: string;
|
||||||
parent_task: string | null;
|
parent_task: string | null;
|
||||||
start_date: string;
|
start_date: string;
|
||||||
}) => {
|
}) => {
|
||||||
dispatch(updateTaskStartDate({ task }));
|
if (!task) return;
|
||||||
dispatch(setStartDate(task));
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
|
const taskWithProgress = {
|
||||||
|
...task,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask;
|
||||||
|
|
||||||
return () => {
|
dispatch(updateTaskStartDate({ task: taskWithProgress }));
|
||||||
socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
|
dispatch(setStartDate(taskWithProgress));
|
||||||
};
|
},
|
||||||
}, [socket, dispatch]);
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
// Socket handler for task subscribers updates
|
// Memoize socket event handlers
|
||||||
useEffect(() => {
|
const handleTaskSubscribersChange = useCallback(
|
||||||
if (!socket) return;
|
(data: InlineMember[]) => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
const handleTaskSubscribersChange = (data: InlineMember[]) => {
|
|
||||||
dispatch(setTaskSubscribers(data));
|
dispatch(setTaskSubscribers(data));
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
|
// Memoize socket event handlers
|
||||||
|
const handleEstimationChange = useCallback(
|
||||||
return () => {
|
(task: {
|
||||||
socket.off(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
|
|
||||||
};
|
|
||||||
}, [socket, dispatch]);
|
|
||||||
|
|
||||||
// Socket handler for task estimation updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleEstimationChange = (task: {
|
|
||||||
id: string;
|
id: string;
|
||||||
parent_task: string | null;
|
parent_task: string | null;
|
||||||
estimation: number;
|
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 () => {
|
dispatch(updateTaskEstimation({ task: taskWithProgress }));
|
||||||
socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
|
},
|
||||||
};
|
[dispatch]
|
||||||
}, [socket, dispatch]);
|
);
|
||||||
|
|
||||||
// Socket handler for task description updates
|
// Memoize socket event handlers
|
||||||
useEffect(() => {
|
const handleTaskDescriptionChange = useCallback(
|
||||||
if (!socket) return;
|
(data: {
|
||||||
|
|
||||||
const handleTaskDescriptionChange = (data: {
|
|
||||||
id: string;
|
id: string;
|
||||||
parent_task: string;
|
parent_task: string;
|
||||||
description: string;
|
description: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
dispatch(updateTaskDescription(data));
|
dispatch(updateTaskDescription(data));
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
|
// Memoize socket event handlers
|
||||||
|
const handleNewTaskReceived = useCallback(
|
||||||
return () => {
|
(data: IProjectTask) => {
|
||||||
socket.off(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
|
|
||||||
};
|
|
||||||
}, [socket, dispatch]);
|
|
||||||
|
|
||||||
// Socket handler for new task creation
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleNewTaskReceived = (data: IProjectTask) => {
|
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
if (data.parent_task_id) {
|
if (data.parent_task_id) {
|
||||||
dispatch(updateSubTasks(data));
|
dispatch(updateSubTasks(data));
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
|
// Memoize socket event handlers
|
||||||
|
const handleTaskProgressUpdated = useCallback(
|
||||||
return () => {
|
(data: {
|
||||||
socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
|
|
||||||
};
|
|
||||||
}, [socket, dispatch]);
|
|
||||||
|
|
||||||
// Socket handler for task progress updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleTaskProgressUpdated = (data: {
|
|
||||||
task_id: string;
|
task_id: string;
|
||||||
progress_value?: number;
|
progress_value?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (!data || !taskGroups) return;
|
||||||
|
|
||||||
if (data.progress_value !== undefined) {
|
if (data.progress_value !== undefined) {
|
||||||
// Find the task in the task groups and update its progress
|
|
||||||
for (const group of taskGroups) {
|
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) {
|
if (task) {
|
||||||
dispatch(
|
dispatch(
|
||||||
updateTaskProgress({
|
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 () => {
|
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) => {
|
const handleDragStart = useCallback(({ active }: DragStartEvent) => {
|
||||||
setActiveId(active.id as string);
|
setActiveId(active.id as string);
|
||||||
|
|
||||||
// Add smooth transition to the dragged item
|
|
||||||
const draggedElement = document.querySelector(`[data-id="${active.id}"]`);
|
const draggedElement = document.querySelector(`[data-id="${active.id}"]`);
|
||||||
if (draggedElement) {
|
if (draggedElement) {
|
||||||
(draggedElement as HTMLElement).style.transition = 'transform 0.2s ease';
|
(draggedElement as HTMLElement).style.transition = 'transform 0.2s ease';
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Memoize drag handlers
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
async ({ active, over }: DragEndEvent) => {
|
async ({ active, over }: DragEndEvent) => {
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
@@ -440,10 +453,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
|
const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
|
||||||
if (fromIndex === -1) return;
|
if (fromIndex === -1) return;
|
||||||
|
|
||||||
// Create a deep clone of the task to avoid reference issues
|
|
||||||
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
|
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
|
||||||
|
|
||||||
// Check if task dependencies allow the move
|
|
||||||
if (activeGroupId !== overGroupId) {
|
if (activeGroupId !== overGroupId) {
|
||||||
const canContinue = await checkTaskDependencyStatus(task.id, overGroupId);
|
const canContinue = await checkTaskDependencyStatus(task.id, overGroupId);
|
||||||
if (!canContinue) {
|
if (!canContinue) {
|
||||||
@@ -455,7 +466,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update task properties based on target group
|
|
||||||
switch (groupBy) {
|
switch (groupBy) {
|
||||||
case IGroupBy.STATUS:
|
case IGroupBy.STATUS:
|
||||||
task.status = overGroupId;
|
task.status = overGroupId;
|
||||||
@@ -468,35 +478,29 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
task.priority_color_dark = targetGroup.color_code_dark;
|
task.priority_color_dark = targetGroup.color_code_dark;
|
||||||
break;
|
break;
|
||||||
case IGroupBy.PHASE:
|
case IGroupBy.PHASE:
|
||||||
// Check if ALPHA_CHANNEL is already added
|
|
||||||
const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL)
|
const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL)
|
||||||
? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) // Remove ALPHA_CHANNEL
|
? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length)
|
||||||
: targetGroup.color_code; // Use as is if not present
|
: targetGroup.color_code;
|
||||||
task.phase_id = overGroupId;
|
task.phase_id = overGroupId;
|
||||||
task.phase_color = baseColor; // Set the cleaned color
|
task.phase_color = baseColor;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTargetGroupEmpty = targetGroup.tasks.length === 0;
|
const isTargetGroupEmpty = targetGroup.tasks.length === 0;
|
||||||
|
|
||||||
// Calculate toIndex - for empty groups, always add at index 0
|
|
||||||
const toIndex = isTargetGroupEmpty
|
const toIndex = isTargetGroupEmpty
|
||||||
? 0
|
? 0
|
||||||
: overTaskId
|
: overTaskId
|
||||||
? targetGroup.tasks.findIndex(t => t.id === overTaskId)
|
? targetGroup.tasks.findIndex(t => t.id === overTaskId)
|
||||||
: targetGroup.tasks.length;
|
: targetGroup.tasks.length;
|
||||||
|
|
||||||
// Calculate toPos similar to Angular implementation
|
|
||||||
const toPos = isTargetGroupEmpty
|
const toPos = isTargetGroupEmpty
|
||||||
? -1
|
? -1
|
||||||
: targetGroup.tasks[toIndex]?.sort_order ||
|
: targetGroup.tasks[toIndex]?.sort_order ||
|
||||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||||
-1;
|
-1;
|
||||||
|
|
||||||
// Update Redux state
|
|
||||||
if (activeGroupId === overGroupId) {
|
if (activeGroupId === overGroupId) {
|
||||||
// Same group - move within array
|
|
||||||
const updatedTasks = [...sourceGroup.tasks];
|
const updatedTasks = [...sourceGroup.tasks];
|
||||||
updatedTasks.splice(fromIndex, 1);
|
updatedTasks.splice(fromIndex, 1);
|
||||||
updatedTasks.splice(toIndex, 0, task);
|
updatedTasks.splice(toIndex, 0, task);
|
||||||
@@ -514,7 +518,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Different groups - transfer between arrays
|
|
||||||
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
||||||
const updatedTargetTasks = [...targetGroup.tasks];
|
const updatedTargetTasks = [...targetGroup.tasks];
|
||||||
|
|
||||||
@@ -540,7 +543,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit socket event
|
|
||||||
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
from_index: sourceGroup.tasks[fromIndex].sort_order,
|
from_index: sourceGroup.tasks[fromIndex].sort_order,
|
||||||
@@ -549,13 +551,11 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
from_group: sourceGroup.id,
|
from_group: sourceGroup.id,
|
||||||
to_group: targetGroup.id,
|
to_group: targetGroup.id,
|
||||||
group_by: groupBy,
|
group_by: groupBy,
|
||||||
task: sourceGroup.tasks[fromIndex], // Send original task to maintain references
|
task: sourceGroup.tasks[fromIndex],
|
||||||
team_id: currentSession?.team_id,
|
team_id: currentSession?.team_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset styles
|
|
||||||
setTimeout(resetTaskRowStyles, 0);
|
setTimeout(resetTaskRowStyles, 0);
|
||||||
|
|
||||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -570,6 +570,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Memoize drag handlers
|
||||||
const handleDragOver = useCallback(
|
const handleDragOver = useCallback(
|
||||||
({ active, over }: DragEndEvent) => {
|
({ active, over }: DragEndEvent) => {
|
||||||
if (!over) return;
|
if (!over) return;
|
||||||
@@ -589,12 +590,9 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
|
|
||||||
if (fromIndex === -1 || toIndex === -1) return;
|
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]));
|
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
|
||||||
|
|
||||||
// Update Redux state
|
|
||||||
if (activeGroupId === overGroupId) {
|
if (activeGroupId === overGroupId) {
|
||||||
// Same group - move within array
|
|
||||||
const updatedTasks = [...sourceGroup.tasks];
|
const updatedTasks = [...sourceGroup.tasks];
|
||||||
updatedTasks.splice(fromIndex, 1);
|
updatedTasks.splice(fromIndex, 1);
|
||||||
updatedTasks.splice(toIndex, 0, task);
|
updatedTasks.splice(toIndex, 0, task);
|
||||||
@@ -612,10 +610,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Different groups - transfer between arrays
|
|
||||||
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
||||||
const updatedTargetTasks = [...targetGroup.tasks];
|
const updatedTargetTasks = [...targetGroup.tasks];
|
||||||
|
|
||||||
updatedTargetTasks.splice(toIndex, 0, task);
|
updatedTargetTasks.splice(toIndex, 0, task);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -663,7 +659,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
// Handle animation cleanup after drag ends
|
// Handle animation cleanup after drag ends
|
||||||
useIsomorphicLayoutEffect(() => {
|
useIsomorphicLayoutEffect(() => {
|
||||||
if (activeId === null) {
|
if (activeId === null) {
|
||||||
// Final cleanup after React updates DOM
|
|
||||||
const timeoutId = setTimeout(resetTaskRowStyles, 50);
|
const timeoutId = setTimeout(resetTaskRowStyles, 50);
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { reportingTimesheetApiService } from '@/api/reporting/reporting.timeshee
|
|||||||
import { IRPTTimeMember } from '@/types/reporting/reporting.types';
|
import { IRPTTimeMember } from '@/types/reporting/reporting.types';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
|
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 selectedProjects = filterProjects.filter(project => project.selected);
|
||||||
const selectedCategories = categories.filter(category => category.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 = {
|
const body = {
|
||||||
teams: selectedTeams.map(t => t.id),
|
teams: selectedTeams.map(t => t.id),
|
||||||
projects: selectedProjects.map(project => project.id),
|
projects: selectedProjects.map(project => project.id),
|
||||||
categories: selectedCategories.map(category => category.id),
|
categories: selectedCategories.map(category => category.id),
|
||||||
duration,
|
duration,
|
||||||
date_range: dateRange,
|
date_range: formattedDateRange,
|
||||||
billable,
|
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;
|
timer_start_time?: number;
|
||||||
recurring?: boolean;
|
recurring?: boolean;
|
||||||
task_level?: number;
|
task_level?: number;
|
||||||
|
schedule_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITaskTeamMember extends ITeamMember {
|
export interface ITaskTeamMember extends ITeamMember {
|
||||||
|
|||||||
Reference in New Issue
Block a user