Merge branch 'feature/project-list-grouping' into upstream/feature/project-groupby
This commit is contained in:
@@ -12,7 +12,7 @@ COPY . .
|
||||
RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \
|
||||
echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js
|
||||
|
||||
RUN npm run build
|
||||
RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
||||
|
||||
FROM node:22-alpine AS production
|
||||
|
||||
|
||||
@@ -1,24 +1,64 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="./favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#2b2b2b" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Worklenz</title>
|
||||
<!-- Environment configuration -->
|
||||
<script src="/env-config.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="./favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#2b2b2b" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet" />
|
||||
<title>Worklenz</title>
|
||||
|
||||
<!-- Environment configuration -->
|
||||
<script src="/env-config.js"></script>
|
||||
<!-- Unregister service worker -->
|
||||
<script src="/unregister-sw.js"></script>
|
||||
<!-- Microsoft Clarity -->
|
||||
<script type="text/javascript">
|
||||
if (window.location.hostname === 'app.worklenz.com') {
|
||||
(function (c, l, a, r, i, t, y) {
|
||||
c[a] = c[a] || function () { (c[a].q = c[a].q || []).push(arguments) };
|
||||
t = l.createElement(r); t.async = 1; t.src = "https://www.clarity.ms/tag/dx77073klh";
|
||||
y = l.getElementsByTagName(r)[0]; y.parentNode.insertBefore(t, y);
|
||||
})(window, document, "clarity", "script", "dx77073klh");
|
||||
}
|
||||
</script>
|
||||
<!-- Google Analytics (only on production) -->
|
||||
<script type="text/javascript">
|
||||
if (window.location.hostname === 'app.worklenz.com') {
|
||||
var gaScript = document.createElement('script');
|
||||
gaScript.async = true;
|
||||
gaScript.src = 'https://www.googletagmanager.com/gtag/js?id=G-7KSRKQ1397';
|
||||
document.head.appendChild(gaScript);
|
||||
|
||||
gaScript.onload = function() {
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-7KSRKQ1397');
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
<script type="text/javascript">
|
||||
if (window.location.hostname === 'app.worklenz.com') {
|
||||
var hs = document.createElement('script');
|
||||
hs.type = 'text/javascript';
|
||||
hs.id = 'hs-script-loader';
|
||||
hs.async = true;
|
||||
hs.defer = true;
|
||||
hs.src = '//js.hs-scripts.com/22348300.js';
|
||||
document.body.appendChild(hs);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
10
worklenz-frontend/package-lock.json
generated
10
worklenz-frontend/package-lock.json
generated
@@ -23,12 +23,12 @@
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@tinymce/tinymce-react": "^5.1.1",
|
||||
"antd": "^5.24.9",
|
||||
"axios": "^1.7.9",
|
||||
"axios": "^1.9.0",
|
||||
"chart.js": "^4.4.7",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"dompurify": "^3.2.5",
|
||||
"gantt-task-react": "^0.3.9",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^23.16.8",
|
||||
@@ -2852,9 +2852,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/antd": {
|
||||
"version": "5.25.3",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-5.25.3.tgz",
|
||||
"integrity": "sha512-tBBcAFRjmWM3sitxrL/FEbQL+MTQntYY5bGa5c1ZZZHXWCynkhS3Ch/gy25mGMUY1M/9Uw3pH029v/RGht1x3w==",
|
||||
"version": "5.24.9",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-5.24.9.tgz",
|
||||
"integrity": "sha512-liB+Y/JwD5/KSKbK1Z1EVAbWcoWYvWJ1s97AbbT+mOdigpJQuWwH7kG8IXNEljI7onvj0DdD43TXhSRLUu9AMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^7.2.1",
|
||||
|
||||
@@ -27,12 +27,12 @@
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@tinymce/tinymce-react": "^5.1.1",
|
||||
"antd": "^5.24.9",
|
||||
"axios": "^1.7.9",
|
||||
"axios": "^1.9.0",
|
||||
"chart.js": "^4.4.7",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"dompurify": "^3.2.5",
|
||||
"gantt-task-react": "^0.3.9",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^23.16.8",
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import MembersReportsTimeLogsTab from './members-reports-time-logs-tab';
|
||||
|
||||
type MembersReportsDrawerProps = {
|
||||
memberId: string | null;
|
||||
exportTimeLogs: () => void;
|
||||
};
|
||||
|
||||
const MembersReportsDrawer = ({ memberId, exportTimeLogs }: MembersReportsDrawerProps) => {
|
||||
return (
|
||||
<Drawer
|
||||
open={isDrawerOpen}
|
||||
onClose={handleClose}
|
||||
width={900}
|
||||
destroyOnClose
|
||||
title={
|
||||
selectedMember && (
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex gap={8} align="center" style={{ fontWeight: 500 }}>
|
||||
<Typography.Text>{selectedMember.name}</Typography.Text>
|
||||
</Flex>
|
||||
|
||||
<Space>
|
||||
<TimeWiseFilter />
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: '1', label: t('timeLogsButton'), onClick: exportTimeLogs },
|
||||
{ key: '2', label: t('activityLogsButton') },
|
||||
{ key: '3', label: t('tasksButton') },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||
{t('exportButton')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
>
|
||||
{selectedMember && <MembersReportsDrawerTabs memberId={selectedMember.id} />}
|
||||
{selectedMember && <MembersOverviewTasksStatsDrawer memberId={selectedMember.id} />}
|
||||
{selectedMember && <MembersOverviewProjectsStatsDrawer memberId={selectedMember.id} />}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default MembersReportsDrawer;
|
||||
@@ -1,41 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Flex, Skeleton } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTimeLogs } from '../contexts/TimeLogsContext';
|
||||
import { BillableFilter } from './BillableFilter';
|
||||
import { TimeLogCard } from './TimeLogCard';
|
||||
import { EmptyListPlaceholder } from './EmptyListPlaceholder';
|
||||
import { TaskDrawer } from './TaskDrawer';
|
||||
import MembersReportsDrawer from './members-reports-drawer';
|
||||
|
||||
const MembersReportsTimeLogsTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { timeLogsData, billable, setBillable, exportTimeLogs, exporting } = useTimeLogs();
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<BillableFilter billable={billable} onBillableChange={setBillable} />
|
||||
|
||||
<button onClick={exportTimeLogs} disabled={exporting}>
|
||||
{exporting ? t('exporting') : t('exportTimeLogs')}
|
||||
</button>
|
||||
|
||||
<Skeleton active loading={exporting} paragraph={{ rows: 10 }}>
|
||||
{timeLogsData.length > 0 ? (
|
||||
<Flex vertical gap={24}>
|
||||
{timeLogsData.map((logs, index) => (
|
||||
<TimeLogCard key={index} data={logs} />
|
||||
))}
|
||||
</Flex>
|
||||
) : (
|
||||
<EmptyListPlaceholder text={t('timeLogsEmptyPlaceholder')} />
|
||||
)}
|
||||
</Skeleton>
|
||||
|
||||
{createPortal(<TaskDrawer />, document.body)}
|
||||
<MembersReportsDrawer memberId={/* pass the memberId here */} exportTimeLogs={exportTimeLogs} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default MembersReportsTimeLogsTab;
|
||||
@@ -109,5 +109,13 @@
|
||||
"expiredDaysAgo": "{{days}} days ago",
|
||||
|
||||
"continueWith": "Continue with {{plan}}",
|
||||
"changeToPlan": "Change to {{plan}}"
|
||||
"changeToPlan": "Change to {{plan}}",
|
||||
"creditPlan": "Credit Plan",
|
||||
"customPlan": "Custom Plan",
|
||||
"planValidTill": "Your plan is valid till {{date}}",
|
||||
"purchaseSeatsText": "To continue, you'll need to purchase additional seats.",
|
||||
"currentSeatsText": "You currently have {{seats}} seats available.",
|
||||
"selectSeatsText": "Please select the number of additional seats to purchase.",
|
||||
"purchase": "Purchase",
|
||||
"contactSales": "Contact sales"
|
||||
}
|
||||
|
||||
@@ -29,5 +29,7 @@
|
||||
"role": "Role",
|
||||
"owner": "Owner",
|
||||
"admin": "Admin",
|
||||
"member": "Member"
|
||||
"member": "Member",
|
||||
"cannotChangeOwnerRole": "Owner role cannot be changed",
|
||||
"pendingInvitation": "Pending invitation"
|
||||
}
|
||||
|
||||
@@ -38,5 +38,14 @@
|
||||
"createClient": "Create client",
|
||||
"searchInputPlaceholder": "Search by name or email",
|
||||
"hoursPerDayValidationMessage": "Hours per day must be a number between 1 and 24",
|
||||
"noPermission": "No permission"
|
||||
"workingDaysValidationMessage": "Working days must be a positive number",
|
||||
"manDaysValidationMessage": "Man days must be a positive number",
|
||||
"noPermission": "No permission",
|
||||
"progressSettings": "Progress Settings",
|
||||
"manualProgress": "Manual Progress",
|
||||
"manualProgressTooltip": "Allow manual progress updates for tasks without subtasks",
|
||||
"weightedProgress": "Weighted Progress",
|
||||
"weightedProgressTooltip": "Calculate progress based on subtask weights",
|
||||
"timeProgress": "Time-based Progress",
|
||||
"timeProgressTooltip": "Calculate progress based on estimated time"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Appearance",
|
||||
"darkMode": "Dark Mode",
|
||||
"darkModeDescription": "Switch between light and dark mode to customize your viewing experience."
|
||||
}
|
||||
@@ -10,5 +10,6 @@
|
||||
"team-members": "Team Members",
|
||||
"teams": "Teams",
|
||||
"change-password": "Change Password",
|
||||
"language-and-region": "Language and Region"
|
||||
"language-and-region": "Language and Region",
|
||||
"appearance": "Appearance"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"hide-start-date": "Hide Start Date",
|
||||
"show-start-date": "Show Start Date",
|
||||
"hours": "Hours",
|
||||
"minutes": "Minutes"
|
||||
"minutes": "Minutes",
|
||||
"recurring": "Recurring"
|
||||
},
|
||||
"description": {
|
||||
"title": "Description",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"recurring": "Recurring",
|
||||
"recurringTaskConfiguration": "Recurring task configuration",
|
||||
"repeats": "Repeats",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"everyXDays": "Every X Days",
|
||||
"everyXWeeks": "Every X Weeks",
|
||||
"everyXMonths": "Every X Months",
|
||||
"monthly": "Monthly",
|
||||
"selectDaysOfWeek": "Select Days of the Week",
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
"thu": "Thu",
|
||||
"fri": "Fri",
|
||||
"sat": "Sat",
|
||||
"sun": "Sun",
|
||||
"monthlyRepeatType": "Monthly repeat type",
|
||||
"onSpecificDate": "On a specific date",
|
||||
"onSpecificDay": "On a specific day",
|
||||
"dateOfMonth": "Date of the month",
|
||||
"weekOfMonth": "Week of the month",
|
||||
"dayOfWeek": "Day of the week",
|
||||
"first": "First",
|
||||
"second": "Second",
|
||||
"third": "Third",
|
||||
"fourth": "Fourth",
|
||||
"last": "Last",
|
||||
"intervalDays": "Interval (days)",
|
||||
"intervalWeeks": "Interval (weeks)",
|
||||
"intervalMonths": "Interval (months)",
|
||||
"saveChanges": "Save Changes"
|
||||
}
|
||||
@@ -22,7 +22,16 @@
|
||||
"hide-start-date": "Hide Start Date",
|
||||
"show-start-date": "Show Start Date",
|
||||
"hours": "Hours",
|
||||
"minutes": "Minutes"
|
||||
"minutes": "Minutes",
|
||||
"progressValue": "Progress Value",
|
||||
"progressValueTooltip": "Set the progress percentage (0-100%)",
|
||||
"progressValueRequired": "Please enter a progress value",
|
||||
"progressValueRange": "Progress must be between 0 and 100",
|
||||
"taskWeight": "Task Weight",
|
||||
"taskWeightTooltip": "Set the weight of this subtask (percentage)",
|
||||
"taskWeightRequired": "Please enter a task weight",
|
||||
"taskWeightRange": "Weight must be between 0 and 100",
|
||||
"recurring": "Recurring"
|
||||
},
|
||||
"labels": {
|
||||
"labelInputPlaceholder": "Search or create",
|
||||
@@ -74,5 +83,11 @@
|
||||
},
|
||||
"taskActivityLogTab": {
|
||||
"title": "Activity Log"
|
||||
},
|
||||
"taskProgress": {
|
||||
"markAsDoneTitle": "Mark Task as Done?",
|
||||
"confirmMarkAsDone": "Yes, mark as done",
|
||||
"cancelMarkAsDone": "No, keep current status",
|
||||
"markAsDoneDescription": "You've set the progress to 100%. Would you like to update the task status to \"Done\"?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,5 +101,13 @@
|
||||
|
||||
"expirestoday": "hoy",
|
||||
"expirestomorrow": "mañana",
|
||||
"expiredDaysAgo": "hace {{days}} días"
|
||||
"expiredDaysAgo": "hace {{days}} días",
|
||||
"creditPlan": "Plan de Crédito",
|
||||
"customPlan": "Plan Personalizado",
|
||||
"planValidTill": "Su plan es válido hasta {{date}}",
|
||||
"purchaseSeatsText": "Para continuar, deberá comprar asientos adicionales.",
|
||||
"currentSeatsText": "Actualmente tiene {{seats}} asientos disponibles.",
|
||||
"selectSeatsText": "Seleccione el número de asientos adicionales a comprar.",
|
||||
"purchase": "Comprar",
|
||||
"contactSales": "Contactar ventas"
|
||||
}
|
||||
|
||||
@@ -29,5 +29,7 @@
|
||||
"role": "Rol",
|
||||
"owner": "Propietario",
|
||||
"admin": "Administrador",
|
||||
"member": "Miembro"
|
||||
"member": "Miembro",
|
||||
"cannotChangeOwnerRole": "El rol de Propietario no puede ser cambiado",
|
||||
"pendingInvitation": "Invitación pendiente"
|
||||
}
|
||||
|
||||
@@ -38,5 +38,14 @@
|
||||
"createClient": "Crear cliente",
|
||||
"searchInputPlaceholder": "Busca por nombre o email",
|
||||
"hoursPerDayValidationMessage": "Las horas por día deben ser un número entre 1 y 24",
|
||||
"noPermission": "Sin permiso"
|
||||
"workingDaysValidationMessage": "Los días de trabajo deben ser un número positivo",
|
||||
"manDaysValidationMessage": "Los días hombre deben ser un número positivo",
|
||||
"noPermission": "Sin permiso",
|
||||
"progressSettings": "Configuración de Progreso",
|
||||
"manualProgress": "Progreso Manual",
|
||||
"manualProgressTooltip": "Permitir actualizaciones manuales de progreso para tareas sin subtareas",
|
||||
"weightedProgress": "Progreso Ponderado",
|
||||
"weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas",
|
||||
"timeProgress": "Progreso Basado en Tiempo",
|
||||
"timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Apariencia",
|
||||
"darkMode": "Modo Oscuro",
|
||||
"darkModeDescription": "Cambia entre el modo claro y oscuro para personalizar tu experiencia visual."
|
||||
}
|
||||
@@ -10,5 +10,6 @@
|
||||
"team-members": "Miembros del equipo",
|
||||
"teams": "Equipos",
|
||||
"change-password": "Cambiar contraseña",
|
||||
"language-and-region": "Idioma y región"
|
||||
"language-and-region": "Idioma y región",
|
||||
"appearance": "Apariencia"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"hide-start-date": "Ocultar fecha de inicio",
|
||||
"show-start-date": "Mostrar fecha de inicio",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos"
|
||||
"minutes": "Minutos",
|
||||
"recurring": "Recurrente"
|
||||
},
|
||||
"description": {
|
||||
"title": "Descripción",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"recurring": "Recurrente",
|
||||
"recurringTaskConfiguration": "Configuración de tarea recurrente",
|
||||
"repeats": "Repeticiones",
|
||||
"daily": "Diario",
|
||||
"weekly": "Semanal",
|
||||
"everyXDays": "Cada X días",
|
||||
"everyXWeeks": "Cada X semanas",
|
||||
"everyXMonths": "Cada X meses",
|
||||
"monthly": "Mensual",
|
||||
"selectDaysOfWeek": "Seleccionar días de la semana",
|
||||
"mon": "Lun",
|
||||
"tue": "Mar",
|
||||
"wed": "Mié",
|
||||
"thu": "Jue",
|
||||
"fri": "Vie",
|
||||
"sat": "Sáb",
|
||||
"sun": "Dom",
|
||||
"monthlyRepeatType": "Tipo de repetición mensual",
|
||||
"onSpecificDate": "En una fecha específica",
|
||||
"onSpecificDay": "En un día específico",
|
||||
"dateOfMonth": "Fecha del mes",
|
||||
"weekOfMonth": "Semana del mes",
|
||||
"dayOfWeek": "Día de la semana",
|
||||
"first": "Primero",
|
||||
"second": "Segundo",
|
||||
"third": "Tercero",
|
||||
"fourth": "Cuarto",
|
||||
"last": "Último",
|
||||
"intervalDays": "Intervalo (días)",
|
||||
"intervalWeeks": "Intervalo (semanas)",
|
||||
"intervalMonths": "Intervalo (meses)",
|
||||
"saveChanges": "Guardar cambios"
|
||||
}
|
||||
@@ -22,7 +22,16 @@
|
||||
"hide-start-date": "Ocultar fecha de inicio",
|
||||
"show-start-date": "Mostrar fecha de inicio",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos"
|
||||
"minutes": "Minutos",
|
||||
"progressValue": "Valor de Progreso",
|
||||
"progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)",
|
||||
"progressValueRequired": "Por favor, introduce un valor de progreso",
|
||||
"progressValueRange": "El progreso debe estar entre 0 y 100",
|
||||
"taskWeight": "Peso de la Tarea",
|
||||
"taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)",
|
||||
"taskWeightRequired": "Por favor, introduce un peso para la tarea",
|
||||
"taskWeightRange": "El peso debe estar entre 0 y 100",
|
||||
"recurring": "Recurrente"
|
||||
},
|
||||
"labels": {
|
||||
"labelInputPlaceholder": "Buscar o crear",
|
||||
@@ -74,5 +83,11 @@
|
||||
},
|
||||
"taskActivityLogTab": {
|
||||
"title": "Registro de actividad"
|
||||
},
|
||||
"taskProgress": {
|
||||
"markAsDoneTitle": "¿Marcar Tarea como Completada?",
|
||||
"confirmMarkAsDone": "Sí, marcar como completada",
|
||||
"cancelMarkAsDone": "No, mantener estado actual",
|
||||
"markAsDoneDescription": "Has establecido el progreso al 100%. ¿Quieres actualizar el estado de la tarea a \"Completada\"?"
|
||||
}
|
||||
}
|
||||
@@ -101,5 +101,13 @@
|
||||
|
||||
"expirestoday": "hoje",
|
||||
"expirestomorrow": "amanhã",
|
||||
"expiredDaysAgo": "há {{days}} dias"
|
||||
"expiredDaysAgo": "há {{days}} dias",
|
||||
"creditPlan": "Plano de Crédito",
|
||||
"customPlan": "Plano Personalizado",
|
||||
"planValidTill": "Seu plano é válido até {{date}}",
|
||||
"purchaseSeatsText": "Para continuar, você precisará comprar assentos adicionais.",
|
||||
"currentSeatsText": "Atualmente você tem {{seats}} assentos disponíveis.",
|
||||
"selectSeatsText": "Selecione o número de assentos adicionais para comprar.",
|
||||
"purchase": "Comprar",
|
||||
"contactSales": "Fale com vendas"
|
||||
}
|
||||
|
||||
@@ -29,5 +29,7 @@
|
||||
"role": "Rol",
|
||||
"owner": "Propietario",
|
||||
"admin": "Administrador",
|
||||
"member": "Miembro"
|
||||
"member": "Miembro",
|
||||
"cannotChangeOwnerRole": "A função de Proprietário não pode ser alterada",
|
||||
"pendingInvitation": "Convite pendente"
|
||||
}
|
||||
|
||||
@@ -38,5 +38,14 @@
|
||||
"createClient": "Criar cliente",
|
||||
"searchInputPlaceholder": "Pesquise por nome ou email",
|
||||
"hoursPerDayValidationMessage": "As horas por dia devem ser um número entre 1 e 24",
|
||||
"noPermission": "Sem permissão"
|
||||
"workingDaysValidationMessage": "Os dias de trabalho devem ser um número positivo",
|
||||
"manDaysValidationMessage": "Os dias de homem devem ser um número positivo",
|
||||
"noPermission": "Sem permissão",
|
||||
"progressSettings": "Configurações de Progresso",
|
||||
"manualProgress": "Progresso Manual",
|
||||
"manualProgressTooltip": "Permitir atualizações manuais de progresso para tarefas sem subtarefas",
|
||||
"weightedProgress": "Progresso Ponderado",
|
||||
"weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas",
|
||||
"timeProgress": "Progresso Baseado em Tempo",
|
||||
"timeProgressTooltip": "Calcular o progresso com base no tempo estimado"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Aparência",
|
||||
"darkMode": "Modo Escuro",
|
||||
"darkModeDescription": "Alterne entre o modo claro e escuro para personalizar sua experiência de visualização."
|
||||
}
|
||||
@@ -10,5 +10,6 @@
|
||||
"team-members": "Membros da Equipe",
|
||||
"teams": "Equipes",
|
||||
"change-password": "Alterar Senha",
|
||||
"language-and-region": "Idioma e Região"
|
||||
"language-and-region": "Idioma e Região",
|
||||
"appearance": "Aparência"
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
"hide-start-date": "Ocultar data de início",
|
||||
"show-start-date": "Mostrar data de início",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos"
|
||||
"minutes": "Minutos",
|
||||
"recurring": "Recorrente"
|
||||
},
|
||||
"description": {
|
||||
"title": "Descrição",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"recurring": "Recorrente",
|
||||
"recurringTaskConfiguration": "Configuração de tarefa recorrente",
|
||||
"repeats": "Repete",
|
||||
"daily": "Diário",
|
||||
"weekly": "Semanal",
|
||||
"everyXDays": "A cada X dias",
|
||||
"everyXWeeks": "A cada X semanas",
|
||||
"everyXMonths": "A cada X meses",
|
||||
"monthly": "Mensal",
|
||||
"selectDaysOfWeek": "Selecionar dias da semana",
|
||||
"mon": "Seg",
|
||||
"tue": "Ter",
|
||||
"wed": "Qua",
|
||||
"thu": "Qui",
|
||||
"fri": "Sex",
|
||||
"sat": "Sáb",
|
||||
"sun": "Dom",
|
||||
"monthlyRepeatType": "Tipo de repetição mensal",
|
||||
"onSpecificDate": "Em uma data específica",
|
||||
"onSpecificDay": "Em um dia específico",
|
||||
"dateOfMonth": "Data do mês",
|
||||
"weekOfMonth": "Semana do mês",
|
||||
"dayOfWeek": "Dia da semana",
|
||||
"first": "Primeira",
|
||||
"second": "Segunda",
|
||||
"third": "Terceira",
|
||||
"fourth": "Quarta",
|
||||
"last": "Última",
|
||||
"intervalDays": "Intervalo (dias)",
|
||||
"intervalWeeks": "Intervalo (semanas)",
|
||||
"intervalMonths": "Intervalo (meses)",
|
||||
"saveChanges": "Salvar alterações"
|
||||
}
|
||||
@@ -22,7 +22,16 @@
|
||||
"hide-start-date": "Ocultar data de início",
|
||||
"show-start-date": "Mostrar data de início",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos"
|
||||
"minutes": "Minutos",
|
||||
"progressValue": "Valor de Progresso",
|
||||
"progressValueTooltip": "Definir a porcentagem de progresso (0-100%)",
|
||||
"progressValueRequired": "Por favor, insira um valor de progresso",
|
||||
"progressValueRange": "O progresso deve estar entre 0 e 100",
|
||||
"taskWeight": "Peso da Tarefa",
|
||||
"taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)",
|
||||
"taskWeightRequired": "Por favor, insira um peso para a tarefa",
|
||||
"taskWeightRange": "O peso deve estar entre 0 e 100",
|
||||
"recurring": "Recorrente"
|
||||
},
|
||||
"labels": {
|
||||
"labelInputPlaceholder": "Pesquisar ou criar",
|
||||
@@ -74,5 +83,11 @@
|
||||
},
|
||||
"taskActivityLogTab": {
|
||||
"title": "Registro de atividade"
|
||||
},
|
||||
"taskProgress": {
|
||||
"markAsDoneTitle": "Marcar Tarefa como Concluída?",
|
||||
"confirmMarkAsDone": "Sim, marcar como concluída",
|
||||
"cancelMarkAsDone": "Não, manter status atual",
|
||||
"markAsDoneDescription": "Você definiu o progresso como 100%. Deseja atualizar o status da tarefa para \"Concluída\"?"
|
||||
}
|
||||
}
|
||||
23
worklenz-frontend/public/unregister-sw.js
Normal file
23
worklenz-frontend/public/unregister-sw.js
Normal file
@@ -0,0 +1,23 @@
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Check if we've already attempted to unregister in this session
|
||||
if (!sessionStorage.getItem('swUnregisterAttempted')) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(registrations) {
|
||||
const ngswWorker = registrations.find(reg => reg.active?.scriptURL.includes('ngsw-worker'));
|
||||
|
||||
if (ngswWorker) {
|
||||
// Mark that we've attempted to unregister
|
||||
sessionStorage.setItem('swUnregisterAttempted', 'true');
|
||||
// Unregister the ngsw-worker
|
||||
ngswWorker.unregister().then(() => {
|
||||
// Reload the page after unregistering
|
||||
window.location.reload(true);
|
||||
});
|
||||
} else {
|
||||
// If no ngsw-worker is found, unregister any other service workers
|
||||
for(let registration of registrations) {
|
||||
registration.unregister();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import router from './app/routes';
|
||||
// Hooks & Utils
|
||||
import { useAppSelector } from './hooks/useAppSelector';
|
||||
import { initMixpanel } from './utils/mixpanelInit';
|
||||
import { initializeCsrfToken } from './api/api-client';
|
||||
|
||||
// Types & Constants
|
||||
import { Language } from './features/i18n/localesSlice';
|
||||
@@ -35,11 +36,17 @@ const App: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
});
|
||||
}, [language]);
|
||||
|
||||
// Initialize CSRF token on app startup
|
||||
useEffect(() => {
|
||||
initializeCsrfToken().catch(error => {
|
||||
logger.error('Failed to initialize CSRF token:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<ThemeWrapper>
|
||||
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
||||
<PreferenceSelector />
|
||||
</ThemeWrapper>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -112,11 +112,11 @@ export const adminCenterApiService = {
|
||||
|
||||
async updateTeam(
|
||||
team_id: string,
|
||||
team_members: IOrganizationUser[]
|
||||
body: {name: string, teamMembers: IOrganizationUser[]}
|
||||
): Promise<IServerResponse<IOrganization>> {
|
||||
const response = await apiClient.put<IServerResponse<IOrganization>>(
|
||||
`${rootUrl}/organization/team/${team_id}`,
|
||||
team_members
|
||||
body
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -4,27 +4,36 @@ import alertService from '@/services/alerts/alertService';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import config from '@/config/env';
|
||||
|
||||
export const getCsrfToken = (): string | null => {
|
||||
const match = document.cookie.split('; ').find(cookie => cookie.startsWith('XSRF-TOKEN='));
|
||||
// Store CSRF token in memory (since csrf-sync uses session-based tokens)
|
||||
let csrfToken: string | null = null;
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return decodeURIComponent(match.split('=')[1]);
|
||||
export const getCsrfToken = (): string | null => {
|
||||
return csrfToken;
|
||||
};
|
||||
|
||||
// Function to refresh CSRF token if needed
|
||||
// Function to refresh CSRF token from server
|
||||
export const refreshCsrfToken = async (): Promise<string | null> => {
|
||||
try {
|
||||
// Make a GET request to the server to get a fresh CSRF token
|
||||
await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
|
||||
return getCsrfToken();
|
||||
const response = await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
|
||||
if (response.data && response.data.token) {
|
||||
csrfToken = response.data.token;
|
||||
return csrfToken;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh CSRF token:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize CSRF token on app load
|
||||
export const initializeCsrfToken = async (): Promise<void> => {
|
||||
if (!csrfToken) {
|
||||
await refreshCsrfToken();
|
||||
}
|
||||
};
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: config.apiUrl,
|
||||
withCredentials: true,
|
||||
@@ -36,12 +45,16 @@ const apiClient = axios.create({
|
||||
|
||||
// Request interceptor
|
||||
apiClient.interceptors.request.use(
|
||||
config => {
|
||||
const token = getCsrfToken();
|
||||
if (token) {
|
||||
config.headers['X-CSRF-Token'] = token;
|
||||
async config => {
|
||||
// Ensure we have a CSRF token before making requests
|
||||
if (!csrfToken) {
|
||||
await refreshCsrfToken();
|
||||
}
|
||||
|
||||
if (csrfToken) {
|
||||
config.headers['X-CSRF-Token'] = csrfToken;
|
||||
} else {
|
||||
console.warn('No CSRF token found');
|
||||
console.warn('No CSRF token available');
|
||||
}
|
||||
return config;
|
||||
},
|
||||
@@ -84,7 +97,7 @@ apiClient.interceptors.response.use(
|
||||
(typeof errorResponse.data === 'object' &&
|
||||
errorResponse.data !== null &&
|
||||
'message' in errorResponse.data &&
|
||||
errorResponse.data.message === 'Invalid CSRF token' ||
|
||||
(errorResponse.data.message === 'invalid csrf token' || errorResponse.data.message === 'Invalid CSRF token') ||
|
||||
(error as any).code === 'EBADCSRFTOKEN')) {
|
||||
alertService.error('Security Error', 'Invalid security token. Refreshing your session...');
|
||||
|
||||
@@ -94,7 +107,7 @@ apiClient.interceptors.response.use(
|
||||
// Update the token in the failed request
|
||||
error.config.headers['X-CSRF-Token'] = newToken;
|
||||
// Retry the original request with the new token
|
||||
return axios(error.config);
|
||||
return apiClient(error.config);
|
||||
} else {
|
||||
// If token refresh failed, redirect to login
|
||||
window.location.href = '/auth/login';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { toQueryString } from '@/utils/toQueryString';
|
||||
import { IHomeTasksModel, IHomeTasksConfig } from '@/types/home/home-page.types';
|
||||
import { IMyTask } from '@/types/home/my-tasks.types';
|
||||
import { IProject } from '@/types/project/project.types';
|
||||
import { getCsrfToken } from '../api-client';
|
||||
import { getCsrfToken, refreshCsrfToken } from '../api-client';
|
||||
import config from '@/config/env';
|
||||
|
||||
const rootUrl = '/home';
|
||||
@@ -14,9 +14,18 @@ const api = createApi({
|
||||
reducerPath: 'homePageApi',
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
||||
prepareHeaders: headers => {
|
||||
headers.set('X-CSRF-Token', getCsrfToken() || '');
|
||||
prepareHeaders: async headers => {
|
||||
// Get CSRF token, refresh if needed
|
||||
let token = getCsrfToken();
|
||||
if (!token) {
|
||||
token = await refreshCsrfToken();
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.set('X-CSRF-Token', token);
|
||||
}
|
||||
headers.set('Content-Type', 'application/json');
|
||||
return headers;
|
||||
},
|
||||
credentials: 'include',
|
||||
}),
|
||||
|
||||
@@ -10,6 +10,11 @@ import { IProjectManager } from '@/types/project/projectManager.types';
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/projects`;
|
||||
|
||||
interface UpdateProjectPayload {
|
||||
id: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const projectsApiService = {
|
||||
getProjects: async (
|
||||
index: number,
|
||||
@@ -78,13 +83,11 @@ export const projectsApiService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProject: async (
|
||||
id: string,
|
||||
project: IProjectViewModel
|
||||
): Promise<IServerResponse<IProjectViewModel>> => {
|
||||
updateProject: async (payload: UpdateProjectPayload): Promise<IServerResponse<IProjectViewModel>> => {
|
||||
const { id, ...data } = payload;
|
||||
const q = toQueryString({ current_project_id: id });
|
||||
const url = `${rootUrl}/${id}${q}`;
|
||||
const response = await apiClient.put<IServerResponse<IProjectViewModel>>(`${url}`, project);
|
||||
const url = `${API_BASE_URL}/projects/${id}${q}`;
|
||||
const response = await apiClient.patch<IServerResponse<IProjectViewModel>>(url, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IProjectCategory } from '@/types/project/projectCategory.types';
|
||||
import { IProjectsViewModel } from '@/types/project/projectsViewModel.types';
|
||||
import { IServerResponse } from '@/types/common.types';
|
||||
import { IProjectMembersViewModel } from '@/types/projectMember.types';
|
||||
import { getCsrfToken } from '../api-client';
|
||||
import { getCsrfToken, refreshCsrfToken } from '../api-client';
|
||||
import config from '@/config/env';
|
||||
|
||||
const rootUrl = '/projects';
|
||||
@@ -14,9 +14,18 @@ export const projectsApi = createApi({
|
||||
reducerPath: 'projectsApi',
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
||||
prepareHeaders: headers => {
|
||||
headers.set('X-CSRF-Token', getCsrfToken() || '');
|
||||
prepareHeaders: async headers => {
|
||||
// Get CSRF token, refresh if needed
|
||||
let token = getCsrfToken();
|
||||
if (!token) {
|
||||
token = await refreshCsrfToken();
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.set('X-CSRF-Token', token);
|
||||
}
|
||||
headers.set('Content-Type', 'application/json');
|
||||
return headers;
|
||||
},
|
||||
credentials: 'include',
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,16 @@ import { ITaskLogViewModel } from "@/types/tasks/task-log-view.types";
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/task-time-log`;
|
||||
|
||||
export interface IRunningTimer {
|
||||
task_id: string;
|
||||
start_time: string;
|
||||
task_name: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
parent_task_id?: string;
|
||||
parent_task_name?: string;
|
||||
}
|
||||
|
||||
export const taskTimeLogsApiService = {
|
||||
getByTask: async (id: string) : Promise<IServerResponse<ITaskLogViewModel[]>> => {
|
||||
const response = await apiClient.get(`${rootUrl}/task/${id}`);
|
||||
@@ -26,6 +36,11 @@ export const taskTimeLogsApiService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getRunningTimers: async (): Promise<IServerResponse<IRunningTimer[]>> => {
|
||||
const response = await apiClient.get(`${rootUrl}/running-timers`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
exportToExcel(taskId: string) {
|
||||
window.location.href = `${import.meta.env.VITE_API_URL}${API_BASE_URL}/task-time-log/export/${taskId}`;
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@ const mainRoutes: RouteObject[] = [
|
||||
path: '/worklenz',
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="home" replace /> },
|
||||
{ path: 'home', element: <HomePage /> },
|
||||
{ path: 'projects', element: <ProjectList /> },
|
||||
{
|
||||
|
||||
BIN
worklenz-frontend/src/assets/images/empty-box.webp
Normal file
BIN
worklenz-frontend/src/assets/images/empty-box.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
worklenz-frontend/src/assets/images/worklenz-dark-mode.png
Normal file
BIN
worklenz-frontend/src/assets/images/worklenz-dark-mode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
BIN
worklenz-frontend/src/assets/images/worklenz-light-mode.png
Normal file
BIN
worklenz-frontend/src/assets/images/worklenz-light-mode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
@@ -1,6 +1,6 @@
|
||||
import { Flex, Typography } from 'antd';
|
||||
import logo from '../assets/images/logo.png';
|
||||
import logoDark from '@/assets/images/logo-dark-mode.png';
|
||||
import logo from '@/assets/images/worklenz-light-mode.png';
|
||||
import logoDark from '@/assets/images/worklenz-dark-mode.png';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
type AuthPageHeaderProp = {
|
||||
|
||||
@@ -8,7 +8,7 @@ type EmptyListPlaceholderProps = {
|
||||
};
|
||||
|
||||
const EmptyListPlaceholder = ({
|
||||
imageSrc = '/assets/images/empty-box.webp',
|
||||
imageSrc = 'https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp',
|
||||
imageHeight = 60,
|
||||
text,
|
||||
}: EmptyListPlaceholderProps) => {
|
||||
|
||||
24
worklenz-frontend/src/components/HubSpot.tsx
Normal file
24
worklenz-frontend/src/components/HubSpot.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const HubSpot = () => {
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.id = 'hs-script-loader';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.src = '//js.hs-scripts.com/22348300.js';
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
const existingScript = document.getElementById('hs-script-loader');
|
||||
if (existingScript) {
|
||||
existingScript.remove();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default HubSpot;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FloatButton, Space, Tooltip } from 'antd';
|
||||
import { FormatPainterOutlined } from '@ant-design/icons';
|
||||
import LanguageSelector from '../features/i18n/language-selector';
|
||||
import ThemeSelector from '../features/theme/ThemeSelector';
|
||||
// import LanguageSelector from '../features/i18n/language-selector';
|
||||
// import ThemeSelector from '../features/theme/ThemeSelector';
|
||||
|
||||
const PreferenceSelector = () => {
|
||||
return (
|
||||
@@ -17,7 +17,7 @@ const PreferenceSelector = () => {
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<ThemeSelector />
|
||||
{/* <ThemeSelector /> */}
|
||||
</Space>
|
||||
</FloatButton.Group>
|
||||
</div>
|
||||
|
||||
@@ -246,7 +246,7 @@ const CurrentPlanDetails = () => {
|
||||
|
||||
const renderFreePlan = () => (
|
||||
<Flex vertical>
|
||||
<Typography.Text strong>Free Plan</Typography.Text>
|
||||
<Typography.Text strong>{t('freePlan')}</Typography.Text>
|
||||
<Typography.Text>
|
||||
<br />-{' '}
|
||||
{freePlanSettings?.team_member_limit === 0
|
||||
@@ -309,16 +309,16 @@ const CurrentPlanDetails = () => {
|
||||
|
||||
const renderCreditSubscriptionInfo = () => {
|
||||
return <Flex vertical>
|
||||
<Typography.Text strong>Credit Plan</Typography.Text>
|
||||
<Typography.Text strong>{t('creditPlan','Credit Plan')}</Typography.Text>
|
||||
</Flex>
|
||||
};
|
||||
};
|
||||
|
||||
const renderCustomSubscriptionInfo = () => {
|
||||
return <Flex vertical>
|
||||
<Typography.Text strong>Custom Plan</Typography.Text>
|
||||
<Typography.Text>Your plan is valid till {billingInfo?.valid_till_date}</Typography.Text>
|
||||
<Typography.Text strong>{t('customPlan','Custom Plan')}</Typography.Text>
|
||||
<Typography.Text>{t('planValidTill','Your plan is valid till {{date}}',{date: billingInfo?.valid_till_date})}</Typography.Text>
|
||||
</Flex>
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -381,15 +381,15 @@ const CurrentPlanDetails = () => {
|
||||
>
|
||||
<Flex vertical gap="middle" style={{ marginTop: '8px' }}>
|
||||
<Typography.Paragraph style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}>
|
||||
To continue, you'll need to purchase additional seats.
|
||||
{t('purchaseSeatsText','To continue, you\'ll need to purchase additional seats.')}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Typography.Paragraph style={{ margin: '0 0 16px 0' }}>
|
||||
You currently have {billingInfo?.total_seats} seats available.
|
||||
{t('currentSeatsText','You currently have {{seats}} seats available.',{seats: billingInfo?.total_seats})}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Typography.Paragraph style={{ margin: '0 0 24px 0' }}>
|
||||
Please select the number of additional seats to purchase.
|
||||
{t('selectSeatsText','Please select the number of additional seats to purchase.')}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
@@ -416,14 +416,14 @@ const CurrentPlanDetails = () => {
|
||||
borderRadius: '2px'
|
||||
}}
|
||||
>
|
||||
Purchase
|
||||
{t('purchase','Purchase')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="middle"
|
||||
>
|
||||
Contact sales
|
||||
{t('contactSales','Contact sales')}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -10,22 +10,17 @@ import {
|
||||
Table,
|
||||
TableProps,
|
||||
Typography,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleSettingDrawer, updateTeam } from '@/features/teams/teamSlice';
|
||||
import { TeamsType } from '@/types/admin-center/team.types';
|
||||
import './settings-drawer.css';
|
||||
import CustomAvatar from '@/components/CustomAvatar';
|
||||
import { teamsApiService } from '@/api/teams/teams.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import {
|
||||
IOrganizationTeam,
|
||||
IOrganizationTeamMember,
|
||||
} from '@/types/admin-center/admin-center.types';
|
||||
import Avatars from '@/components/avatars/avatars';
|
||||
import { AvatarNamesMap } from '@/shared/constants';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -68,26 +63,30 @@ const SettingTeamDrawer: React.FC<SettingTeamDrawerProps> = ({
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (values: any) => {
|
||||
console.log(values);
|
||||
// const newTeam: TeamsType = {
|
||||
// teamId: teamId,
|
||||
// teamName: values.name,
|
||||
// membersCount: team?.membersCount || 1,
|
||||
// members: team?.members || ['Raveesha Dilanka'],
|
||||
// owner: values.name,
|
||||
// created: team?.created || new Date(),
|
||||
// isActive: false,
|
||||
// };
|
||||
// dispatch(updateTeam(newTeam));
|
||||
// dispatch(toggleSettingDrawer());
|
||||
// form.resetFields();
|
||||
// message.success('Team updated!');
|
||||
try {
|
||||
setUpdatingTeam(true);
|
||||
|
||||
const body = {
|
||||
name: values.name,
|
||||
teamMembers: teamData?.team_members || []
|
||||
};
|
||||
|
||||
const response = await adminCenterApiService.updateTeam(teamId, body);
|
||||
|
||||
if (response.done) {
|
||||
setIsSettingDrawerOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating team', error);
|
||||
} finally {
|
||||
setUpdatingTeam(false);
|
||||
}
|
||||
};
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'Admin', label: t('admin') },
|
||||
{ value: 'Member', label: t('member') },
|
||||
{ value: 'Owner', label: t('owner') },
|
||||
{ key: 'Admin', value: 'Admin', label: t('admin') },
|
||||
{ key: 'Member', value: 'Member', label: t('member') },
|
||||
{ key: 'Owner', value: 'Owner', label: t('owner'), disabled: true },
|
||||
];
|
||||
|
||||
const columns: TableProps['columns'] = [
|
||||
@@ -104,16 +103,57 @@ const SettingTeamDrawer: React.FC<SettingTeamDrawerProps> = ({
|
||||
{
|
||||
title: t('role'),
|
||||
key: 'role',
|
||||
render: (_, record: IOrganizationTeamMember) => (
|
||||
<div>
|
||||
render: (_, record: IOrganizationTeamMember) => {
|
||||
const handleRoleChange = (value: string) => {
|
||||
if (value === 'Owner') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the team member's role in teamData
|
||||
if (teamData && teamData.team_members) {
|
||||
const updatedMembers = teamData.team_members.map(member => {
|
||||
if (member.id === record.id) {
|
||||
return { ...member, role_name: value };
|
||||
}
|
||||
return member;
|
||||
});
|
||||
|
||||
setTeamData({
|
||||
...teamData,
|
||||
team_members: updatedMembers
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = record.role_name === 'Owner' || record.pending_invitation;
|
||||
const tooltipTitle = record.role_name === 'Owner'
|
||||
? t('cannotChangeOwnerRole')
|
||||
: record.pending_invitation
|
||||
? t('pendingInvitation')
|
||||
: '';
|
||||
|
||||
const selectComponent = (
|
||||
<Select
|
||||
style={{ width: '150px', height: '32px' }}
|
||||
options={roleOptions.map(option => ({ ...option, key: option.value }))}
|
||||
options={roleOptions}
|
||||
defaultValue={record.role_name || ''}
|
||||
disabled={record.role_name === 'Owner'}
|
||||
disabled={isDisabled}
|
||||
onChange={handleRoleChange}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isDisabled ? (
|
||||
<Tooltip title={tooltipTitle}>
|
||||
{selectComponent}
|
||||
</Tooltip>
|
||||
) : (
|
||||
selectComponent
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ const HomeTasksStatusDropdown = ({ task, teamId }: HomeTasksStatusDropdownProps)
|
||||
const { socket, connected } = useSocket();
|
||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||
const {
|
||||
refetch
|
||||
} = useGetMyTasksQuery(homeTasksConfig, {
|
||||
skip: true // Skip automatic queries entirely
|
||||
});
|
||||
refetch
|
||||
} = useGetMyTasksQuery(homeTasksConfig, {
|
||||
skip: false, // Ensure this query runs
|
||||
});
|
||||
|
||||
const [selectedStatus, setSelectedStatus] = useState<ITaskStatus | undefined>(undefined);
|
||||
|
||||
|
||||
@@ -23,14 +23,14 @@ const HomeTasksDatePicker = ({ record }: HomeTasksDatePickerProps) => {
|
||||
const { t } = useTranslation('home');
|
||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
|
||||
skip: true // Skip automatic queries entirely
|
||||
skip: false
|
||||
});
|
||||
|
||||
|
||||
// Use useMemo to avoid re-renders when record.end_date is the same
|
||||
const initialDate = useMemo(() =>
|
||||
const initialDate = useMemo(() =>
|
||||
record.end_date ? dayjs(record.end_date) : null
|
||||
, [record.end_date]);
|
||||
|
||||
, [record.end_date]);
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(initialDate);
|
||||
|
||||
// Update selected date when record changes
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Popconfirm,
|
||||
Skeleton,
|
||||
Space,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
@@ -46,7 +47,11 @@ import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse
|
||||
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
|
||||
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { setProjectData, toggleProjectDrawer, setProjectId as setDrawerProjectId } from '@/features/project/project-drawer.slice';
|
||||
import {
|
||||
setProjectData,
|
||||
toggleProjectDrawer,
|
||||
setProjectId as setDrawerProjectId,
|
||||
} from '@/features/project/project-drawer.slice';
|
||||
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { evt_projects_create } from '@/shared/worklenz-analytics-events';
|
||||
@@ -60,7 +65,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
|
||||
// State
|
||||
const [editMode, setEditMode] = useState<boolean>(false);
|
||||
const [selectedProjectManager, setSelectedProjectManager] = useState<ITeamMemberViewModel | null>(
|
||||
@@ -96,6 +101,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
working_days: project?.working_days || 0,
|
||||
man_days: project?.man_days || 0,
|
||||
hours_per_day: project?.hours_per_day || 8,
|
||||
use_manual_progress: project?.use_manual_progress || false,
|
||||
use_weighted_progress: project?.use_weighted_progress || false,
|
||||
use_time_progress: project?.use_time_progress || false,
|
||||
}),
|
||||
[project, projectStatuses, projectHealths]
|
||||
);
|
||||
@@ -155,6 +163,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
man_days: parseInt(values.man_days),
|
||||
hours_per_day: parseInt(values.hours_per_day),
|
||||
project_manager: selectedProjectManager,
|
||||
use_manual_progress: values.use_manual_progress || false,
|
||||
use_weighted_progress: values.use_weighted_progress || false,
|
||||
use_time_progress: values.use_time_progress || false,
|
||||
};
|
||||
|
||||
const action =
|
||||
@@ -169,7 +180,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
dispatch(toggleProjectDrawer());
|
||||
if (!editMode) {
|
||||
trackMixpanelEvent(evt_projects_create);
|
||||
navigate(`/worklenz/projects/${response.data.body.id}?tab=tasks-list&pinned_tab=tasks-list`);
|
||||
navigate(
|
||||
`/worklenz/projects/${response.data.body.id}?tab=tasks-list&pinned_tab=tasks-list`
|
||||
);
|
||||
}
|
||||
refetchProjects();
|
||||
window.location.reload(); // Refresh the page
|
||||
@@ -184,8 +197,17 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
logger.error('Error saving project', error);
|
||||
}
|
||||
};
|
||||
const calculateWorkingDays = (startDate: dayjs.Dayjs | null, endDate: dayjs.Dayjs | null): number => {
|
||||
if (!startDate || !endDate || !startDate.isValid() || !endDate.isValid() || startDate.isAfter(endDate)) {
|
||||
const calculateWorkingDays = (
|
||||
startDate: dayjs.Dayjs | null,
|
||||
endDate: dayjs.Dayjs | null
|
||||
): number => {
|
||||
if (
|
||||
!startDate ||
|
||||
!endDate ||
|
||||
!startDate.isValid() ||
|
||||
!endDate.isValid() ||
|
||||
startDate.isAfter(endDate)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -213,7 +235,16 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
...project,
|
||||
start_date: project.start_date ? dayjs(project.start_date) : null,
|
||||
end_date: project.end_date ? dayjs(project.end_date) : null,
|
||||
working_days: form.getFieldValue('start_date') && form.getFieldValue('end_date') ? calculateWorkingDays(form.getFieldValue('start_date'), form.getFieldValue('end_date')) : project.working_days || 0,
|
||||
working_days:
|
||||
form.getFieldValue('start_date') && form.getFieldValue('end_date')
|
||||
? calculateWorkingDays(
|
||||
form.getFieldValue('start_date'),
|
||||
form.getFieldValue('end_date')
|
||||
)
|
||||
: project.working_days || 0,
|
||||
use_manual_progress: project.use_manual_progress || false,
|
||||
use_weighted_progress: project.use_weighted_progress || false,
|
||||
use_time_progress: project.use_time_progress || false,
|
||||
});
|
||||
setSelectedProjectManager(project.project_manager || null);
|
||||
setLoading(false);
|
||||
@@ -235,7 +266,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
setLoading(true);
|
||||
resetForm();
|
||||
dispatch(setProjectData({} as IProjectViewModel));
|
||||
dispatch(setProjectId(null));
|
||||
// dispatch(setProjectId(null));
|
||||
dispatch(setDrawerProjectId(null));
|
||||
dispatch(toggleProjectDrawer());
|
||||
onClose();
|
||||
@@ -284,6 +315,49 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
setIsFormValid(isValid);
|
||||
};
|
||||
|
||||
// Progress calculation method handlers
|
||||
const handleManualProgressChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
form.setFieldsValue({
|
||||
use_manual_progress: true,
|
||||
use_weighted_progress: false,
|
||||
use_time_progress: false,
|
||||
});
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
use_manual_progress: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleWeightedProgressChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
form.setFieldsValue({
|
||||
use_manual_progress: false,
|
||||
use_weighted_progress: true,
|
||||
use_time_progress: false,
|
||||
});
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
use_weighted_progress: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeProgressChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
form.setFieldsValue({
|
||||
use_manual_progress: false,
|
||||
use_weighted_progress: false,
|
||||
use_time_progress: true,
|
||||
});
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
use_time_progress: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
// loading={loading}
|
||||
@@ -329,12 +403,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
}
|
||||
>
|
||||
{!isEditable && (
|
||||
<Alert
|
||||
message={t('noPermission')}
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Alert message={t('noPermission')} type="warning" showIcon style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
<Skeleton active paragraph={{ rows: 12 }} loading={projectLoading}>
|
||||
<Form
|
||||
@@ -395,14 +464,11 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
|
||||
<Form.Item name="date" layout="horizontal">
|
||||
<Flex gap={8}>
|
||||
<Form.Item
|
||||
name="start_date"
|
||||
label={t('startDate')}
|
||||
>
|
||||
<Form.Item name="start_date" label={t('startDate')}>
|
||||
<DatePicker
|
||||
disabledDate={disabledStartDate}
|
||||
disabled={!isProjectManager && !isOwnerorAdmin}
|
||||
onChange={(date) => {
|
||||
onChange={date => {
|
||||
const endDate = form.getFieldValue('end_date');
|
||||
if (date && endDate) {
|
||||
const days = calculateWorkingDays(date, endDate);
|
||||
@@ -411,14 +477,11 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="end_date"
|
||||
label={t('endDate')}
|
||||
>
|
||||
<Form.Item name="end_date" label={t('endDate')}>
|
||||
<DatePicker
|
||||
disabledDate={disabledEndDate}
|
||||
disabled={!isProjectManager && !isOwnerorAdmin}
|
||||
onChange={(date) => {
|
||||
onChange={date => {
|
||||
const startDate = form.getFieldValue('start_date');
|
||||
if (startDate && date) {
|
||||
const days = calculateWorkingDays(startDate, date);
|
||||
@@ -429,22 +492,51 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
{/* <Form.Item
|
||||
|
||||
<Form.Item
|
||||
name="working_days"
|
||||
label={t('estimateWorkingDays')}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (value === undefined || value >= 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t('workingDaysValidationMessage', { min: 0 })));
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input type="number" min={0} disabled={!isProjectManager && !isOwnerorAdmin} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="man_days"
|
||||
label={t('estimateManDays')}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (value === undefined || value >= 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t('manDaysValidationMessage', { min: 0 })));
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
disabled // Make it read-only since it's calculated
|
||||
min={0}
|
||||
disabled={!isProjectManager && !isOwnerorAdmin}
|
||||
onBlur={e => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (value < 0) {
|
||||
form.setFieldsValue({ man_days: 0 });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item> */}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="working_days" label={t('estimateWorkingDays')}>
|
||||
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
|
||||
</Form.Item>
|
||||
<Form.Item name="man_days" label={t('estimateManDays')}>
|
||||
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="hours_per_day"
|
||||
label={t('hoursPerDay')}
|
||||
@@ -454,12 +546,80 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
if (value === undefined || (value >= 0 && value <= 24)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t('hoursPerDayValidationMessage', { min: 0, max: 24 })));
|
||||
return Promise.reject(
|
||||
new Error(t('hoursPerDayValidationMessage', { min: 0, max: 24 }))
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
disabled={!isProjectManager && !isOwnerorAdmin}
|
||||
onBlur={e => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (value < 0) {
|
||||
form.setFieldsValue({ hours_per_day: 8 });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="left">{t('progressSettings')}</Divider>
|
||||
|
||||
<Form.Item
|
||||
name="use_manual_progress"
|
||||
label={
|
||||
<Space>
|
||||
<Typography.Text>{t('manualProgress')}</Typography.Text>
|
||||
<Tooltip title={t('manualProgressTooltip')}>
|
||||
<Button type="text" size="small" icon={<Typography.Text>ⓘ</Typography.Text>} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch
|
||||
onChange={handleManualProgressChange}
|
||||
disabled={!isProjectManager && !isOwnerorAdmin}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="use_weighted_progress"
|
||||
label={
|
||||
<Space>
|
||||
<Typography.Text>{t('weightedProgress')}</Typography.Text>
|
||||
<Tooltip title={t('weightedProgressTooltip')}>
|
||||
<Button type="text" size="small" icon={<Typography.Text>ⓘ</Typography.Text>} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch
|
||||
onChange={handleWeightedProgressChange}
|
||||
disabled={!isProjectManager && !isOwnerorAdmin}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="use_time_progress"
|
||||
label={
|
||||
<Space>
|
||||
<Typography.Text>{t('timeProgress')}</Typography.Text>
|
||||
<Tooltip title={t('timeProgressTooltip')}>
|
||||
<Button type="text" size="small" icon={<Typography.Text>ⓘ</Typography.Text>} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch
|
||||
onChange={handleTimeProgressChange}
|
||||
disabled={!isProjectManager && !isOwnerorAdmin}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -111,6 +111,32 @@ const TaskDrawerActivityLog = () => {
|
||||
</Tag>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case IActivityLogAttributeTypes.PROGRESS:
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<Tag color="blue">
|
||||
{activity.previous || '0'}%
|
||||
</Tag>
|
||||
<ArrowRightOutlined />
|
||||
<Tag color="blue">
|
||||
{activity.current || '0'}%
|
||||
</Tag>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case IActivityLogAttributeTypes.WEIGHT:
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<Tag color="purple">
|
||||
Weight: {activity.previous || '100'}
|
||||
</Tag>
|
||||
<ArrowRightOutlined />
|
||||
<Tag color="purple">
|
||||
Weight: {activity.current || '100'}
|
||||
</Tag>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { Form, InputNumber, Tooltip, Modal } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import Flex from 'antd/lib/flex';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types';
|
||||
import { setTaskStatus } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { updateBoardTaskStatus } from '@/features/board/board-slice';
|
||||
import { updateTaskProgress, updateTaskStatus } from '@/features/tasks/tasks.slice';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
|
||||
interface TaskDrawerProgressProps {
|
||||
task: ITaskViewModel;
|
||||
form: any;
|
||||
}
|
||||
|
||||
const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
|
||||
const { t } = useTranslation('task-drawer/task-drawer');
|
||||
const dispatch = useAppDispatch();
|
||||
const { tab } = useTabSearchParam();
|
||||
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
const { socket, connected } = useSocket();
|
||||
const [isCompletionModalVisible, setIsCompletionModalVisible] = useState(false);
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const isSubTask = !!task?.parent_task_id;
|
||||
// Safe handling of sub_tasks_count which might be undefined in some cases
|
||||
const hasSubTasks = (task?.sub_tasks_count || 0) > 0;
|
||||
|
||||
// HIGHEST PRIORITY CHECK: Never show progress inputs for parent tasks with subtasks
|
||||
if (hasSubTasks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Never show manual progress input for parent tasks (tasks with subtasks)
|
||||
// Only show progress input for tasks without subtasks
|
||||
const showManualProgressInput = !hasSubTasks;
|
||||
|
||||
// Only show weight input for subtasks in weighted progress mode
|
||||
const showTaskWeightInput = project?.use_weighted_progress && isSubTask && !hasSubTasks;
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for progress updates from the server
|
||||
const handleProgressUpdate = (data: any) => {
|
||||
if (data.task_id === task.id) {
|
||||
if (data.progress_value !== undefined) {
|
||||
form.setFieldsValue({ progress_value: data.progress_value });
|
||||
}
|
||||
if (data.weight !== undefined) {
|
||||
form.setFieldsValue({ weight: data.weight });
|
||||
}
|
||||
|
||||
// Check if we should prompt the user to mark the task as done
|
||||
if (data.should_prompt_for_done) {
|
||||
setIsCompletionModalVisible(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket?.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleProgressUpdate);
|
||||
|
||||
// When the component mounts, explicitly request the latest progress for this task
|
||||
if (connected && task.id) {
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket?.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleProgressUpdate);
|
||||
};
|
||||
}, [socket, connected, task.id, form]);
|
||||
|
||||
// One last check before rendering
|
||||
if (hasSubTasks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleProgressChange = (value: number | null) => {
|
||||
if (connected && task.id && value !== null && !hasSubTasks) {
|
||||
// Check if progress is set to 100% to show completion confirmation
|
||||
if (value === 100) {
|
||||
setIsCompletionModalVisible(true);
|
||||
}
|
||||
|
||||
// Ensure parent_task_id is not undefined
|
||||
const parent_task_id = task.parent_task_id || null;
|
||||
|
||||
socket?.emit(
|
||||
SocketEvents.UPDATE_TASK_PROGRESS.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
progress_value: value,
|
||||
parent_task_id: parent_task_id,
|
||||
})
|
||||
);
|
||||
|
||||
socket?.once(SocketEvents.GET_TASK_PROGRESS.toString(), (data: any) => {
|
||||
dispatch(
|
||||
updateTaskProgress({
|
||||
taskId: task.id,
|
||||
progress: data.complete_ratio,
|
||||
totalTasksCount: data.total_tasks_count,
|
||||
completedCount: data.completed_count,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
if (task.id) {
|
||||
setTimeout(() => {
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// If this is a subtask, request the parent's progress to be updated in UI
|
||||
if (parent_task_id) {
|
||||
setTimeout(() => {
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), parent_task_id);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleWeightChange = (value: number | null) => {
|
||||
if (connected && task.id && value !== null && !hasSubTasks) {
|
||||
// Ensure parent_task_id is not undefined
|
||||
const parent_task_id = task.parent_task_id || null;
|
||||
|
||||
socket?.emit(
|
||||
SocketEvents.UPDATE_TASK_WEIGHT.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
weight: value,
|
||||
parent_task_id: parent_task_id,
|
||||
})
|
||||
);
|
||||
|
||||
// If this is a subtask, request the parent's progress to be updated in UI
|
||||
if (parent_task_id) {
|
||||
setTimeout(() => {
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), parent_task_id);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkTaskAsComplete = () => {
|
||||
// Close the modal
|
||||
setIsCompletionModalVisible(false);
|
||||
|
||||
// Find a "Done" status for this project
|
||||
if (connected && task.id) {
|
||||
// Emit socket event to get "done" category statuses
|
||||
socket?.emit(
|
||||
SocketEvents.GET_DONE_STATUSES.toString(),
|
||||
task.project_id,
|
||||
(doneStatuses: any[]) => {
|
||||
if (doneStatuses && doneStatuses.length > 0) {
|
||||
// Use the first "done" status
|
||||
const doneStatusId = doneStatuses[0].id;
|
||||
|
||||
// Emit socket event to update the task status
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
status_id: doneStatusId,
|
||||
project_id: task.project_id,
|
||||
team_id: currentSession?.team_id,
|
||||
parent_task: task.parent_task_id || null,
|
||||
})
|
||||
);
|
||||
socket?.once(
|
||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||
(data: ITaskListStatusChangeResponse) => {
|
||||
dispatch(setTaskStatus(data));
|
||||
|
||||
if (tab === 'tasks-list') {
|
||||
dispatch(updateTaskStatus(data));
|
||||
}
|
||||
if (tab === 'board') {
|
||||
dispatch(updateBoardTaskStatus(data));
|
||||
}
|
||||
if (data.parent_task)
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), data.parent_task);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
logger.error(`No "done" statuses found for project ${task.project_id}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const percentFormatter = (value: number | undefined) => (value ? `${value}%` : '0%');
|
||||
const percentParser = (value: string | undefined) => {
|
||||
const parsed = parseInt(value?.replace('%', '') || '0', 10);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
if (!showManualProgressInput && !showTaskWeightInput) {
|
||||
return null; // Don't show any progress inputs if not applicable
|
||||
}
|
||||
|
||||
// Final safety check
|
||||
if (hasSubTasks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showTaskWeightInput && (
|
||||
<Form.Item
|
||||
name="weight"
|
||||
label={
|
||||
<Flex align="center" gap={4}>
|
||||
{t('taskInfoTab.details.taskWeight')}
|
||||
<Tooltip title={t('taskInfoTab.details.taskWeightTooltip')}>
|
||||
<QuestionCircleOutlined />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
}
|
||||
rules={[
|
||||
{
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 100,
|
||||
message: t('taskInfoTab.details.taskWeightRange'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
formatter={percentFormatter}
|
||||
parser={percentParser}
|
||||
onBlur={e => {
|
||||
let value = percentParser(e.target.value);
|
||||
// Ensure value doesn't exceed 100
|
||||
if (value > 100) {
|
||||
value = 100;
|
||||
form.setFieldsValue({ weight: 100 });
|
||||
}
|
||||
handleWeightChange(value);
|
||||
}}
|
||||
onChange={value => {
|
||||
if (value !== null && value > 100) {
|
||||
form.setFieldsValue({ weight: 100 });
|
||||
handleWeightChange(100);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{showManualProgressInput && (
|
||||
<Form.Item
|
||||
name="progress_value"
|
||||
label={
|
||||
<Flex align="center" gap={4}>
|
||||
{t('taskInfoTab.details.progressValue')}
|
||||
<Tooltip title={t('taskInfoTab.details.progressValueTooltip')}>
|
||||
<QuestionCircleOutlined />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
}
|
||||
rules={[
|
||||
{
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 100,
|
||||
message: t('taskInfoTab.details.progressValueRange'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
formatter={percentFormatter}
|
||||
parser={percentParser}
|
||||
onBlur={e => {
|
||||
let value = percentParser(e.target.value);
|
||||
// Ensure value doesn't exceed 100
|
||||
if (value > 100) {
|
||||
value = 100;
|
||||
form.setFieldsValue({ progress_value: 100 });
|
||||
}
|
||||
handleProgressChange(value);
|
||||
}}
|
||||
onChange={value => {
|
||||
if (value !== null && value > 100) {
|
||||
form.setFieldsValue({ progress_value: 100 });
|
||||
handleProgressChange(100);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={t('taskProgress.markAsDoneTitle', 'Mark Task as Done?')}
|
||||
open={isCompletionModalVisible}
|
||||
onOk={handleMarkTaskAsComplete}
|
||||
onCancel={() => setIsCompletionModalVisible(false)}
|
||||
okText={t('taskProgress.confirmMarkAsDone', 'Yes, mark as done')}
|
||||
cancelText={t('taskProgress.cancelMarkAsDone', 'No, keep current status')}
|
||||
>
|
||||
<p>{t('taskProgress.markAsDoneDescription', 'You\'ve set the progress to 100%. Would you like to update the task status to "Done"?')}</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDrawerProgress;
|
||||
@@ -0,0 +1,382 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Switch,
|
||||
Button,
|
||||
Popover,
|
||||
Select,
|
||||
Checkbox,
|
||||
Radio,
|
||||
InputNumber,
|
||||
Skeleton,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { IRepeatOption, ITaskRecurring, ITaskRecurringSchedule, ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { updateRecurringChange } from '@/features/tasks/tasks.slice';
|
||||
import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice';
|
||||
|
||||
const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1);
|
||||
|
||||
const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('task-drawer/task-drawer-recurring-config');
|
||||
|
||||
const repeatOptions: IRepeatOption[] = [
|
||||
{ label: t('daily'), value: ITaskRecurring.Daily },
|
||||
{ label: t('weekly'), value: ITaskRecurring.Weekly },
|
||||
{ label: t('everyXDays'), value: ITaskRecurring.EveryXDays },
|
||||
{ label: t('everyXWeeks'), value: ITaskRecurring.EveryXWeeks },
|
||||
{ label: t('everyXMonths'), value: ITaskRecurring.EveryXMonths },
|
||||
{ label: t('monthly'), value: ITaskRecurring.Monthly },
|
||||
];
|
||||
|
||||
const daysOfWeek = [
|
||||
{ label: t('sun'), value: 0, checked: false },
|
||||
{ label: t('mon'), value: 1, checked: false },
|
||||
{ label: t('tue'), value: 2, checked: false },
|
||||
{ label: t('wed'), value: 3, checked: false },
|
||||
{ label: t('thu'), value: 4, checked: false },
|
||||
{ label: t('fri'), value: 5, checked: false },
|
||||
{ label: t('sat'), value: 6, checked: false }
|
||||
];
|
||||
|
||||
const weekOptions = [
|
||||
{ label: t('first'), value: 1 },
|
||||
{ label: t('second'), value: 2 },
|
||||
{ label: t('third'), value: 3 },
|
||||
{ label: t('fourth'), value: 4 },
|
||||
{ label: t('last'), value: 5 }
|
||||
];
|
||||
|
||||
const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value }));
|
||||
|
||||
const [recurring, setRecurring] = useState(false);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [repeatOption, setRepeatOption] = useState<IRepeatOption>({});
|
||||
const [selectedDays, setSelectedDays] = useState<number[]>([]);
|
||||
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: number[]) => {
|
||||
setSelectedDays(checkedValues);
|
||||
};
|
||||
|
||||
const getSelectedDays = () => {
|
||||
return daysOfWeek
|
||||
.filter(day => day.checked) // Get only the checked days
|
||||
.map(day => day.value); // Extract their numeric values
|
||||
}
|
||||
|
||||
const getUpdateBody = () => {
|
||||
if (!task.id || !task.schedule_id || !repeatOption.value) return;
|
||||
|
||||
const body: ITaskRecurringSchedule = {
|
||||
id: task.id,
|
||||
schedule_type: repeatOption.value
|
||||
};
|
||||
|
||||
switch (repeatOption.value) {
|
||||
case ITaskRecurring.Weekly:
|
||||
body.days_of_week = getSelectedDays();
|
||||
break;
|
||||
|
||||
case ITaskRecurring.Monthly:
|
||||
if (monthlyOption === 'date') {
|
||||
body.date_of_month = selectedMonthlyDate;
|
||||
setSelectedMonthlyDate(0);
|
||||
setSelectedMonthlyDay(0);
|
||||
} else {
|
||||
body.week_of_month = selectedMonthlyWeek;
|
||||
body.day_of_month = selectedMonthlyDay;
|
||||
setSelectedMonthlyDate(0);
|
||||
}
|
||||
break;
|
||||
|
||||
case ITaskRecurring.EveryXDays:
|
||||
body.interval_days = intervalDays;
|
||||
break;
|
||||
|
||||
case ITaskRecurring.EveryXWeeks:
|
||||
body.interval_weeks = intervalWeeks;
|
||||
break;
|
||||
|
||||
case ITaskRecurring.EveryXMonths:
|
||||
body.interval_months = intervalMonths;
|
||||
break;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!task.id || !task.schedule_id) return;
|
||||
|
||||
try {
|
||||
setUpdatingData(true);
|
||||
const body = getUpdateBody();
|
||||
|
||||
const res = await taskRecurringApiService.updateTaskRecurringData(task.schedule_id, body);
|
||||
if (res.done) {
|
||||
setRecurring(true);
|
||||
setShowConfig(false);
|
||||
configVisibleChange(false);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("handleSave", e);
|
||||
} finally {
|
||||
setUpdatingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDaysOfWeek = () => {
|
||||
for (let i = 0; i < daysOfWeek.length; i++) {
|
||||
daysOfWeek[i].checked = scheduleData.days_of_week?.includes(daysOfWeek[i].value) ?? false;
|
||||
}
|
||||
};
|
||||
|
||||
const getScheduleData = async () => {
|
||||
if (!task.schedule_id) return;
|
||||
setLoadingData(true);
|
||||
try {
|
||||
const res = await taskRecurringApiService.getTaskRecurringData(task.schedule_id);
|
||||
if (res.done) {
|
||||
setScheduleData(res.body);
|
||||
if (!res.body) {
|
||||
setRepeatOption(repeatOptions[0]);
|
||||
} else {
|
||||
const selected = repeatOptions.find(e => e.value == res.body.schedule_type);
|
||||
if (selected) {
|
||||
setRepeatOption(selected);
|
||||
setSelectedMonthlyDate(scheduleData.date_of_month || 1);
|
||||
setSelectedMonthlyDay(scheduleData.day_of_month || 0);
|
||||
setSelectedMonthlyWeek(scheduleData.week_of_month || 0);
|
||||
setIntervalDays(scheduleData.interval_days || 1);
|
||||
setIntervalWeeks(scheduleData.interval_weeks || 1);
|
||||
setIntervalMonths(scheduleData.interval_months || 1);
|
||||
setMonthlyOption(selectedMonthlyDate ? 'date' : 'day');
|
||||
updateDaysOfWeek();
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
logger.error("getScheduleData", e);
|
||||
}
|
||||
finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleResponse = (response: ITaskRecurringScheduleData) => {
|
||||
if (!task || !response.task_id) return;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!task) return;
|
||||
|
||||
if (task) setRecurring(!!task.schedule_id);
|
||||
if (task.schedule_id) void getScheduleData();
|
||||
socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse);
|
||||
}, [task?.schedule_id]);
|
||||
|
||||
return (
|
||||
<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={t('recurringTaskConfiguration')}
|
||||
content={
|
||||
<Skeleton loading={loadingData} active>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={t('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={t('selectDaysOfWeek')}>
|
||||
<Checkbox.Group
|
||||
options={daysOfWeek.map(day => ({
|
||||
label: day.label,
|
||||
value: day.value
|
||||
}))}
|
||||
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={t('monthlyRepeatType')}>
|
||||
<Radio.Group
|
||||
value={monthlyOption}
|
||||
onChange={e => setMonthlyOption(e.target.value)}
|
||||
>
|
||||
<Radio.Button value="date">{t('onSpecificDate')}</Radio.Button>
|
||||
<Radio.Button value="day">{t('onSpecificDay')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{monthlyOption === 'date' && (
|
||||
<Form.Item label={t('dateOfMonth')}>
|
||||
<Select
|
||||
value={selectedMonthlyDate}
|
||||
onChange={setSelectedMonthlyDate}
|
||||
options={monthlyDateOptions.map(date => ({
|
||||
label: date.toString(),
|
||||
value: date,
|
||||
}))}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{monthlyOption === 'day' && (
|
||||
<>
|
||||
<Form.Item label={t('weekOfMonth')}>
|
||||
<Select
|
||||
value={selectedMonthlyWeek}
|
||||
onChange={setSelectedMonthlyWeek}
|
||||
options={weekOptions}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dayOfWeek')}>
|
||||
<Select
|
||||
value={selectedMonthlyDay}
|
||||
onChange={setSelectedMonthlyDay}
|
||||
options={dayOptions}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{repeatOption.value === ITaskRecurring.EveryXDays && (
|
||||
<Form.Item label={t('intervalDays')}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={intervalDays}
|
||||
onChange={value => value && setIntervalDays(value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{repeatOption.value === ITaskRecurring.EveryXWeeks && (
|
||||
<Form.Item label={t('intervalWeeks')}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={intervalWeeks}
|
||||
onChange={value => value && setIntervalWeeks(value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{repeatOption.value === ITaskRecurring.EveryXMonths && (
|
||||
<Form.Item label={t('intervalMonths')}>
|
||||
<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}
|
||||
>
|
||||
{t('saveChanges')}
|
||||
</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;
|
||||
@@ -210,10 +210,11 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText="Yes"
|
||||
cancelText="No"
|
||||
onConfirm={() => handleDeleteSubTask(record.id)}
|
||||
onPopupClick={(e) => e.stopPropagation()}
|
||||
onConfirm={(e) => {handleDeleteSubTask(record.id)}}
|
||||
>
|
||||
<Tooltip title="Delete">
|
||||
<Button shape="default" icon={<DeleteOutlined />} size="small" />
|
||||
<Button shape="default" icon={<DeleteOutlined />} size="small" onClick={(e)=> e.stopPropagation()} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Flex>
|
||||
|
||||
@@ -26,14 +26,59 @@ import TaskDrawerDueDate from './details/task-drawer-due-date/task-drawer-due-da
|
||||
import TaskDrawerEstimation from './details/task-drawer-estimation/task-drawer-estimation';
|
||||
import TaskDrawerPrioritySelector from './details/task-drawer-priority-selector/task-drawer-priority-selector';
|
||||
import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billable';
|
||||
import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progress';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/task-drawer-recurring-config';
|
||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
|
||||
interface TaskDetailsFormProps {
|
||||
taskFormViewModel?: ITaskFormViewModel | null;
|
||||
}
|
||||
|
||||
// Custom wrapper that enforces stricter rules for displaying progress input
|
||||
interface ConditionalProgressInputProps {
|
||||
task: ITaskViewModel;
|
||||
form: any; // Using any for the form as the exact type may be complex
|
||||
}
|
||||
|
||||
const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps) => {
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
const hasSubTasks = task?.sub_tasks_count > 0;
|
||||
const isSubTask = !!task?.parent_task_id;
|
||||
|
||||
// STRICT RULE: Never show progress input for parent tasks with subtasks
|
||||
// This is the most important check and must be done first
|
||||
if (hasSubTasks) {
|
||||
logger.debug(`Task ${task.id} has ${task.sub_tasks_count} subtasks. Hiding progress input.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only for tasks without subtasks, determine which input to show based on project mode
|
||||
if (project?.use_time_progress) {
|
||||
// In time-based mode, show progress input ONLY for tasks without subtasks
|
||||
return (
|
||||
<TaskDrawerProgress task={{ ...task, sub_tasks_count: hasSubTasks ? 1 : 0 }} form={form} />
|
||||
);
|
||||
} else if (project?.use_manual_progress) {
|
||||
// In manual mode, show progress input ONLY for tasks without subtasks
|
||||
return (
|
||||
<TaskDrawerProgress task={{ ...task, sub_tasks_count: hasSubTasks ? 1 : 0 }} form={form} />
|
||||
);
|
||||
} else if (project?.use_weighted_progress && isSubTask) {
|
||||
// In weighted mode, show weight input for subtasks
|
||||
return (
|
||||
<TaskDrawerProgress task={{ ...task, sub_tasks_count: hasSubTasks ? 1 : 0 }} form={form} />
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => {
|
||||
const { t } = useTranslation('task-drawer/task-drawer');
|
||||
const [form] = Form.useForm();
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskFormViewModel) {
|
||||
@@ -53,6 +98,8 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
||||
labels: task?.labels || [],
|
||||
billable: task?.billable || false,
|
||||
notify: [],
|
||||
progress_value: task?.progress_value || null,
|
||||
weight: task?.weight || null,
|
||||
});
|
||||
}, [taskFormViewModel, form]);
|
||||
|
||||
@@ -89,6 +136,8 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
billable: false,
|
||||
progress_value: null,
|
||||
weight: null,
|
||||
}}
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
@@ -103,7 +152,13 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
||||
|
||||
<Form.Item name="assignees" label={t('taskInfoTab.details.assignees')}>
|
||||
<Flex gap={4} align="center">
|
||||
<Avatars members={taskFormViewModel?.task?.names || []} />
|
||||
<Avatars
|
||||
members={
|
||||
taskFormViewModel?.task?.assignee_names ||
|
||||
(taskFormViewModel?.task?.names as unknown as InlineMember[]) ||
|
||||
[]
|
||||
}
|
||||
/>
|
||||
<TaskDrawerAssigneeSelector
|
||||
task={(taskFormViewModel?.task as ITaskViewModel) || null}
|
||||
/>
|
||||
@@ -114,6 +169,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
||||
|
||||
<TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} />
|
||||
|
||||
{taskFormViewModel?.task && (
|
||||
<ConditionalProgressInput task={taskFormViewModel?.task as ITaskViewModel} form={form} />
|
||||
)}
|
||||
|
||||
<Form.Item name="priority" label={t('taskInfoTab.details.priority')}>
|
||||
<TaskDrawerPrioritySelector task={taskFormViewModel?.task as ITaskViewModel} />
|
||||
</Form.Item>
|
||||
@@ -124,6 +183,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
||||
<TaskDrawerBillable task={taskFormViewModel?.task as ITaskViewModel} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="recurring" label={t('taskInfoTab.details.recurring')}>
|
||||
<TaskDrawerRecurringConfig task={taskFormViewModel?.task as ITaskViewModel} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="notify" label={t('taskInfoTab.details.notify')}>
|
||||
<NotifyMemberSelector task={taskFormViewModel?.task as ITaskViewModel} t={t} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -125,7 +125,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<ReloadOutlined spin={loadingSubTasks} />}
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation(); // Prevent click from bubbling up
|
||||
fetchSubTasks();
|
||||
}}
|
||||
@@ -182,19 +182,15 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
||||
label: <Typography.Text strong>{t('taskInfoTab.comments.title')}</Typography.Text>,
|
||||
style: panelStyle,
|
||||
className: 'custom-task-drawer-info-collapse',
|
||||
children: (
|
||||
<TaskComments
|
||||
taskId={selectedTaskId || ''}
|
||||
t={t}
|
||||
/>
|
||||
),
|
||||
children: <TaskComments taskId={selectedTaskId || ''} t={t} />,
|
||||
},
|
||||
];
|
||||
|
||||
// Filter out the 'subTasks' item if this task is a subtask
|
||||
const infoItems = taskFormViewModel?.task?.parent_task_id
|
||||
? allInfoItems.filter(item => item.key !== 'subTasks')
|
||||
: allInfoItems;
|
||||
// Filter out the 'subTasks' item if this task is more than level 2
|
||||
const infoItems =
|
||||
(taskFormViewModel?.task?.task_level ?? 0) >= 2
|
||||
? allInfoItems.filter(item => item.key !== 'subTasks')
|
||||
: allInfoItems;
|
||||
|
||||
const fetchSubTasks = async () => {
|
||||
if (!selectedTaskId || loadingSubTasks) return;
|
||||
@@ -281,7 +277,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
||||
defaultActiveKey={[
|
||||
'details',
|
||||
'description',
|
||||
...(taskFormViewModel?.task?.parent_task_id ? [] : ['subTasks']),
|
||||
'subTasks',
|
||||
'dependencies',
|
||||
'attachments',
|
||||
'comments',
|
||||
|
||||
@@ -27,6 +27,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const { clearTaskFromUrl } = useTaskDrawerUrlSync();
|
||||
const isDeleting = useRef(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
||||
const [taskName, setTaskName] = useState<string>(taskFormViewModel?.task?.name ?? '');
|
||||
@@ -88,6 +89,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
setIsEditing(false);
|
||||
if (
|
||||
!selectedTaskId ||
|
||||
!connected ||
|
||||
@@ -113,21 +115,39 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
return (
|
||||
<Flex gap={12} align="center" style={{ marginBlockEnd: 6 }}>
|
||||
<Flex style={{ position: 'relative', width: '100%' }}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size="large"
|
||||
value={taskName}
|
||||
onChange={e => onTaskNameChange(e)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={t('taskHeader.taskNamePlaceholder')}
|
||||
className="task-name-input"
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
showCount={false}
|
||||
maxLength={250}
|
||||
/>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size="large"
|
||||
value={taskName}
|
||||
onChange={e => onTaskNameChange(e)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={t('taskHeader.taskNamePlaceholder')}
|
||||
className="task-name-input"
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
showCount={true}
|
||||
maxLength={250}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
onClick={() => setIsEditing(true)}
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: '4px 11px',
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{taskName || t('taskHeader.taskNamePlaceholder')}
|
||||
</p>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<TaskDrawerStatusDropdown
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
|
||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||
import { Select } from 'antd';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface TaskDrawerStatusDropdownProps {
|
||||
statuses: ITaskStatus[];
|
||||
@@ -21,7 +21,7 @@ interface TaskDrawerStatusDropdownProps {
|
||||
}
|
||||
|
||||
const TaskDrawerStatusDropdown = ({ statuses, task, teamId }: TaskDrawerStatusDropdownProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const { socket } = useSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { tab } = useTabSearchParam();
|
||||
@@ -46,6 +46,7 @@ const TaskDrawerStatusDropdown = ({ statuses, task, teamId }: TaskDrawerStatusDr
|
||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||
(data: ITaskListStatusChangeResponse) => {
|
||||
dispatch(setTaskStatus(data));
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
|
||||
if (tab === 'tasks-list') {
|
||||
dispatch(updateTaskStatus(data));
|
||||
@@ -65,7 +66,6 @@ const TaskDrawerStatusDropdown = ({ statuses, task, teamId }: TaskDrawerStatusDr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const options = useMemo(
|
||||
|
||||
@@ -32,7 +32,7 @@ const TaskDrawer = () => {
|
||||
const [refreshTimeLogTrigger, setRefreshTimeLogTrigger] = useState(0);
|
||||
|
||||
const { showTaskDrawer, timeLogEditing } = useAppSelector(state => state.taskDrawerReducer);
|
||||
|
||||
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
||||
const taskNameInputRef = useRef<InputRef>(null);
|
||||
const isClosingManually = useRef(false);
|
||||
|
||||
@@ -47,20 +47,32 @@ const TaskDrawer = () => {
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleOnClose = () => {
|
||||
// Set flag to indicate we're manually closing the drawer
|
||||
isClosingManually.current = true;
|
||||
setActiveTab('info');
|
||||
|
||||
// Explicitly clear the task parameter from URL
|
||||
clearTaskFromUrl();
|
||||
|
||||
// Update the Redux state
|
||||
const resetTaskState = () => {
|
||||
dispatch(setShowTaskDrawer(false));
|
||||
dispatch(setSelectedTaskId(null));
|
||||
dispatch(setTaskFormViewModel({}));
|
||||
dispatch(setTaskSubscribers([]));
|
||||
};
|
||||
|
||||
const handleOnClose = (
|
||||
e?: React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent<Element>
|
||||
) => {
|
||||
// Set flag to indicate we're manually closing the drawer
|
||||
isClosingManually.current = true;
|
||||
setActiveTab('info');
|
||||
clearTaskFromUrl();
|
||||
|
||||
const isClickOutsideDrawer =
|
||||
e?.target && (e.target as HTMLElement).classList.contains('ant-drawer-mask');
|
||||
|
||||
if (isClickOutsideDrawer || !taskFormViewModel?.task?.is_sub_task) {
|
||||
resetTaskState();
|
||||
} else {
|
||||
dispatch(setSelectedTaskId(null));
|
||||
dispatch(setTaskFormViewModel({}));
|
||||
dispatch(setTaskSubscribers([]));
|
||||
dispatch(setSelectedTaskId(taskFormViewModel?.task?.parent_task_id || null));
|
||||
}
|
||||
// Reset the flag after a short delay
|
||||
setTimeout(() => {
|
||||
isClosingManually.current = false;
|
||||
@@ -176,8 +188,8 @@ const TaskDrawer = () => {
|
||||
// Get conditional body style
|
||||
const getBodyStyle = () => {
|
||||
const baseStyle = {
|
||||
padding: '24px',
|
||||
overflow: 'auto'
|
||||
padding: '24px',
|
||||
overflow: 'auto',
|
||||
};
|
||||
|
||||
if (activeTab === 'timeLog' && timeLogEditing.isEditing) {
|
||||
|
||||
@@ -58,7 +58,7 @@ import alertService from '@/services/alerts/alertService';
|
||||
|
||||
interface ITaskAssignee {
|
||||
id: string;
|
||||
name?: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar_url?: string;
|
||||
team_member_id: string;
|
||||
@@ -437,7 +437,7 @@ const TaskListBulkActionsBar = () => {
|
||||
placement="top"
|
||||
arrow
|
||||
trigger={['click']}
|
||||
destroyPopupOnHide
|
||||
destroyOnHidden
|
||||
onOpenChange={value => {
|
||||
if (!value) {
|
||||
setSelectedLabels([]);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import logoDark from '@/assets/images/logo-dark-mode.png';
|
||||
import logo from '@/assets/images/worklenz-light-mode.png';
|
||||
import logoDark from '@/assets/images/worklenz-dark-mode.png';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -20,23 +20,6 @@ const NavbarLogo = () => {
|
||||
alt={t('logoAlt')}
|
||||
style={{ width: '100%', maxWidth: 140 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -1,
|
||||
right: 0,
|
||||
backgroundColor: '#ff5722',
|
||||
color: 'white',
|
||||
fontSize: '7px',
|
||||
padding: '0px 3px',
|
||||
borderRadius: '3px',
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
lineHeight: '1.8',
|
||||
}}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Col, ConfigProvider, Flex, Menu, MenuProps, Alert } from 'antd';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members';
|
||||
import HelpButton from './help/HelpButton';
|
||||
import InviteButton from './invite/InviteButton';
|
||||
import MobileMenuButton from './mobileMenu/MobileMenuButton';
|
||||
import NavbarLogo from './navbar-logo';
|
||||
@@ -22,6 +21,7 @@ import { useAuthService } from '@/hooks/useAuth';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import TimerButton from './timers/timer-button';
|
||||
|
||||
const Navbar = () => {
|
||||
const [current, setCurrent] = useState<string>('home');
|
||||
@@ -90,6 +90,7 @@ const Navbar = () => {
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
|
||||
<Col
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -101,14 +102,6 @@ const Navbar = () => {
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && (
|
||||
<Alert
|
||||
message={daysUntilExpiry > 0 ? `Your license will expire in ${daysUntilExpiry} days` : `Your license has expired ${Math.abs(daysUntilExpiry)} days ago`}
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ width: '100%', marginTop: 12 }}
|
||||
/>
|
||||
)}
|
||||
<Flex
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -152,7 +145,7 @@ const Navbar = () => {
|
||||
<Flex align="center">
|
||||
<SwitchTeamButton />
|
||||
<NotificationButton />
|
||||
<HelpButton />
|
||||
<TimerButton />
|
||||
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
275
worklenz-frontend/src/features/navbar/timers/timer-button.tsx
Normal file
275
worklenz-frontend/src/features/navbar/timers/timer-button.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { ClockCircleOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import { Badge, Button, Dropdown, List, Tooltip, Typography, Space, Divider, theme } from 'antd';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { taskTimeLogsApiService, IRunningTimer } from '@/api/tasks/task-time-logs.api.service';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice';
|
||||
import moment from 'moment';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { useToken } = theme;
|
||||
|
||||
const TimerButton = () => {
|
||||
const [runningTimers, setRunningTimers] = useState<IRunningTimer[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentTimes, setCurrentTimes] = useState<Record<string, string>>({});
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const { t } = useTranslation('navbar');
|
||||
const { token } = useToken();
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket } = useSocket();
|
||||
|
||||
const fetchRunningTimers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await taskTimeLogsApiService.getRunningTimers();
|
||||
if (response.done) {
|
||||
setRunningTimers(response.body || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching running timers:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateCurrentTimes = () => {
|
||||
const newTimes: Record<string, string> = {};
|
||||
runningTimers.forEach(timer => {
|
||||
const startTime = moment(timer.start_time);
|
||||
const now = moment();
|
||||
const duration = moment.duration(now.diff(startTime));
|
||||
const hours = Math.floor(duration.asHours());
|
||||
const minutes = duration.minutes();
|
||||
const seconds = duration.seconds();
|
||||
newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
setCurrentTimes(newTimes);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRunningTimers();
|
||||
|
||||
// Set up polling to refresh timers every 30 seconds
|
||||
const pollInterval = setInterval(() => {
|
||||
fetchRunningTimers();
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(pollInterval);
|
||||
}, [fetchRunningTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (runningTimers.length > 0) {
|
||||
updateCurrentTimes();
|
||||
const interval = setInterval(updateCurrentTimes, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [runningTimers]);
|
||||
|
||||
// Listen for timer start/stop events and project updates to refresh the count
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleTimerStart = (data: string) => {
|
||||
try {
|
||||
const { id } = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
if (id) {
|
||||
// Refresh the running timers list when a new timer is started
|
||||
fetchRunningTimers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing timer start event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimerStop = (data: string) => {
|
||||
try {
|
||||
const { id } = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
if (id) {
|
||||
// Refresh the running timers list when a timer is stopped
|
||||
fetchRunningTimers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing timer stop event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectUpdates = () => {
|
||||
// Refresh timers when project updates are available
|
||||
fetchRunningTimers();
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
|
||||
socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
|
||||
socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
|
||||
socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
|
||||
socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
|
||||
};
|
||||
}, [socket, fetchRunningTimers]);
|
||||
|
||||
const hasRunningTimers = () => {
|
||||
return runningTimers.length > 0;
|
||||
};
|
||||
|
||||
const timerCount = () => {
|
||||
return runningTimers.length;
|
||||
};
|
||||
|
||||
const handleStopTimer = (taskId: string) => {
|
||||
if (!socket) return;
|
||||
|
||||
socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId }));
|
||||
dispatch(updateTaskTimeTracking({ taskId, timeTracking: null }));
|
||||
};
|
||||
|
||||
const dropdownContent = (
|
||||
<div
|
||||
style={{
|
||||
width: 350,
|
||||
maxHeight: 400,
|
||||
overflow: 'auto',
|
||||
backgroundColor: token.colorBgElevated,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
border: `1px solid ${token.colorBorderSecondary}`
|
||||
}}
|
||||
>
|
||||
{runningTimers.length === 0 ? (
|
||||
<div style={{ padding: 16, textAlign: 'center' }}>
|
||||
<Text type="secondary">No running timers</Text>
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
dataSource={runningTimers}
|
||||
renderItem={(timer) => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text strong style={{ fontSize: 14, color: token.colorText }}>
|
||||
{timer.task_name}
|
||||
</Text>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: token.colorPrimaryBg,
|
||||
color: token.colorPrimary,
|
||||
padding: '2px 8px',
|
||||
borderRadius: token.borderRadiusSM,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
marginTop: 2
|
||||
}}>
|
||||
{timer.project_name}
|
||||
</div>
|
||||
{timer.parent_task_name && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
Parent: {timer.parent_task_name}
|
||||
</Text>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
Started: {moment(timer.start_time).format('HH:mm')}
|
||||
</Text>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: token.colorPrimary,
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
>
|
||||
{currentTimes[timer.task_id] || '00:00:00'}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<StopOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStopTimer(timer.task_id);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: token.colorErrorBg,
|
||||
borderColor: token.colorError,
|
||||
color: token.colorError,
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{runningTimers.length > 0 && (
|
||||
<>
|
||||
<Divider style={{ margin: 0, borderColor: token.colorBorderSecondary }} />
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
borderBottomLeftRadius: token.borderRadius,
|
||||
borderBottomRightRadius: token.borderRadius
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{runningTimers.length} timer{runningTimers.length !== 1 ? 's' : ''} running
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
popupRender={() => dropdownContent}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
open={dropdownOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDropdownOpen(open);
|
||||
if (open) {
|
||||
fetchRunningTimers();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Running Timers">
|
||||
<Button
|
||||
style={{ height: '62px', width: '60px' }}
|
||||
type="text"
|
||||
icon={
|
||||
hasRunningTimers() ? (
|
||||
<Badge count={timerCount()}>
|
||||
<ClockCircleOutlined style={{ fontSize: 20 }} />
|
||||
</Badge>
|
||||
) : (
|
||||
<ClockCircleOutlined style={{ fontSize: 20 }} />
|
||||
)
|
||||
}
|
||||
loading={loading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimerButton;
|
||||
@@ -55,10 +55,9 @@ const initialState: TaskListState = {
|
||||
|
||||
export const getProject = createAsyncThunk(
|
||||
'project/getProject',
|
||||
async (projectId: string, { rejectWithValue, dispatch }) => {
|
||||
async (projectId: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectsApiService.getProject(projectId);
|
||||
dispatch(setProject(response.body));
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
return rejectWithValue(error instanceof Error ? error.message : 'Failed to fetch project');
|
||||
|
||||
42
worklenz-frontend/src/features/projects/projects.slice.ts
Normal file
42
worklenz-frontend/src/features/projects/projects.slice.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||
|
||||
interface UpdateProjectPayload {
|
||||
id: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const projectsSlice = createSlice({
|
||||
name: 'projects',
|
||||
initialState: {
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
reducers: {
|
||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.loading = action.payload;
|
||||
},
|
||||
setError: (state, action: PayloadAction<string | null>) => {
|
||||
state.error = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Export actions
|
||||
export const { setLoading, setError } = projectsSlice.actions;
|
||||
|
||||
// Async thunks
|
||||
export const updateProject = (payload: UpdateProjectPayload) => async (dispatch: any) => {
|
||||
try {
|
||||
dispatch(setLoading(true));
|
||||
const response = await projectsApiService.updateProject(payload);
|
||||
dispatch(setLoading(false));
|
||||
return response;
|
||||
} catch (error) {
|
||||
dispatch(setError((error as Error).message));
|
||||
dispatch(setLoading(false));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default projectsSlice.reducer;
|
||||
@@ -107,6 +107,10 @@ const membersReportsSlice = createSlice({
|
||||
setDateRange: (state, action) => {
|
||||
state.dateRange = action.payload;
|
||||
},
|
||||
setPagination: (state, action) => {
|
||||
state.index = action.payload.index;
|
||||
state.pageSize = action.payload.pageSize;
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
@@ -139,5 +143,6 @@ export const {
|
||||
setOrder,
|
||||
setDuration,
|
||||
setDateRange,
|
||||
setPagination,
|
||||
} = membersReportsSlice.actions;
|
||||
export default membersReportsSlice.reducer;
|
||||
|
||||
@@ -105,6 +105,15 @@ const taskDrawerSlice = createSlice({
|
||||
}>) => {
|
||||
state.timeLogEditing = action.payload;
|
||||
},
|
||||
setTaskRecurringSchedule: (state, action: PayloadAction<{
|
||||
schedule_id: string;
|
||||
task_id: string;
|
||||
}>) => {
|
||||
const { schedule_id, task_id } = action.payload;
|
||||
if (state.taskFormViewModel?.task && state.taskFormViewModel.task.id === task_id) {
|
||||
state.taskFormViewModel.task.schedule_id = schedule_id;
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(fetchTask.pending, state => {
|
||||
@@ -133,5 +142,6 @@ export const {
|
||||
setTaskLabels,
|
||||
setTaskSubscribers,
|
||||
setTimeLogEditing,
|
||||
setTaskRecurringSchedule
|
||||
} = taskDrawerSlice.actions;
|
||||
export default taskDrawerSlice.reducer;
|
||||
|
||||
@@ -21,6 +21,8 @@ import { ITaskLabel, ITaskLabelFilter } from '@/types/tasks/taskLabel.types';
|
||||
import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-response';
|
||||
import { produce } from 'immer';
|
||||
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule';
|
||||
|
||||
export enum IGroupBy {
|
||||
STATUS = 'status',
|
||||
@@ -192,6 +194,20 @@ export const fetchSubTasks = createAsyncThunk(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Request subtask progress data when expanding the task
|
||||
// This will trigger the socket to emit TASK_PROGRESS_UPDATED events for all subtasks
|
||||
try {
|
||||
// Get access to the socket from the state
|
||||
const socket = (getState() as any).socketReducer?.socket;
|
||||
if (socket?.connected) {
|
||||
// Request subtask count and progress information
|
||||
socket.emit(SocketEvents.GET_TASK_SUBTASKS_COUNT.toString(), taskId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error requesting subtask progress:', error);
|
||||
// Non-critical error, continue with fetching subtasks
|
||||
}
|
||||
|
||||
const selectedMembers = taskReducer.taskAssignees
|
||||
.filter(member => member.selected)
|
||||
.map(member => member.id)
|
||||
@@ -572,14 +588,30 @@ const taskSlice = createSlice({
|
||||
) => {
|
||||
const { taskId, progress, totalTasksCount, completedCount } = action.payload;
|
||||
|
||||
for (const group of state.taskGroups) {
|
||||
const task = group.tasks.find(task => task.id === taskId);
|
||||
if (task) {
|
||||
task.complete_ratio = progress;
|
||||
task.total_tasks_count = totalTasksCount;
|
||||
task.completed_count = completedCount;
|
||||
break;
|
||||
// Helper function to find and update a task at any nesting level
|
||||
const findAndUpdateTask = (tasks: IProjectTask[]) => {
|
||||
for (const task of tasks) {
|
||||
if (task.id === taskId) {
|
||||
task.complete_ratio = progress;
|
||||
task.progress_value = progress;
|
||||
task.total_tasks_count = totalTasksCount;
|
||||
task.completed_count = completedCount;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check subtasks if they exist
|
||||
if (task.sub_tasks && task.sub_tasks.length > 0) {
|
||||
const found = findAndUpdateTask(task.sub_tasks);
|
||||
if (found) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Try to find and update the task in any task group
|
||||
for (const group of state.taskGroups) {
|
||||
const found = findAndUpdateTask(group.tasks);
|
||||
if (found) break;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -975,6 +1007,15 @@ const taskSlice = createSlice({
|
||||
column.pinned = isVisible;
|
||||
}
|
||||
},
|
||||
|
||||
updateRecurringChange: (state, action: PayloadAction<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 => {
|
||||
@@ -1134,6 +1175,7 @@ export const {
|
||||
updateSubTasks,
|
||||
updateCustomColumnValue,
|
||||
updateCustomColumnPinned,
|
||||
updateRecurringChange
|
||||
} = taskSlice.actions;
|
||||
|
||||
export default taskSlice.reducer;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// ThemeSelector.tsx
|
||||
import { Button } from 'antd';
|
||||
import React from 'react';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { toggleTheme } from './themeSlice';
|
||||
import { MoonOutlined, SunOutlined } from '@ant-design/icons';
|
||||
|
||||
const ThemeSelector = () => {
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleDarkModeToggle = () => {
|
||||
dispatch(toggleTheme());
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type={themeMode === 'dark' ? 'primary' : 'default'}
|
||||
icon={themeMode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
|
||||
shape="circle"
|
||||
onClick={handleDarkModeToggle}
|
||||
className="transition-all duration-300" // Optional: add smooth transition
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSelector;
|
||||
69
worklenz-frontend/src/hooks/useFilterDataLoader.ts
Normal file
69
worklenz-frontend/src/hooks/useFilterDataLoader.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||
import {
|
||||
fetchLabelsByProject,
|
||||
fetchTaskAssignees,
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||
|
||||
/**
|
||||
* Hook to manage filter data loading independently of main task list loading
|
||||
* This ensures filter data loading doesn't block the main UI skeleton
|
||||
*/
|
||||
export const useFilterDataLoader = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { priorities } = useAppSelector(state => ({
|
||||
priorities: state.priorityReducer.priorities,
|
||||
}));
|
||||
|
||||
const { projectId } = useAppSelector(state => ({
|
||||
projectId: state.projectReducer.projectId,
|
||||
}));
|
||||
|
||||
// Load filter data asynchronously
|
||||
const loadFilterData = useCallback(async () => {
|
||||
try {
|
||||
// Load priorities if not already loaded (usually fast/cached)
|
||||
if (!priorities.length) {
|
||||
dispatch(fetchPriorities());
|
||||
}
|
||||
|
||||
// Load project-specific data in parallel without blocking
|
||||
if (projectId) {
|
||||
// These dispatch calls are fire-and-forget
|
||||
// They will update the UI when ready, but won't block initial render
|
||||
dispatch(fetchLabelsByProject(projectId));
|
||||
dispatch(fetchTaskAssignees(projectId));
|
||||
}
|
||||
|
||||
// Load team members for member filters
|
||||
dispatch(getTeamMembers({
|
||||
index: 0,
|
||||
size: 100,
|
||||
field: null,
|
||||
order: null,
|
||||
search: null,
|
||||
all: true
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error loading filter data:', error);
|
||||
// Don't throw - filter loading errors shouldn't break the main UI
|
||||
}
|
||||
}, [dispatch, priorities.length, projectId]);
|
||||
|
||||
// Load filter data on mount and when dependencies change
|
||||
useEffect(() => {
|
||||
// Use setTimeout to ensure this runs after the main component render
|
||||
// This prevents filter loading from blocking the initial render
|
||||
const timeoutId = setTimeout(loadFilterData, 0);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [loadFilterData]);
|
||||
|
||||
return {
|
||||
loadFilterData,
|
||||
};
|
||||
};
|
||||
146
worklenz-frontend/src/hooks/useTaskDragAndDrop.ts
Normal file
146
worklenz-frontend/src/hooks/useTaskDragAndDrop.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
KeyboardSensor,
|
||||
TouchSensor,
|
||||
} from '@dnd-kit/core';
|
||||
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { updateTaskStatus } from '@/features/tasks/tasks.slice';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
export const useTaskDragAndDrop = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Memoize the selector to prevent unnecessary rerenders
|
||||
const taskGroups = useAppSelector(state => state.taskReducer.taskGroups);
|
||||
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
|
||||
|
||||
// Memoize sensors configuration for better performance
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
// Add visual feedback for drag start
|
||||
const { active } = event;
|
||||
if (active) {
|
||||
document.body.style.cursor = 'grabbing';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
// Handle drag over logic if needed
|
||||
// This can be used for visual feedback during drag
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
// Reset cursor
|
||||
document.body.style.cursor = '';
|
||||
|
||||
const { active, over } = event;
|
||||
|
||||
if (!active || !over || !taskGroups) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
// Find the task being dragged
|
||||
let draggedTask: IProjectTask | null = null;
|
||||
let sourceGroupId: string | null = null;
|
||||
|
||||
for (const group of taskGroups) {
|
||||
const task = group.tasks?.find((t: IProjectTask) => t.id === activeId);
|
||||
if (task) {
|
||||
draggedTask = task;
|
||||
sourceGroupId = group.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!draggedTask || !sourceGroupId) {
|
||||
console.warn('Could not find dragged task');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine target group
|
||||
let targetGroupId: string | null = null;
|
||||
|
||||
// Check if dropped on a group container
|
||||
const targetGroup = taskGroups.find((group: ITaskListGroup) => group.id === overId);
|
||||
if (targetGroup) {
|
||||
targetGroupId = targetGroup.id;
|
||||
} else {
|
||||
// Check if dropped on another task
|
||||
for (const group of taskGroups) {
|
||||
const targetTask = group.tasks?.find((t: IProjectTask) => t.id === overId);
|
||||
if (targetTask) {
|
||||
targetGroupId = group.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetGroupId || targetGroupId === sourceGroupId) {
|
||||
return; // No change needed
|
||||
}
|
||||
|
||||
// Update task status based on group change
|
||||
const targetGroupData = taskGroups.find((group: ITaskListGroup) => group.id === targetGroupId);
|
||||
if (targetGroupData && groupBy === 'status') {
|
||||
const updatePayload: any = {
|
||||
task_id: draggedTask.id,
|
||||
status_id: targetGroupData.id,
|
||||
};
|
||||
|
||||
if (draggedTask.parent_task_id) {
|
||||
updatePayload.parent_task = draggedTask.parent_task_id;
|
||||
}
|
||||
|
||||
dispatch(updateTaskStatus(updatePayload));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling drag end:', error);
|
||||
}
|
||||
},
|
||||
[taskGroups, groupBy, dispatch]
|
||||
);
|
||||
|
||||
// Memoize the drag and drop configuration
|
||||
const dragAndDropConfig = useMemo(
|
||||
() => ({
|
||||
sensors,
|
||||
onDragStart: handleDragStart,
|
||||
onDragOver: handleDragOver,
|
||||
onDragEnd: handleDragEnd,
|
||||
}),
|
||||
[sensors, handleDragStart, handleDragOver, handleDragEnd]
|
||||
);
|
||||
|
||||
return dragAndDropConfig;
|
||||
};
|
||||
343
worklenz-frontend/src/hooks/useTaskSocketHandlers.ts
Normal file
343
worklenz-frontend/src/hooks/useTaskSocketHandlers.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
|
||||
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
|
||||
import { ILabelsChangeResponse } from '@/types/tasks/taskList.types';
|
||||
import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types';
|
||||
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
|
||||
import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-response';
|
||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
|
||||
import {
|
||||
fetchTaskAssignees,
|
||||
updateTaskAssignees,
|
||||
fetchLabelsByProject,
|
||||
updateTaskLabel,
|
||||
updateTaskStatus,
|
||||
updateTaskPriority,
|
||||
updateTaskEndDate,
|
||||
updateTaskEstimation,
|
||||
updateTaskName,
|
||||
updateTaskPhase,
|
||||
updateTaskStartDate,
|
||||
updateTaskDescription,
|
||||
updateSubTasks,
|
||||
updateTaskProgress,
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||
import {
|
||||
setStartDate,
|
||||
setTaskAssignee,
|
||||
setTaskEndDate,
|
||||
setTaskLabels,
|
||||
setTaskPriority,
|
||||
setTaskStatus,
|
||||
setTaskSubscribers,
|
||||
} from '@/features/task-drawer/task-drawer.slice';
|
||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
|
||||
export const useTaskSocketHandlers = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket } = useSocket();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const { loadingAssignees, taskGroups } = useAppSelector((state: any) => state.taskReducer);
|
||||
const { projectId } = useAppSelector((state: any) => state.projectReducer);
|
||||
|
||||
// Memoize socket event handlers
|
||||
const handleAssigneesUpdate = useCallback(
|
||||
(data: ITaskAssigneesUpdateResponse) => {
|
||||
if (!data) return;
|
||||
|
||||
const updatedAssignees = data.assignees?.map(assignee => ({
|
||||
...assignee,
|
||||
selected: true,
|
||||
})) || [];
|
||||
|
||||
const groupId = taskGroups?.find((group: ITaskListGroup) =>
|
||||
group.tasks?.some(
|
||||
(task: IProjectTask) =>
|
||||
task.id === data.id ||
|
||||
(task.sub_tasks && task.sub_tasks.some((subtask: IProjectTask) => subtask.id === data.id))
|
||||
)
|
||||
)?.id;
|
||||
|
||||
if (groupId) {
|
||||
dispatch(
|
||||
updateTaskAssignees({
|
||||
groupId,
|
||||
taskId: data.id,
|
||||
assignees: updatedAssignees,
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(
|
||||
setTaskAssignee({
|
||||
...data,
|
||||
manual_progress: false,
|
||||
} as IProjectTask)
|
||||
);
|
||||
|
||||
if (currentSession?.team_id && !loadingAssignees) {
|
||||
dispatch(fetchTaskAssignees(currentSession.team_id));
|
||||
}
|
||||
}
|
||||
},
|
||||
[taskGroups, dispatch, currentSession?.team_id, loadingAssignees]
|
||||
);
|
||||
|
||||
const handleLabelsChange = useCallback(
|
||||
async (labels: ILabelsChangeResponse) => {
|
||||
if (!labels) return;
|
||||
|
||||
await Promise.all([
|
||||
dispatch(updateTaskLabel(labels)),
|
||||
dispatch(setTaskLabels(labels)),
|
||||
dispatch(fetchLabels()),
|
||||
projectId && dispatch(fetchLabelsByProject(projectId)),
|
||||
]);
|
||||
},
|
||||
[dispatch, projectId]
|
||||
);
|
||||
|
||||
const handleTaskStatusChange = useCallback(
|
||||
(response: ITaskListStatusChangeResponse) => {
|
||||
if (!response) return;
|
||||
|
||||
if (response.completed_deps === false) {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
'Please complete the task dependencies before proceeding'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(updateTaskStatus(response));
|
||||
dispatch(deselectAll());
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTaskProgress = useCallback(
|
||||
(data: {
|
||||
id: string;
|
||||
status: string;
|
||||
complete_ratio: number;
|
||||
completed_count: number;
|
||||
total_tasks_count: number;
|
||||
parent_task: string;
|
||||
}) => {
|
||||
if (!data) return;
|
||||
|
||||
dispatch(
|
||||
updateTaskProgress({
|
||||
taskId: data.parent_task || data.id,
|
||||
progress: data.complete_ratio,
|
||||
totalTasksCount: data.total_tasks_count,
|
||||
completedCount: data.completed_count,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handlePriorityChange = useCallback(
|
||||
(response: ITaskListPriorityChangeResponse) => {
|
||||
if (!response) return;
|
||||
|
||||
dispatch(updateTaskPriority(response));
|
||||
dispatch(setTaskPriority(response));
|
||||
dispatch(deselectAll());
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleEndDateChange = useCallback(
|
||||
(task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
end_date: string;
|
||||
}) => {
|
||||
if (!task) return;
|
||||
|
||||
const taskWithProgress = {
|
||||
...task,
|
||||
manual_progress: false,
|
||||
} as IProjectTask;
|
||||
|
||||
dispatch(updateTaskEndDate({ task: taskWithProgress }));
|
||||
dispatch(setTaskEndDate(taskWithProgress));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTaskNameChange = useCallback(
|
||||
(data: { id: string; parent_task: string; name: string }) => {
|
||||
if (!data) return;
|
||||
dispatch(updateTaskName(data));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handlePhaseChange = useCallback(
|
||||
(data: ITaskPhaseChangeResponse) => {
|
||||
if (!data) return;
|
||||
dispatch(updateTaskPhase(data));
|
||||
dispatch(deselectAll());
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleStartDateChange = useCallback(
|
||||
(task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
start_date: string;
|
||||
}) => {
|
||||
if (!task) return;
|
||||
|
||||
const taskWithProgress = {
|
||||
...task,
|
||||
manual_progress: false,
|
||||
} as IProjectTask;
|
||||
|
||||
dispatch(updateTaskStartDate({ task: taskWithProgress }));
|
||||
dispatch(setStartDate(taskWithProgress));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTaskSubscribersChange = useCallback(
|
||||
(data: InlineMember[]) => {
|
||||
if (!data) return;
|
||||
dispatch(setTaskSubscribers(data));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleEstimationChange = useCallback(
|
||||
(task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
estimation: number;
|
||||
}) => {
|
||||
if (!task) return;
|
||||
|
||||
const taskWithProgress = {
|
||||
...task,
|
||||
manual_progress: false,
|
||||
} as IProjectTask;
|
||||
|
||||
dispatch(updateTaskEstimation({ task: taskWithProgress }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTaskDescriptionChange = useCallback(
|
||||
(data: {
|
||||
id: string;
|
||||
parent_task: string;
|
||||
description: string;
|
||||
}) => {
|
||||
if (!data) return;
|
||||
dispatch(updateTaskDescription(data));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleNewTaskReceived = useCallback(
|
||||
(data: IProjectTask) => {
|
||||
if (!data) return;
|
||||
if (data.parent_task_id) {
|
||||
dispatch(updateSubTasks(data));
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTaskProgressUpdated = useCallback(
|
||||
(data: {
|
||||
task_id: string;
|
||||
progress_value?: number;
|
||||
weight?: number;
|
||||
}) => {
|
||||
if (!data || !taskGroups) return;
|
||||
|
||||
if (data.progress_value !== undefined) {
|
||||
for (const group of taskGroups) {
|
||||
const task = group.tasks?.find((task: IProjectTask) => task.id === data.task_id);
|
||||
if (task) {
|
||||
dispatch(
|
||||
updateTaskProgress({
|
||||
taskId: data.task_id,
|
||||
progress: data.progress_value,
|
||||
totalTasksCount: task.total_tasks_count || 0,
|
||||
completedCount: task.completed_count || 0,
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[dispatch, taskGroups]
|
||||
);
|
||||
|
||||
// Register socket event listeners
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const eventHandlers = [
|
||||
{ event: SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handler: handleAssigneesUpdate },
|
||||
{ event: SocketEvents.TASK_LABELS_CHANGE.toString(), handler: handleLabelsChange },
|
||||
{ event: SocketEvents.TASK_STATUS_CHANGE.toString(), handler: handleTaskStatusChange },
|
||||
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgress },
|
||||
{ event: SocketEvents.TASK_PRIORITY_CHANGE.toString(), handler: handlePriorityChange },
|
||||
{ event: SocketEvents.TASK_END_DATE_CHANGE.toString(), handler: handleEndDateChange },
|
||||
{ event: SocketEvents.TASK_NAME_CHANGE.toString(), handler: handleTaskNameChange },
|
||||
{ event: SocketEvents.TASK_PHASE_CHANGE.toString(), handler: handlePhaseChange },
|
||||
{ event: SocketEvents.TASK_START_DATE_CHANGE.toString(), handler: handleStartDateChange },
|
||||
{ event: SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handler: handleTaskSubscribersChange },
|
||||
{ event: SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handler: handleEstimationChange },
|
||||
{ event: SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handler: handleTaskDescriptionChange },
|
||||
{ event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived },
|
||||
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated },
|
||||
];
|
||||
|
||||
// Register all event listeners
|
||||
eventHandlers.forEach(({ event, handler }) => {
|
||||
socket.on(event, handler);
|
||||
});
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
eventHandlers.forEach(({ event, handler }) => {
|
||||
socket.off(event, handler);
|
||||
});
|
||||
};
|
||||
}, [
|
||||
socket,
|
||||
handleAssigneesUpdate,
|
||||
handleLabelsChange,
|
||||
handleTaskStatusChange,
|
||||
handleTaskProgress,
|
||||
handlePriorityChange,
|
||||
handleEndDateChange,
|
||||
handleTaskNameChange,
|
||||
handlePhaseChange,
|
||||
handleStartDateChange,
|
||||
handleTaskSubscribersChange,
|
||||
handleEstimationChange,
|
||||
handleTaskDescriptionChange,
|
||||
handleNewTaskReceived,
|
||||
handleTaskProgressUpdated,
|
||||
]);
|
||||
};
|
||||
@@ -58,9 +58,9 @@ html.light body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial,
|
||||
"Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji" !important;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial, "Noto Sans",
|
||||
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !important;
|
||||
}
|
||||
|
||||
/* helper classes */
|
||||
@@ -145,3 +145,4 @@ Not supports in Firefox and IE */
|
||||
tr:hover .action-buttons {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { colors } from '../styles/colors';
|
||||
import { verifyAuthentication } from '@/features/auth/authSlice';
|
||||
import { useEffect } from 'react';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import TawkTo from '@/components/TawkTo';
|
||||
import HubSpot from '@/components/HubSpot';
|
||||
|
||||
const MainLayout = () => {
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
@@ -68,9 +68,6 @@ const MainLayout = () => {
|
||||
<Outlet />
|
||||
</Col>
|
||||
</Layout.Content>
|
||||
{import.meta.env.VITE_APP_ENV === 'production' && (
|
||||
<TawkTo propertyId="67ecc524f62fbf190db18bde" widgetId="1inqe45sq" />
|
||||
)}
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
UserSwitchOutlined,
|
||||
BulbOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import React, { ReactNode } from 'react';
|
||||
import ProfileSettings from '../../pages/settings/profile/profile-settings';
|
||||
@@ -25,6 +26,7 @@ import TeamMembersSettings from '@/pages/settings/team-members/team-members-sett
|
||||
import TeamsSettings from '../../pages/settings/teams/teams-settings';
|
||||
import ChangePassword from '@/pages/settings/change-password/change-password';
|
||||
import LanguageAndRegionSettings from '@/pages/settings/language-and-region/language-and-region-settings';
|
||||
import AppearanceSettings from '@/pages/settings/appearance/appearance-settings';
|
||||
|
||||
// type of menu item in settings sidebar
|
||||
type SettingMenuItems = {
|
||||
@@ -52,6 +54,13 @@ export const settingsItems: SettingMenuItems[] = [
|
||||
icon: React.createElement(NotificationOutlined),
|
||||
element: React.createElement(NotificationsSettings),
|
||||
},
|
||||
{
|
||||
key: 'appearance',
|
||||
name: 'appearance',
|
||||
endpoint: 'appearance',
|
||||
icon: React.createElement(BulbOutlined),
|
||||
element: React.createElement(AppearanceSettings),
|
||||
},
|
||||
{
|
||||
key: 'change-password',
|
||||
name: 'change-password',
|
||||
@@ -99,14 +108,14 @@ export const settingsItems: SettingMenuItems[] = [
|
||||
element: React.createElement(CategoriesSettings),
|
||||
adminOnly: true,
|
||||
},
|
||||
// {
|
||||
// key: 'project-templates',
|
||||
// name: 'project-templates',
|
||||
// endpoint: 'project-templates',
|
||||
// icon: React.createElement(FileZipOutlined),
|
||||
// element: React.createElement(ProjectTemplatesSettings),
|
||||
// adminOnly: true,
|
||||
// },
|
||||
{
|
||||
key: 'project-templates',
|
||||
name: 'project-templates',
|
||||
endpoint: 'project-templates',
|
||||
icon: React.createElement(FileZipOutlined),
|
||||
element: React.createElement(ProjectTemplatesSettings),
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: 'task-templates',
|
||||
name: 'task-templates',
|
||||
|
||||
@@ -24,8 +24,8 @@ import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { getUserSession, setSession } from '@/utils/session-helper';
|
||||
import { validateEmail } from '@/utils/validateEmail';
|
||||
import { sanitizeInput } from '@/utils/sanitizeInput';
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import logoDark from '@/assets/images/logo-dark-mode.png';
|
||||
import logo from '@/assets/images/worklenz-light-mode.png';
|
||||
import logoDark from '@/assets/images/worklenz-dark-mode.png';
|
||||
|
||||
import './account-setup.css';
|
||||
import { IAccountSetupRequest } from '@/types/project-templates/project-templates.types';
|
||||
|
||||
@@ -77,6 +77,18 @@ const LoginPage: React.FC = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check and unregister ngsw-worker if present
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(registrations) {
|
||||
const ngswWorker = registrations.find(reg => reg.active?.scriptURL.includes('ngsw-worker'));
|
||||
if (ngswWorker) {
|
||||
ngswWorker.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
trackMixpanelEvent(evt_login_page_visit);
|
||||
if (currentSession && !currentSession?.setup_completed) {
|
||||
navigate('/worklenz/setup');
|
||||
|
||||
@@ -132,7 +132,7 @@ const RecentAndFavouriteProjectList = () => {
|
||||
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
||||
{projectsData?.body?.length === 0 ? (
|
||||
<Empty
|
||||
image="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
image="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
imageStyle={{ height: 60 }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -89,7 +89,7 @@ const TasksList: React.FC = React.memo(() => {
|
||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSelectTask = useCallback((task : IMyTask) => {
|
||||
const handleSelectTask = useCallback((task: IMyTask) => {
|
||||
dispatch(setSelectedTaskId(task.id || ''));
|
||||
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
|
||||
dispatch(setProjectId(task.project_id || ''));
|
||||
@@ -155,7 +155,7 @@ const TasksList: React.FC = React.memo(() => {
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<Tooltip title={record.project_name}>
|
||||
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth:120 }} ellipsis={{ tooltip: true }}>
|
||||
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth: 120 }} ellipsis={{ tooltip: true }}>
|
||||
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
|
||||
{record.project_name}
|
||||
</Typography.Paragraph>
|
||||
@@ -259,7 +259,7 @@ const TasksList: React.FC = React.memo(() => {
|
||||
<Skeleton active />
|
||||
) : data?.body.total === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
text=" No tasks to show."
|
||||
/>
|
||||
) : (
|
||||
@@ -271,10 +271,10 @@ const TasksList: React.FC = React.memo(() => {
|
||||
columns={columns as TableProps<IMyTask>['columns']}
|
||||
size="middle"
|
||||
rowClassName={() => 'custom-row-height'}
|
||||
loading={homeTasksFetching && !skipAutoRefetch}
|
||||
loading={homeTasksFetching && skipAutoRefetch}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
|
||||
<div style={{ marginTop: 16, textAlign: 'right', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
|
||||
@@ -147,7 +147,7 @@ const TodoList = () => {
|
||||
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
||||
{data?.body.length === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
text={t('home:todoList.noTasks')}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
|
||||
import { Flex, Skeleton } from 'antd';
|
||||
@@ -35,7 +35,6 @@ import { evt_project_board_visit, evt_project_task_list_drag_and_move } from '@/
|
||||
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||
|
||||
const ProjectViewBoard = () => {
|
||||
@@ -45,7 +44,10 @@ const ProjectViewBoard = () => {
|
||||
const authService = useAuthService();
|
||||
const currentSession = authService.getCurrentSession();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const [ currentTaskIndex, setCurrentTaskIndex] = useState(-1);
|
||||
const [currentTaskIndex, setCurrentTaskIndex] = useState(-1);
|
||||
// Add local loading state to immediately show skeleton
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
|
||||
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
||||
@@ -56,14 +58,34 @@ const ProjectViewBoard = () => {
|
||||
// Store the original source group ID when drag starts
|
||||
const originalSourceGroupIdRef = useRef<string | null>(null);
|
||||
|
||||
// Update loading state based on all loading conditions
|
||||
useEffect(() => {
|
||||
if (projectId && groupBy && projectView === 'kanban') {
|
||||
if (!loadingGroups) {
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
setIsLoading(loadingGroups || loadingStatusCategories);
|
||||
}, [loadingGroups, loadingStatusCategories]);
|
||||
|
||||
// Load data efficiently with async/await and Promise.all
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (projectId && groupBy && projectView === 'kanban') {
|
||||
const promises = [];
|
||||
|
||||
if (!loadingGroups) {
|
||||
promises.push(dispatch(fetchBoardTaskGroups(projectId)));
|
||||
}
|
||||
|
||||
if (!statusCategories.length) {
|
||||
promises.push(dispatch(fetchStatusesCategories()));
|
||||
}
|
||||
|
||||
// Wait for all data to load
|
||||
await Promise.all(promises);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [dispatch, projectId, groupBy, projectView, search, archived]);
|
||||
|
||||
// Create sensors with memoization to prevent unnecessary re-renders
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
// Require the mouse to move by 10 pixels before activating
|
||||
@@ -394,18 +416,16 @@ const ProjectViewBoard = () => {
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
// Track analytics event on component mount
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_project_board_visit);
|
||||
if (!statusCategories.length && projectId) {
|
||||
dispatch(fetchStatusesCategories());
|
||||
}
|
||||
}, [dispatch, projectId]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16}>
|
||||
<TaskListFilters position={'board'} />
|
||||
|
||||
<Skeleton active loading={loadingGroups} className='mt-4 p-4'>
|
||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
|
||||
@@ -105,8 +105,8 @@ const OverLoggedTasksTable = () => {
|
||||
{
|
||||
key: 'overLoggedTime',
|
||||
title: 'Over Logged Time',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>{record.overlogged_time}</Typography.Text>
|
||||
render: (_, record: IInsightTasks) => (
|
||||
<Typography.Text>{record.overlogged_time_string}</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { format } from 'date-fns';
|
||||
import html2canvas from 'html2canvas';
|
||||
import jsPDF from 'jspdf';
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import logo from '@/assets/images/worklenz-light-mode.png';
|
||||
import { evt_project_insights_members_visit, evt_project_insights_overview_visit, evt_project_insights_tasks_visit } from '@/shared/worklenz-analytics-events';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ const ProjectViewMembers = () => {
|
||||
>
|
||||
{members?.total === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
imageHeight={120}
|
||||
text={t('emptyText')}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp } from '@features/project/project.slice';
|
||||
import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp, getProject } from '@features/project/project.slice';
|
||||
import { addTask, fetchTaskGroups, fetchTaskListColumns, IGroupBy } from '@features/tasks/tasks.slice';
|
||||
import ProjectStatusIcon from '@/components/common/project-status-icon/project-status-icon';
|
||||
import { formatDate } from '@/utils/timeUtils';
|
||||
@@ -70,6 +70,7 @@ const ProjectViewHeader = () => {
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (!projectId) return;
|
||||
dispatch(getProject(projectId));
|
||||
switch (tab) {
|
||||
case 'tasks-list':
|
||||
dispatch(fetchTaskListColumns(projectId));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { PushpinFilled, PushpinOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd';
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
@@ -43,6 +43,14 @@ const ProjectView = () => {
|
||||
const [pinnedTab, setPinnedTab] = useState<string>(searchParams.get('pinned_tab') || '');
|
||||
const [taskid, setTaskId] = useState<string>(searchParams.get('task') || '');
|
||||
|
||||
const resetProjectData = useCallback(() => {
|
||||
dispatch(setProjectId(null));
|
||||
dispatch(resetStatuses());
|
||||
dispatch(deselectAll());
|
||||
dispatch(resetTaskListData());
|
||||
dispatch(resetBoardData());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
dispatch(setProjectId(projectId));
|
||||
@@ -59,9 +67,13 @@ const ProjectView = () => {
|
||||
dispatch(setSelectedTaskId(taskid || ''));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}
|
||||
}, [dispatch, navigate, projectId, taskid]);
|
||||
|
||||
const pinToDefaultTab = async (itemKey: string) => {
|
||||
return () => {
|
||||
resetProjectData();
|
||||
};
|
||||
}, [dispatch, navigate, projectId, taskid, resetProjectData]);
|
||||
|
||||
const pinToDefaultTab = useCallback(async (itemKey: string) => {
|
||||
if (!itemKey || !projectId) return;
|
||||
|
||||
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
|
||||
@@ -88,9 +100,9 @@ const ProjectView = () => {
|
||||
}).toString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [projectId, activeTab, navigate]);
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
const handleTabChange = useCallback((key: string) => {
|
||||
setActiveTab(key);
|
||||
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
|
||||
navigate({
|
||||
@@ -100,9 +112,9 @@ const ProjectView = () => {
|
||||
pinned_tab: pinnedTab,
|
||||
}).toString(),
|
||||
});
|
||||
};
|
||||
}, [dispatch, location.pathname, navigate, pinnedTab]);
|
||||
|
||||
const tabMenuItems = tabItems.map(item => ({
|
||||
const tabMenuItems = useMemo(() => tabItems.map(item => ({
|
||||
key: item.key,
|
||||
label: (
|
||||
<Flex align="center" style={{ color: colors.skyBlue }}>
|
||||
@@ -144,21 +156,17 @@ const ProjectView = () => {
|
||||
</Flex>
|
||||
),
|
||||
children: item.element,
|
||||
}));
|
||||
})), [pinnedTab, pinToDefaultTab]);
|
||||
|
||||
const resetProjectData = () => {
|
||||
dispatch(setProjectId(null));
|
||||
dispatch(resetStatuses());
|
||||
dispatch(deselectAll());
|
||||
dispatch(resetTaskListData());
|
||||
dispatch(resetBoardData());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
resetProjectData();
|
||||
};
|
||||
}, []);
|
||||
const portalElements = useMemo(() => (
|
||||
<>
|
||||
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
|
||||
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
|
||||
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
|
||||
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
|
||||
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
|
||||
</>
|
||||
), []);
|
||||
|
||||
return (
|
||||
<div style={{ marginBlockStart: 80, marginBlockEnd: 24, minHeight: '80vh' }}>
|
||||
@@ -169,34 +177,12 @@ const ProjectView = () => {
|
||||
onChange={handleTabChange}
|
||||
items={tabMenuItems}
|
||||
tabBarStyle={{ paddingInline: 0 }}
|
||||
destroyInactiveTabPane={true}
|
||||
// tabBarExtraContent={
|
||||
// <div>
|
||||
// <span style={{ position: 'relative', top: '-10px' }}>
|
||||
// <Tooltip title="Members who are active on this project will be displayed here.">
|
||||
// <QuestionCircleOutlined />
|
||||
// </Tooltip>
|
||||
// </span>
|
||||
// <span
|
||||
// style={{
|
||||
// position: 'relative',
|
||||
// right: '20px',
|
||||
// top: '10px',
|
||||
// }}
|
||||
// >
|
||||
// <Badge status="success" dot className="profile-badge" />
|
||||
// </span>
|
||||
// </div>
|
||||
// }
|
||||
destroyOnHidden={true}
|
||||
/>
|
||||
|
||||
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
|
||||
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
|
||||
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
|
||||
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
|
||||
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
|
||||
{portalElements}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectView;
|
||||
export default React.memo(ProjectView);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import Flex from 'antd/es/flex';
|
||||
import Badge from 'antd/es/badge';
|
||||
import Button from 'antd/es/button';
|
||||
import Dropdown from 'antd/es/dropdown';
|
||||
import Input from 'antd/es/input';
|
||||
import Typography from 'antd/es/typography';
|
||||
import { MenuProps } from 'antd/es/menu';
|
||||
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
|
||||
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import Collapsible from '@/components/collapsible/collapsible';
|
||||
import TaskListTable from '../../task-list-table/task-list-table';
|
||||
import { IGroupBy, updateTaskGroupColor } from '@/features/tasks/tasks.slice';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
interface TaskGroupProps {
|
||||
taskGroup: ITaskListGroup;
|
||||
groupBy: string;
|
||||
color: string;
|
||||
activeId?: string | null;
|
||||
}
|
||||
|
||||
const TaskGroup: React.FC<TaskGroupProps> = ({
|
||||
taskGroup,
|
||||
groupBy,
|
||||
color,
|
||||
activeId
|
||||
}) => {
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const dispatch = useAppDispatch();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const isProjectManager = useIsProjectManager();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [groupName, setGroupName] = useState(taskGroup.name || '');
|
||||
|
||||
const { projectId } = useAppSelector((state: any) => state.projectReducer);
|
||||
const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
|
||||
|
||||
// Memoize droppable configuration
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: taskGroup.id,
|
||||
data: {
|
||||
type: 'group',
|
||||
groupId: taskGroup.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Memoize task count
|
||||
const taskCount = useMemo(() => taskGroup.tasks?.length || 0, [taskGroup.tasks]);
|
||||
|
||||
// Memoize dropdown items
|
||||
const dropdownItems: MenuProps['items'] = useMemo(() => {
|
||||
if (groupBy !== IGroupBy.STATUS || !isProjectManager) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'rename',
|
||||
label: t('renameText'),
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => setIsRenaming(true),
|
||||
},
|
||||
{
|
||||
key: 'change-category',
|
||||
label: t('changeCategoryText'),
|
||||
icon: <RetweetOutlined />,
|
||||
children: [
|
||||
{
|
||||
key: 'todo',
|
||||
label: t('todoText'),
|
||||
onClick: () => handleStatusCategoryChange('0'),
|
||||
},
|
||||
{
|
||||
key: 'doing',
|
||||
label: t('doingText'),
|
||||
onClick: () => handleStatusCategoryChange('1'),
|
||||
},
|
||||
{
|
||||
key: 'done',
|
||||
label: t('doneText'),
|
||||
onClick: () => handleStatusCategoryChange('2'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [groupBy, isProjectManager, t]);
|
||||
|
||||
const handleStatusCategoryChange = async (category: string) => {
|
||||
if (!projectId || !taskGroup.id) return;
|
||||
|
||||
try {
|
||||
await statusApiService.updateStatus({
|
||||
id: taskGroup.id,
|
||||
category_id: category,
|
||||
project_id: projectId,
|
||||
});
|
||||
|
||||
dispatch(fetchStatuses());
|
||||
trackMixpanelEvent(evt_project_board_column_setting_click, {
|
||||
column_id: taskGroup.id,
|
||||
action: 'change_category',
|
||||
category,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating status category:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!projectId || !taskGroup.id || !groupName.trim()) return;
|
||||
|
||||
try {
|
||||
if (groupBy === IGroupBy.STATUS) {
|
||||
await statusApiService.updateStatus({
|
||||
id: taskGroup.id,
|
||||
name: groupName.trim(),
|
||||
project_id: projectId,
|
||||
});
|
||||
dispatch(fetchStatuses());
|
||||
} else if (groupBy === IGroupBy.PHASE) {
|
||||
const phaseData: ITaskPhase = {
|
||||
id: taskGroup.id,
|
||||
name: groupName.trim(),
|
||||
project_id: projectId,
|
||||
color_code: taskGroup.color_code,
|
||||
};
|
||||
await phasesApiService.updatePhase(phaseData);
|
||||
dispatch(fetchPhasesByProjectId(projectId));
|
||||
}
|
||||
|
||||
setIsRenaming(false);
|
||||
} catch (error) {
|
||||
logger.error('Error renaming group:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorChange = async (newColor: string) => {
|
||||
if (!projectId || !taskGroup.id) return;
|
||||
|
||||
try {
|
||||
const baseColor = newColor.endsWith(ALPHA_CHANNEL)
|
||||
? newColor.slice(0, -ALPHA_CHANNEL.length)
|
||||
: newColor;
|
||||
|
||||
if (groupBy === IGroupBy.PHASE) {
|
||||
const phaseData: ITaskPhase = {
|
||||
id: taskGroup.id,
|
||||
name: taskGroup.name || '',
|
||||
project_id: projectId,
|
||||
color_code: baseColor,
|
||||
};
|
||||
await phasesApiService.updatePhase(phaseData);
|
||||
dispatch(fetchPhasesByProjectId(projectId));
|
||||
}
|
||||
|
||||
dispatch(updateTaskGroupColor({
|
||||
groupId: taskGroup.id,
|
||||
color: baseColor,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Error updating group color:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef}>
|
||||
<Flex vertical>
|
||||
{/* Group Header */}
|
||||
<Flex style={{ transform: 'translateY(6px)' }}>
|
||||
<Button
|
||||
className="custom-collapse-button"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
border: 'none',
|
||||
borderBottomLeftRadius: isExpanded ? 0 : 4,
|
||||
borderBottomRightRadius: isExpanded ? 0 : 4,
|
||||
color: colors.darkGray,
|
||||
minWidth: 200,
|
||||
}}
|
||||
icon={<RightOutlined rotate={isExpanded ? 90 : 0} />}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<Input
|
||||
size="small"
|
||||
value={groupName}
|
||||
onChange={e => setGroupName(e.target.value)}
|
||||
onBlur={handleRename}
|
||||
onPressEnter={handleRename}
|
||||
onClick={e => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text style={{ fontSize: 14, fontWeight: 600 }}>
|
||||
{taskGroup.name} ({taskCount})
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{dropdownItems.length > 0 && !isRenaming && (
|
||||
<Dropdown menu={{ items: dropdownItems }} trigger={['click']}>
|
||||
<Button icon={<EllipsisOutlined />} className="borderless-icon-btn" />
|
||||
</Dropdown>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Task List */}
|
||||
<Collapsible isOpen={isExpanded}>
|
||||
<TaskListTable
|
||||
taskList={taskGroup.tasks || []}
|
||||
tableId={taskGroup.id}
|
||||
groupBy={groupBy}
|
||||
color={color}
|
||||
activeId={activeId}
|
||||
/>
|
||||
</Collapsible>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TaskGroup);
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import Flex from 'antd/es/flex';
|
||||
import Skeleton from 'antd/es/skeleton';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import TaskListFilters from './task-list-filters/task-list-filters';
|
||||
import TaskGroupWrapper from './task-list-table/task-group-wrapper/task-group-wrapper';
|
||||
import TaskGroupWrapperOptimized from './task-group-wrapper-optimized';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice';
|
||||
@@ -17,48 +17,99 @@ const ProjectViewTaskList = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { projectView } = useTabSearchParam();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector(
|
||||
state => state.taskReducer
|
||||
);
|
||||
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
||||
state => state.taskStatusReducer
|
||||
);
|
||||
const { loadingPhases } = useAppSelector(state => state.phaseReducer);
|
||||
const { loadingColumns } = useAppSelector(state => state.taskReducer);
|
||||
// Split selectors to prevent unnecessary rerenders
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
const taskGroups = useAppSelector(state => state.taskReducer.taskGroups);
|
||||
const loadingGroups = useAppSelector(state => state.taskReducer.loadingGroups);
|
||||
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
|
||||
const archived = useAppSelector(state => state.taskReducer.archived);
|
||||
const fields = useAppSelector(state => state.taskReducer.fields);
|
||||
const search = useAppSelector(state => state.taskReducer.search);
|
||||
|
||||
const statusCategories = useAppSelector(state => state.taskStatusReducer.statusCategories);
|
||||
const loadingStatusCategories = useAppSelector(state => state.taskStatusReducer.loading);
|
||||
|
||||
const loadingPhases = useAppSelector(state => state.phaseReducer.loadingPhases);
|
||||
|
||||
// Single source of truth for loading state - EXCLUDE labels loading from skeleton
|
||||
// Labels loading should not block the main task list display
|
||||
const isLoading = useMemo(() =>
|
||||
loadingGroups || loadingPhases || loadingStatusCategories || !initialLoadComplete,
|
||||
[loadingGroups, loadingPhases, loadingStatusCategories, initialLoadComplete]
|
||||
);
|
||||
|
||||
// Memoize the empty state check
|
||||
const isEmptyState = useMemo(() =>
|
||||
taskGroups && taskGroups.length === 0 && !isLoading,
|
||||
[taskGroups, isLoading]
|
||||
);
|
||||
|
||||
// Handle view type changes
|
||||
useEffect(() => {
|
||||
// Set default view to list if projectView is not list or board
|
||||
if (projectView !== 'list' && projectView !== 'board') {
|
||||
searchParams.set('tab', 'tasks-list');
|
||||
searchParams.set('pinned_tab', 'tasks-list');
|
||||
setSearchParams(searchParams);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('tab', 'tasks-list');
|
||||
newParams.set('pinned_tab', 'tasks-list');
|
||||
setSearchParams(newParams);
|
||||
}
|
||||
}, [projectView, searchParams, setSearchParams]);
|
||||
}, [projectView, setSearchParams, searchParams]);
|
||||
|
||||
// Batch initial data fetching - core data only
|
||||
useEffect(() => {
|
||||
if (projectId && groupBy) {
|
||||
if (!loadingColumns) dispatch(fetchTaskListColumns(projectId));
|
||||
if (!loadingPhases) dispatch(fetchPhasesByProjectId(projectId));
|
||||
if (!loadingGroups && projectView === 'list') {
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
const fetchInitialData = async () => {
|
||||
if (!projectId || !groupBy || initialLoadComplete) return;
|
||||
|
||||
try {
|
||||
// Batch only essential API calls for initial load
|
||||
// Filter data (labels, assignees, etc.) will load separately and not block the UI
|
||||
await Promise.allSettled([
|
||||
dispatch(fetchTaskListColumns(projectId)),
|
||||
dispatch(fetchPhasesByProjectId(projectId)),
|
||||
dispatch(fetchStatusesCategories()),
|
||||
]);
|
||||
setInitialLoadComplete(true);
|
||||
} catch (error) {
|
||||
console.error('Error fetching initial data:', error);
|
||||
setInitialLoadComplete(true); // Still mark as complete to prevent infinite loading
|
||||
}
|
||||
}
|
||||
if (!statusCategories.length) {
|
||||
dispatch(fetchStatusesCategories());
|
||||
}
|
||||
}, [dispatch, projectId, groupBy, fields, search, archived]);
|
||||
};
|
||||
|
||||
fetchInitialData();
|
||||
}, [projectId, groupBy, dispatch, initialLoadComplete]);
|
||||
|
||||
// Fetch task groups with dependency on initial load completion
|
||||
useEffect(() => {
|
||||
const fetchTasks = async () => {
|
||||
if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return;
|
||||
|
||||
try {
|
||||
await dispatch(fetchTaskGroups(projectId));
|
||||
} catch (error) {
|
||||
console.error('Error fetching task groups:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTasks();
|
||||
}, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]);
|
||||
|
||||
// Memoize the task groups to prevent unnecessary re-renders
|
||||
const memoizedTaskGroups = useMemo(() => taskGroups || [], [taskGroups]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||
{/* Filters load independently and don't block the main content */}
|
||||
<TaskListFilters position="list" />
|
||||
|
||||
{(taskGroups.length === 0 && !loadingGroups) ? (
|
||||
{isEmptyState ? (
|
||||
<Empty description="No tasks group found" />
|
||||
) : (
|
||||
<Skeleton active loading={loadingGroups} className='mt-4 p-4'>
|
||||
<TaskGroupWrapper taskGroups={taskGroups} groupBy={groupBy} />
|
||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||
<TaskGroupWrapperOptimized
|
||||
taskGroups={memoizedTaskGroups}
|
||||
groupBy={groupBy}
|
||||
/>
|
||||
</Skeleton>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Flex from 'antd/es/flex';
|
||||
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
pointerWithin,
|
||||
} from '@dnd-kit/core';
|
||||
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
import TaskListTableWrapper from './task-list-table/task-list-table-wrapper/task-list-table-wrapper';
|
||||
import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
|
||||
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||
|
||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||
import { useTaskDragAndDrop } from '@/hooks/useTaskDragAndDrop';
|
||||
|
||||
interface TaskGroupWrapperOptimizedProps {
|
||||
taskGroups: ITaskListGroup[];
|
||||
groupBy: string;
|
||||
}
|
||||
|
||||
const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOptimizedProps) => {
|
||||
const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
|
||||
|
||||
// Use extracted hooks
|
||||
useTaskSocketHandlers();
|
||||
const {
|
||||
activeId,
|
||||
sensors,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
handleDragOver,
|
||||
resetTaskRowStyles,
|
||||
} = useTaskDragAndDrop({ taskGroups, groupBy });
|
||||
|
||||
// Memoize task groups with colors
|
||||
const taskGroupsWithColors = useMemo(() =>
|
||||
taskGroups?.map(taskGroup => ({
|
||||
...taskGroup,
|
||||
displayColor: themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code,
|
||||
})) || [],
|
||||
[taskGroups, themeMode]
|
||||
);
|
||||
|
||||
// Add drag styles
|
||||
useEffect(() => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.task-row[data-is-dragging="true"] {
|
||||
opacity: 0.5 !important;
|
||||
transform: rotate(5deg) !important;
|
||||
z-index: 1000 !important;
|
||||
position: relative !important;
|
||||
}
|
||||
.task-row {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(style);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle animation cleanup after drag ends
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (activeId === null) {
|
||||
const timeoutId = setTimeout(resetTaskRowStyles, 50);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [activeId, resetTaskRowStyles]);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
<Flex gap={24} vertical>
|
||||
{taskGroupsWithColors.map(taskGroup => (
|
||||
<TaskListTableWrapper
|
||||
key={taskGroup.id}
|
||||
taskList={taskGroup.tasks}
|
||||
tableId={taskGroup.id}
|
||||
name={taskGroup.name}
|
||||
groupBy={groupBy}
|
||||
statusCategory={taskGroup.category_id}
|
||||
color={taskGroup.displayColor}
|
||||
activeId={activeId}
|
||||
/>
|
||||
))}
|
||||
|
||||
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
|
||||
|
||||
{createPortal(
|
||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
||||
document.body,
|
||||
'task-template-drawer'
|
||||
)}
|
||||
</Flex>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TaskGroupWrapperOptimized);
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
|
||||
import {
|
||||
fetchLabelsByProject,
|
||||
fetchTaskAssignees,
|
||||
@@ -33,23 +34,49 @@ const TaskListFilters: React.FC<TaskListFiltersProps> = ({ position }) => {
|
||||
const { projectView } = useTabSearchParam();
|
||||
|
||||
const priorities = useAppSelector(state => state.priorityReducer.priorities);
|
||||
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
const archived = useAppSelector(state => state.taskReducer.archived);
|
||||
|
||||
const handleShowArchivedChange = () => dispatch(toggleArchived());
|
||||
|
||||
// Load filter data asynchronously and non-blocking
|
||||
// This runs independently of the main task list loading
|
||||
useEffect(() => {
|
||||
const fetchInitialData = async () => {
|
||||
if (!priorities.length) await dispatch(fetchPriorities());
|
||||
if (projectId) {
|
||||
await dispatch(fetchLabelsByProject(projectId));
|
||||
await dispatch(fetchTaskAssignees(projectId));
|
||||
const loadFilterData = async () => {
|
||||
try {
|
||||
// Load priorities first (usually cached/fast)
|
||||
if (!priorities.length) {
|
||||
dispatch(fetchPriorities());
|
||||
}
|
||||
|
||||
// Load project-specific filter data in parallel, but don't await
|
||||
// This allows the main task list to load while filters are still loading
|
||||
if (projectId) {
|
||||
// Fire and forget - these will update the UI when ready
|
||||
dispatch(fetchLabelsByProject(projectId));
|
||||
dispatch(fetchTaskAssignees(projectId));
|
||||
}
|
||||
|
||||
// Load team members (usually needed for member filters)
|
||||
dispatch(getTeamMembers({
|
||||
index: 0,
|
||||
size: 100,
|
||||
field: null,
|
||||
order: null,
|
||||
search: null,
|
||||
all: true
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error loading filter data:', error);
|
||||
// Don't throw - filter loading errors shouldn't break the main UI
|
||||
}
|
||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
||||
};
|
||||
|
||||
fetchInitialData();
|
||||
// Use setTimeout to ensure this runs after the main component render
|
||||
// This prevents filter loading from blocking the initial render
|
||||
const timeoutId = setTimeout(loadFilterData, 0);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [dispatch, priorities.length, projectId]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Flex from 'antd/es/flex';
|
||||
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
|
||||
@@ -87,16 +87,22 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
// Move useSensors to top level and memoize its configuration
|
||||
const sensorConfig = useMemo(
|
||||
() => ({
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const pointerSensor = useSensor(PointerSensor, sensorConfig);
|
||||
const sensors = useSensors(pointerSensor);
|
||||
|
||||
useEffect(() => {
|
||||
setGroups(taskGroups);
|
||||
}, [taskGroups]);
|
||||
|
||||
// Memoize resetTaskRowStyles to prevent unnecessary re-renders
|
||||
const resetTaskRowStyles = useCallback(() => {
|
||||
document.querySelectorAll<HTMLElement>('.task-row').forEach(row => {
|
||||
row.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
|
||||
@@ -106,21 +112,18 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Socket handler for assignee updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
|
||||
// Memoize socket event handlers
|
||||
const handleAssigneesUpdate = useCallback(
|
||||
(data: ITaskAssigneesUpdateResponse) => {
|
||||
if (!data) return;
|
||||
|
||||
const updatedAssignees = data.assignees.map(assignee => ({
|
||||
const updatedAssignees = data.assignees?.map(assignee => ({
|
||||
...assignee,
|
||||
selected: true,
|
||||
}));
|
||||
})) || [];
|
||||
|
||||
// Find the group that contains the task or its subtasks
|
||||
const groupId = groups.find(group =>
|
||||
group.tasks.some(
|
||||
const groupId = groups?.find(group =>
|
||||
group.tasks?.some(
|
||||
task =>
|
||||
task.id === data.id ||
|
||||
(task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id))
|
||||
@@ -136,47 +139,41 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(setTaskAssignee(data));
|
||||
dispatch(
|
||||
setTaskAssignee({
|
||||
...data,
|
||||
manual_progress: false,
|
||||
} as IProjectTask)
|
||||
);
|
||||
|
||||
if (currentSession?.team_id && !loadingAssignees) {
|
||||
dispatch(fetchTaskAssignees(currentSession.team_id));
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[groups, dispatch, currentSession?.team_id, loadingAssignees]
|
||||
);
|
||||
|
||||
socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
||||
return () => {
|
||||
socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
||||
};
|
||||
}, [socket, currentSession?.team_id, loadingAssignees, groups, dispatch]);
|
||||
|
||||
// Socket handler for label updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleLabelsChange = async (labels: ILabelsChangeResponse) => {
|
||||
// Memoize socket event handlers
|
||||
const handleLabelsChange = useCallback(
|
||||
async (labels: ILabelsChangeResponse) => {
|
||||
if (!labels) return;
|
||||
|
||||
await Promise.all([
|
||||
dispatch(updateTaskLabel(labels)),
|
||||
dispatch(setTaskLabels(labels)),
|
||||
dispatch(fetchLabels()),
|
||||
projectId && dispatch(fetchLabelsByProject(projectId)),
|
||||
]);
|
||||
};
|
||||
},
|
||||
[dispatch, projectId]
|
||||
);
|
||||
|
||||
socket.on(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
|
||||
socket.on(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
|
||||
// Memoize socket event handlers
|
||||
const handleTaskStatusChange = useCallback(
|
||||
(response: ITaskListStatusChangeResponse) => {
|
||||
if (!response) return;
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
|
||||
socket.off(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
|
||||
};
|
||||
}, [socket, dispatch, projectId]);
|
||||
|
||||
// Socket handler for status updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleTaskStatusChange = (response: ITaskListStatusChangeResponse) => {
|
||||
if (response.completed_deps === false) {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
@@ -186,11 +183,14 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
}
|
||||
|
||||
dispatch(updateTaskStatus(response));
|
||||
// dispatch(setTaskStatus(response));
|
||||
dispatch(deselectAll());
|
||||
};
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTaskProgress = (data: {
|
||||
// Memoize socket event handlers
|
||||
const handleTaskProgress = useCallback(
|
||||
(data: {
|
||||
id: string;
|
||||
status: string;
|
||||
complete_ratio: number;
|
||||
@@ -198,6 +198,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
total_tasks_count: number;
|
||||
parent_task: string;
|
||||
}) => {
|
||||
if (!data) return;
|
||||
|
||||
dispatch(
|
||||
updateTaskProgress({
|
||||
taskId: data.parent_task || data.id,
|
||||
@@ -206,187 +208,233 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
completedCount: data.completed_count,
|
||||
})
|
||||
);
|
||||
};
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
socket.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
|
||||
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||
// Memoize socket event handlers
|
||||
const handlePriorityChange = useCallback(
|
||||
(response: ITaskListPriorityChangeResponse) => {
|
||||
if (!response) return;
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
|
||||
socket.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for priority updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handlePriorityChange = (response: ITaskListPriorityChangeResponse) => {
|
||||
dispatch(updateTaskPriority(response));
|
||||
dispatch(setTaskPriority(response));
|
||||
dispatch(deselectAll());
|
||||
};
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
socket.on(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for due date updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleEndDateChange = (task: {
|
||||
// Memoize socket event handlers
|
||||
const handleEndDateChange = useCallback(
|
||||
(task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
end_date: string;
|
||||
}) => {
|
||||
dispatch(updateTaskEndDate({ task }));
|
||||
dispatch(setTaskEndDate(task));
|
||||
};
|
||||
if (!task) return;
|
||||
|
||||
socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
||||
const taskWithProgress = {
|
||||
...task,
|
||||
manual_progress: false,
|
||||
} as IProjectTask;
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
dispatch(updateTaskEndDate({ task: taskWithProgress }));
|
||||
dispatch(setTaskEndDate(taskWithProgress));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Socket handler for task name updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
// Memoize socket event handlers
|
||||
const handleTaskNameChange = useCallback(
|
||||
(data: { id: string; parent_task: string; name: string }) => {
|
||||
if (!data) return;
|
||||
|
||||
const handleTaskNameChange = (data: { id: string; parent_task: string; name: string }) => {
|
||||
dispatch(updateTaskName(data));
|
||||
};
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
socket.on(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
|
||||
// Memoize socket event handlers
|
||||
const handlePhaseChange = useCallback(
|
||||
(data: ITaskPhaseChangeResponse) => {
|
||||
if (!data) return;
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for phase updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handlePhaseChange = (data: ITaskPhaseChangeResponse) => {
|
||||
dispatch(updateTaskPhase(data));
|
||||
dispatch(deselectAll());
|
||||
};
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
socket.on(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for start date updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleStartDateChange = (task: {
|
||||
// Memoize socket event handlers
|
||||
const handleStartDateChange = useCallback(
|
||||
(task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
start_date: string;
|
||||
}) => {
|
||||
dispatch(updateTaskStartDate({ task }));
|
||||
dispatch(setStartDate(task));
|
||||
};
|
||||
if (!task) return;
|
||||
|
||||
socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
|
||||
const taskWithProgress = {
|
||||
...task,
|
||||
manual_progress: false,
|
||||
} as IProjectTask;
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
dispatch(updateTaskStartDate({ task: taskWithProgress }));
|
||||
dispatch(setStartDate(taskWithProgress));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Socket handler for task subscribers updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
// Memoize socket event handlers
|
||||
const handleTaskSubscribersChange = useCallback(
|
||||
(data: InlineMember[]) => {
|
||||
if (!data) return;
|
||||
|
||||
const handleTaskSubscribersChange = (data: InlineMember[]) => {
|
||||
dispatch(setTaskSubscribers(data));
|
||||
};
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
socket.on(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for task estimation updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleEstimationChange = (task: {
|
||||
// Memoize socket event handlers
|
||||
const handleEstimationChange = useCallback(
|
||||
(task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
estimation: number;
|
||||
}) => {
|
||||
dispatch(updateTaskEstimation({ task }));
|
||||
};
|
||||
if (!task) return;
|
||||
|
||||
socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
|
||||
const taskWithProgress = {
|
||||
...task,
|
||||
manual_progress: false,
|
||||
} as IProjectTask;
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
dispatch(updateTaskEstimation({ task: taskWithProgress }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Socket handler for task description updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleTaskDescriptionChange = (data: {
|
||||
// Memoize socket event handlers
|
||||
const handleTaskDescriptionChange = useCallback(
|
||||
(data: {
|
||||
id: string;
|
||||
parent_task: string;
|
||||
description: string;
|
||||
}) => {
|
||||
if (!data) return;
|
||||
|
||||
dispatch(updateTaskDescription(data));
|
||||
};
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
// Socket handler for new task creation
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleNewTaskReceived = (data: IProjectTask) => {
|
||||
// Memoize socket event handlers
|
||||
const handleNewTaskReceived = useCallback(
|
||||
(data: IProjectTask) => {
|
||||
if (!data) return;
|
||||
|
||||
if (data.parent_task_id) {
|
||||
dispatch(updateSubTasks(data));
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Memoize socket event handlers
|
||||
const handleTaskProgressUpdated = useCallback(
|
||||
(data: {
|
||||
task_id: string;
|
||||
progress_value?: number;
|
||||
weight?: number;
|
||||
}) => {
|
||||
if (!data || !taskGroups) return;
|
||||
|
||||
if (data.progress_value !== undefined) {
|
||||
for (const group of taskGroups) {
|
||||
const task = group.tasks?.find(task => task.id === data.task_id);
|
||||
if (task) {
|
||||
dispatch(
|
||||
updateTaskProgress({
|
||||
taskId: data.task_id,
|
||||
progress: data.progress_value,
|
||||
totalTasksCount: task.total_tasks_count || 0,
|
||||
completedCount: task.completed_count || 0,
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[dispatch, taskGroups]
|
||||
);
|
||||
|
||||
// Set up socket event listeners
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const eventHandlers = {
|
||||
[SocketEvents.QUICK_ASSIGNEES_UPDATE.toString()]: handleAssigneesUpdate,
|
||||
[SocketEvents.TASK_LABELS_CHANGE.toString()]: handleLabelsChange,
|
||||
[SocketEvents.CREATE_LABEL.toString()]: handleLabelsChange,
|
||||
[SocketEvents.TASK_STATUS_CHANGE.toString()]: handleTaskStatusChange,
|
||||
[SocketEvents.GET_TASK_PROGRESS.toString()]: handleTaskProgress,
|
||||
[SocketEvents.TASK_PRIORITY_CHANGE.toString()]: handlePriorityChange,
|
||||
[SocketEvents.TASK_END_DATE_CHANGE.toString()]: handleEndDateChange,
|
||||
[SocketEvents.TASK_NAME_CHANGE.toString()]: handleTaskNameChange,
|
||||
[SocketEvents.TASK_PHASE_CHANGE.toString()]: handlePhaseChange,
|
||||
[SocketEvents.TASK_START_DATE_CHANGE.toString()]: handleStartDateChange,
|
||||
[SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString()]: handleTaskSubscribersChange,
|
||||
[SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString()]: handleEstimationChange,
|
||||
[SocketEvents.TASK_DESCRIPTION_CHANGE.toString()]: handleTaskDescriptionChange,
|
||||
[SocketEvents.QUICK_TASK.toString()]: handleNewTaskReceived,
|
||||
[SocketEvents.TASK_PROGRESS_UPDATED.toString()]: handleTaskProgressUpdated,
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
|
||||
// Register all event handlers
|
||||
Object.entries(eventHandlers).forEach(([event, handler]) => {
|
||||
if (handler) {
|
||||
socket.on(event, handler);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
|
||||
Object.entries(eventHandlers).forEach(([event, handler]) => {
|
||||
if (handler) {
|
||||
socket.off(event, handler);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
}, [
|
||||
socket,
|
||||
handleAssigneesUpdate,
|
||||
handleLabelsChange,
|
||||
handleTaskStatusChange,
|
||||
handleTaskProgress,
|
||||
handlePriorityChange,
|
||||
handleEndDateChange,
|
||||
handleTaskNameChange,
|
||||
handlePhaseChange,
|
||||
handleStartDateChange,
|
||||
handleTaskSubscribersChange,
|
||||
handleEstimationChange,
|
||||
handleTaskDescriptionChange,
|
||||
handleNewTaskReceived,
|
||||
handleTaskProgressUpdated,
|
||||
]);
|
||||
|
||||
// Memoize drag handlers
|
||||
const handleDragStart = useCallback(({ active }: DragStartEvent) => {
|
||||
setActiveId(active.id as string);
|
||||
|
||||
// Add smooth transition to the dragged item
|
||||
const draggedElement = document.querySelector(`[data-id="${active.id}"]`);
|
||||
if (draggedElement) {
|
||||
(draggedElement as HTMLElement).style.transition = 'transform 0.2s ease';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Memoize drag handlers
|
||||
const handleDragEnd = useCallback(
|
||||
async ({ active, over }: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
@@ -405,10 +453,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
|
||||
if (fromIndex === -1) return;
|
||||
|
||||
// Create a deep clone of the task to avoid reference issues
|
||||
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
|
||||
|
||||
// Check if task dependencies allow the move
|
||||
if (activeGroupId !== overGroupId) {
|
||||
const canContinue = await checkTaskDependencyStatus(task.id, overGroupId);
|
||||
if (!canContinue) {
|
||||
@@ -420,7 +466,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update task properties based on target group
|
||||
switch (groupBy) {
|
||||
case IGroupBy.STATUS:
|
||||
task.status = overGroupId;
|
||||
@@ -433,35 +478,29 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
task.priority_color_dark = targetGroup.color_code_dark;
|
||||
break;
|
||||
case IGroupBy.PHASE:
|
||||
// Check if ALPHA_CHANNEL is already added
|
||||
const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL)
|
||||
? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) // Remove ALPHA_CHANNEL
|
||||
: targetGroup.color_code; // Use as is if not present
|
||||
? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length)
|
||||
: targetGroup.color_code;
|
||||
task.phase_id = overGroupId;
|
||||
task.phase_color = baseColor; // Set the cleaned color
|
||||
task.phase_color = baseColor;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const isTargetGroupEmpty = targetGroup.tasks.length === 0;
|
||||
|
||||
// Calculate toIndex - for empty groups, always add at index 0
|
||||
const toIndex = isTargetGroupEmpty
|
||||
? 0
|
||||
: overTaskId
|
||||
? targetGroup.tasks.findIndex(t => t.id === overTaskId)
|
||||
: targetGroup.tasks.length;
|
||||
|
||||
// Calculate toPos similar to Angular implementation
|
||||
const toPos = isTargetGroupEmpty
|
||||
? -1
|
||||
: targetGroup.tasks[toIndex]?.sort_order ||
|
||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||
-1;
|
||||
|
||||
// Update Redux state
|
||||
if (activeGroupId === overGroupId) {
|
||||
// Same group - move within array
|
||||
const updatedTasks = [...sourceGroup.tasks];
|
||||
updatedTasks.splice(fromIndex, 1);
|
||||
updatedTasks.splice(toIndex, 0, task);
|
||||
@@ -479,7 +518,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Different groups - transfer between arrays
|
||||
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
||||
const updatedTargetTasks = [...targetGroup.tasks];
|
||||
|
||||
@@ -505,7 +543,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Emit socket event
|
||||
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
||||
project_id: projectId,
|
||||
from_index: sourceGroup.tasks[fromIndex].sort_order,
|
||||
@@ -514,13 +551,11 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
from_group: sourceGroup.id,
|
||||
to_group: targetGroup.id,
|
||||
group_by: groupBy,
|
||||
task: sourceGroup.tasks[fromIndex], // Send original task to maintain references
|
||||
task: sourceGroup.tasks[fromIndex],
|
||||
team_id: currentSession?.team_id,
|
||||
});
|
||||
|
||||
// Reset styles
|
||||
setTimeout(resetTaskRowStyles, 0);
|
||||
|
||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||
},
|
||||
[
|
||||
@@ -535,6 +570,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
]
|
||||
);
|
||||
|
||||
// Memoize drag handlers
|
||||
const handleDragOver = useCallback(
|
||||
({ active, over }: DragEndEvent) => {
|
||||
if (!over) return;
|
||||
@@ -554,12 +590,9 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
|
||||
if (fromIndex === -1 || toIndex === -1) return;
|
||||
|
||||
// Create a deep clone of the task to avoid reference issues
|
||||
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
|
||||
|
||||
// Update Redux state
|
||||
if (activeGroupId === overGroupId) {
|
||||
// Same group - move within array
|
||||
const updatedTasks = [...sourceGroup.tasks];
|
||||
updatedTasks.splice(fromIndex, 1);
|
||||
updatedTasks.splice(toIndex, 0, task);
|
||||
@@ -577,10 +610,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Different groups - transfer between arrays
|
||||
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
||||
const updatedTargetTasks = [...targetGroup.tasks];
|
||||
|
||||
updatedTargetTasks.splice(toIndex, 0, task);
|
||||
|
||||
dispatch({
|
||||
@@ -628,7 +659,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
// Handle animation cleanup after drag ends
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (activeId === null) {
|
||||
// Final cleanup after React updates DOM
|
||||
const timeoutId = setTimeout(resetTaskRowStyles, 50);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Progress, Tooltip } from 'antd';
|
||||
import './task-list-progress-cell.css';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
type TaskListProgressCellProps = {
|
||||
task: IProjectTask;
|
||||
};
|
||||
|
||||
const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => {
|
||||
return task.is_sub_task ? null : (
|
||||
<Tooltip title={`${task.completed_count || 0} / ${task.total_tasks_count || 0}`}>
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
const isManualProgressEnabled = (task.project_use_manual_progress || task.project_use_weighted_progress || task.project_use_time_progress);;
|
||||
const isSubtask = task.is_sub_task;
|
||||
const hasManualProgress = task.manual_progress;
|
||||
|
||||
// Handle different cases:
|
||||
// 1. For subtasks when manual progress is enabled, show the progress
|
||||
// 2. For parent tasks, always show progress
|
||||
// 3. For subtasks when manual progress is not enabled, don't show progress (null)
|
||||
|
||||
if (isSubtask && !isManualProgressEnabled) {
|
||||
return null; // Don't show progress for subtasks when manual progress is disabled
|
||||
}
|
||||
|
||||
// For parent tasks, show completion ratio with task count tooltip
|
||||
if (!isSubtask) {
|
||||
return (
|
||||
<Tooltip title={`${task.completed_count || 0} / ${task.total_tasks_count || 0}`}>
|
||||
<Progress
|
||||
percent={task.complete_ratio || 0}
|
||||
type="circle"
|
||||
size={24}
|
||||
style={{ cursor: 'default' }}
|
||||
strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// For subtasks with manual progress enabled, show the progress
|
||||
return (
|
||||
<Tooltip
|
||||
title={hasManualProgress ? `Manual: ${task.progress_value || 0}%` : `${task.progress || 0}%`}
|
||||
>
|
||||
<Progress
|
||||
percent={task.complete_ratio || 0}
|
||||
percent={hasManualProgress ? (task.progress_value || 0) : (task.progress || 0)}
|
||||
type="circle"
|
||||
size={24}
|
||||
size={22} // Slightly smaller for subtasks
|
||||
style={{ cursor: 'default' }}
|
||||
strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7}
|
||||
strokeWidth={(task.progress || 0) >= 100 ? 9 : 7}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -86,7 +86,7 @@ const TaskListTaskCell = ({
|
||||
isSubTask: boolean,
|
||||
subTasksCount: number
|
||||
) => {
|
||||
if (subTasksCount > 0) {
|
||||
if (subTasksCount > 0 && !isSubTask) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleToggleExpansion(taskId)}
|
||||
@@ -112,23 +112,21 @@ const TaskListTaskCell = ({
|
||||
const renderSubtasksCountLabel = (taskId: string, isSubTask: boolean, subTasksCount: number) => {
|
||||
if (!taskId) return null;
|
||||
return (
|
||||
!isSubTask && (
|
||||
<Button
|
||||
onClick={() => handleToggleExpansion(taskId)}
|
||||
size="small"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
paddingInline: 4,
|
||||
alignItems: 'center',
|
||||
justifyItems: 'center',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Typography.Text style={{ fontSize: 12, lineHeight: 1 }}>{subTasksCount}</Typography.Text>
|
||||
<DoubleRightOutlined style={{ fontSize: 10 }} />
|
||||
</Button>
|
||||
)
|
||||
<Button
|
||||
onClick={() => handleToggleExpansion(taskId)}
|
||||
size="small"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
paddingInline: 4,
|
||||
alignItems: 'center',
|
||||
justifyItems: 'center',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Typography.Text style={{ fontSize: 12, lineHeight: 1 }}>{subTasksCount}</Typography.Text>
|
||||
<DoubleRightOutlined style={{ fontSize: 10 }} />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1548,7 +1548,6 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
||||
};
|
||||
|
||||
const handleCustomColumnSettings = (columnKey: string) => {
|
||||
console.log('columnKey', columnKey);
|
||||
if (!columnKey) return;
|
||||
setEditColumnKey(columnKey);
|
||||
dispatch(setCustomColumnModalAttributes({modalType: 'edit', columnId: columnKey}));
|
||||
|
||||
@@ -6,9 +6,14 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import CustomTableTitle from '@/components/CustomTableTitle';
|
||||
import TasksProgressCell from './tablesCells/tasksProgressCell/TasksProgressCell';
|
||||
import MemberCell from './tablesCells/memberCell/MemberCell';
|
||||
import { fetchMembersData, toggleMembersReportsDrawer } from '@/features/reporting/membersReports/membersReportsSlice';
|
||||
import {
|
||||
fetchMembersData,
|
||||
setPagination,
|
||||
toggleMembersReportsDrawer,
|
||||
} from '@/features/reporting/membersReports/membersReportsSlice';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import MembersReportsDrawer from '@/features/reporting/membersReports/membersReportsDrawer/members-reports-drawer';
|
||||
import { PaginationConfig } from 'antd/es/pagination';
|
||||
|
||||
const MembersReportsTable = () => {
|
||||
const { t } = useTranslation('reporting-members');
|
||||
@@ -16,7 +21,9 @@ const MembersReportsTable = () => {
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
|
||||
const { membersList, isLoading, total, archived, searchQuery } = useAppSelector(state => state.membersReportsReducer);
|
||||
const { membersList, isLoading, total, archived, searchQuery, index, pageSize } = useAppSelector(
|
||||
state => state.membersReportsReducer
|
||||
);
|
||||
|
||||
// function to handle drawer toggle
|
||||
const handleDrawerOpen = (id: string) => {
|
||||
@@ -24,6 +31,10 @@ const MembersReportsTable = () => {
|
||||
dispatch(toggleMembersReportsDrawer());
|
||||
};
|
||||
|
||||
const handleOnChange = (pagination: any, filters: any, sorter: any, extra: any) => {
|
||||
dispatch(setPagination({ index: pagination.current, pageSize: pagination.pageSize }));
|
||||
};
|
||||
|
||||
const columns: TableColumnsType = [
|
||||
{
|
||||
key: 'member',
|
||||
@@ -40,7 +51,7 @@ const MembersReportsTable = () => {
|
||||
title: <CustomTableTitle title={t('tasksProgressColumn')} />,
|
||||
render: record => {
|
||||
const { todo, doing, done } = record.tasks_stat;
|
||||
return (todo || doing || done) ? <TasksProgressCell tasksStat={record.tasks_stat} /> : '-';
|
||||
return todo || doing || done ? <TasksProgressCell tasksStat={record.tasks_stat} /> : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -95,7 +106,7 @@ const MembersReportsTable = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) dispatch(fetchMembersData({ duration, dateRange }));
|
||||
}, [dispatch, archived, searchQuery, dateRange]);
|
||||
}, [dispatch, archived, searchQuery, dateRange, index, pageSize]);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
@@ -113,6 +124,7 @@ const MembersReportsTable = () => {
|
||||
dataSource={membersList}
|
||||
rowKey={record => record.id}
|
||||
pagination={{ showSizeChanger: true, defaultPageSize: 10, total: total }}
|
||||
onChange={(pagination, filters, sorter, extra) => handleOnChange(pagination, filters, sorter, extra)}
|
||||
scroll={{ x: 'max-content' }}
|
||||
loading={isLoading}
|
||||
onRow={record => {
|
||||
|
||||
@@ -25,9 +25,7 @@ const MembersReports = () => {
|
||||
useDocumentTitle('Reporting - Members');
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const { archived, searchQuery } = useAppSelector(
|
||||
state => state.membersReportsReducer,
|
||||
);
|
||||
const { archived, searchQuery, total } = useAppSelector(state => state.membersReportsReducer);
|
||||
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
|
||||
|
||||
|
||||
@@ -44,7 +42,7 @@ const MembersReports = () => {
|
||||
return (
|
||||
<Flex vertical>
|
||||
<CustomPageHeader
|
||||
title={`Members`}
|
||||
title={`Members (${total})`}
|
||||
children={
|
||||
<Space>
|
||||
<Button>
|
||||
|
||||
@@ -81,6 +81,22 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
display: false,
|
||||
position: 'top' as const,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
const idx = context.dataIndex;
|
||||
const member = jsonData[idx];
|
||||
const hours = member?.utilized_hours || '0.00';
|
||||
const percent = member?.utilization_percent || '0.00';
|
||||
const overUnder = member?.over_under_utilized_hours || '0.00';
|
||||
return [
|
||||
`${context.dataset.label}: ${hours} h`,
|
||||
`Utilization: ${percent}%`,
|
||||
`Over/Under Utilized: ${overUnder} h`
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundColor: 'black',
|
||||
indexAxis: 'y' as const,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Card, Divider, Flex, Switch, Typography } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { toggleTheme } from '@/features/theme/themeSlice';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { MoonOutlined, SunOutlined } from '@ant-design/icons';
|
||||
|
||||
const AppearanceSettings = () => {
|
||||
const { t } = useTranslation('settings/appearance');
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useDocumentTitle(t('title'));
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
dispatch(toggleTheme());
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={{ width: '100%' }}>
|
||||
<Flex vertical gap={4}>
|
||||
<Flex gap={8} align="center">
|
||||
<Switch
|
||||
checked={themeMode === 'dark'}
|
||||
onChange={handleThemeToggle}
|
||||
checkedChildren={<MoonOutlined />}
|
||||
unCheckedChildren={<SunOutlined />}
|
||||
/>
|
||||
<Typography.Title level={4} style={{ marginBlockEnd: 0 }}>
|
||||
{t('darkMode')}
|
||||
</Typography.Title>
|
||||
</Flex>
|
||||
<Typography.Text
|
||||
style={{ fontSize: 14, color: themeMode === 'dark' ? '#9CA3AF' : '#00000073' }}
|
||||
>
|
||||
{t('darkModeDescription')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppearanceSettings;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user