Merge branch 'feature/recurring-tasks' of https://github.com/Worklenz/worklenz into release/v2.0.3

This commit is contained in:
chamikaJ
2025-06-11 12:41:04 +05:30
17 changed files with 368 additions and 65 deletions

View File

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

View File

@@ -47,5 +47,6 @@
"weightedProgress": "Weighted Progress",
"weightedProgressTooltip": "Calculate progress based on subtask weights",
"timeProgress": "Time-based Progress",
"timeProgressTooltip": "Calculate progress based on estimated time"
"timeProgressTooltip": "Calculate progress based on estimated time",
"enterProjectKey": "Enter project key"
}

View File

@@ -47,5 +47,6 @@
"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"
"timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado",
"enterProjectKey": "Ingresa la clave del proyecto"
}

View File

@@ -47,5 +47,6 @@
"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"
"timeProgressTooltip": "Calcular o progresso com base no tempo estimado",
"enterProjectKey": "Insira a chave do projeto"
}

View File

@@ -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}`;
},

View File

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

View File

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

View File

@@ -437,7 +437,7 @@ const TaskListBulkActionsBar = () => {
placement="top"
arrow
trigger={['click']}
destroyPopupOnHide
destroyOnHidden
onOpenChange={value => {
if (!value) {
setSelectedLabels([]);

View File

@@ -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%',
@@ -144,7 +145,7 @@ const Navbar = () => {
<Flex align="center">
<SwitchTeamButton />
<NotificationButton />
<HelpButton />
<TimerButton />
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
</Flex>
</Flex>

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

View File

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

View File

@@ -177,7 +177,7 @@ const ProjectView = () => {
onChange={handleTabChange}
items={tabMenuItems}
tabBarStyle={{ paddingInline: 0 }}
destroyInactiveTabPane={true}
destroyOnHidden={true}
/>
{portalElements}

View File

@@ -19,36 +19,19 @@ const ProjectViewTaskList = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
// Combine related selectors to reduce subscriptions
const {
projectId,
taskGroups,
loadingGroups,
groupBy,
archived,
fields,
search,
} = useAppSelector(state => ({
projectId: state.projectReducer.projectId,
taskGroups: state.taskReducer.taskGroups,
loadingGroups: state.taskReducer.loadingGroups,
groupBy: state.taskReducer.groupBy,
archived: state.taskReducer.archived,
fields: state.taskReducer.fields,
search: state.taskReducer.search,
}));
// 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,
loading: loadingStatusCategories,
} = useAppSelector(state => ({
statusCategories: state.taskStatusReducer.statusCategories,
loading: state.taskStatusReducer.loading,
}));
const statusCategories = useAppSelector(state => state.taskStatusReducer.statusCategories);
const loadingStatusCategories = useAppSelector(state => state.taskStatusReducer.loading);
const { loadingPhases } = useAppSelector(state => ({
loadingPhases: state.phaseReducer.loadingPhases,
}));
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