Merge branch 'feature/project-list-grouping' into upstream/feature/project-groupby
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user