Merge branch 'main' of https://github.com/Worklenz/worklenz into feature/task-activities-by-user
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import { IBillingAccountInfo, IBillingAccountStorage, IFreePlanSettings } from '@/types/admin-center/admin-center.types';
|
||||
import {
|
||||
IBillingAccountInfo,
|
||||
IBillingAccountStorage,
|
||||
IFreePlanSettings,
|
||||
} from '@/types/admin-center/admin-center.types';
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
interface adminCenterState {
|
||||
@@ -27,10 +31,13 @@ export const fetchBillingInfo = createAsyncThunk('adminCenter/fetchBillingInfo',
|
||||
return res.body;
|
||||
});
|
||||
|
||||
export const fetchFreePlanSettings = createAsyncThunk('adminCenter/fetchFreePlanSettings', async () => {
|
||||
const res = await adminCenterApiService.getFreePlanSettings();
|
||||
return res.body;
|
||||
});
|
||||
export const fetchFreePlanSettings = createAsyncThunk(
|
||||
'adminCenter/fetchFreePlanSettings',
|
||||
async () => {
|
||||
const res = await adminCenterApiService.getFreePlanSettings();
|
||||
return res.body;
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchStorageInfo = createAsyncThunk('adminCenter/fetchStorageInfo', async () => {
|
||||
const res = await adminCenterApiService.getAccountStorage();
|
||||
@@ -42,10 +49,14 @@ const adminCenterSlice = createSlice({
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleRedeemCodeDrawer: state => {
|
||||
state.isRedeemCodeDrawerOpen ? (state.isRedeemCodeDrawerOpen = false) : (state.isRedeemCodeDrawerOpen = true);
|
||||
state.isRedeemCodeDrawerOpen
|
||||
? (state.isRedeemCodeDrawerOpen = false)
|
||||
: (state.isRedeemCodeDrawerOpen = true);
|
||||
},
|
||||
toggleUpgradeModal: state => {
|
||||
state.isUpgradeModalOpen ? (state.isUpgradeModalOpen = false) : (state.isUpgradeModalOpen = true);
|
||||
state.isUpgradeModalOpen
|
||||
? (state.isUpgradeModalOpen = false)
|
||||
: (state.isUpgradeModalOpen = true);
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
@@ -78,6 +89,5 @@ const adminCenterSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
export const { toggleRedeemCodeDrawer, toggleUpgradeModal } = adminCenterSlice.actions;
|
||||
export default adminCenterSlice.reducer;
|
||||
|
||||
@@ -76,6 +76,10 @@ interface BoardState {
|
||||
priorities: string[];
|
||||
members: string[];
|
||||
editableSectionId: string | null;
|
||||
|
||||
allTasks: IProjectTask[];
|
||||
grouping: string;
|
||||
totalTasks: number;
|
||||
}
|
||||
|
||||
const initialState: BoardState = {
|
||||
@@ -98,6 +102,9 @@ const initialState: BoardState = {
|
||||
priorities: [],
|
||||
members: [],
|
||||
editableSectionId: null,
|
||||
allTasks: [],
|
||||
grouping: '',
|
||||
totalTasks: 0,
|
||||
};
|
||||
|
||||
const deleteTaskFromGroup = (
|
||||
@@ -186,7 +193,7 @@ export const fetchBoardTaskGroups = createAsyncThunk(
|
||||
priorities: boardReducer.priorities.join(' '),
|
||||
};
|
||||
|
||||
const response = await tasksApiService.getTaskList(config);
|
||||
const response = await tasksApiService.getTaskListV3(config);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Task Groups', error);
|
||||
@@ -400,11 +407,11 @@ const boardSlice = createSlice({
|
||||
section.tasks.splice(taskIndex, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if task is in subtasks
|
||||
for (const parentTask of section.tasks) {
|
||||
if (!parentTask.sub_tasks) continue;
|
||||
|
||||
|
||||
const subtaskIndex = parentTask.sub_tasks.findIndex(st => st.id === taskId);
|
||||
if (subtaskIndex !== -1) {
|
||||
parentTask.sub_tasks.splice(subtaskIndex, 1);
|
||||
@@ -423,11 +430,11 @@ const boardSlice = createSlice({
|
||||
group.tasks.splice(taskIndex, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check subtasks
|
||||
for (const parentTask of group.tasks) {
|
||||
if (!parentTask.sub_tasks) continue;
|
||||
|
||||
|
||||
const subtaskIndex = parentTask.sub_tasks.findIndex(st => st.id === taskId);
|
||||
if (subtaskIndex !== -1) {
|
||||
parentTask.sub_tasks.splice(subtaskIndex, 1);
|
||||
@@ -459,10 +466,24 @@ const boardSlice = createSlice({
|
||||
const { body, sectionId, taskId } = action.payload;
|
||||
const section = state.taskGroups.find(sec => sec.id === sectionId);
|
||||
if (section) {
|
||||
const task = section.tasks.find(task => task.id === taskId);
|
||||
if (task) {
|
||||
task.assignees = body.assignees;
|
||||
task.names = body.names;
|
||||
// First try to find the task in main tasks
|
||||
const mainTask = section.tasks.find(task => task.id === taskId);
|
||||
if (mainTask) {
|
||||
mainTask.assignees = body.assignees;
|
||||
mainTask.names = body.names;
|
||||
return;
|
||||
}
|
||||
|
||||
// If not found in main tasks, look in subtasks
|
||||
for (const parentTask of section.tasks) {
|
||||
if (!parentTask.sub_tasks) continue;
|
||||
|
||||
const subtask = parentTask.sub_tasks.find(st => st.id === taskId);
|
||||
if (subtask) {
|
||||
subtask.assignees = body.assignees;
|
||||
subtask.names = body.names;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -789,7 +810,11 @@ const boardSlice = createSlice({
|
||||
})
|
||||
.addCase(fetchBoardTaskGroups.fulfilled, (state, action) => {
|
||||
state.loadingGroups = false;
|
||||
state.taskGroups = action.payload;
|
||||
state.taskGroups = action.payload && action.payload.groups ? action.payload.groups : [];
|
||||
state.allTasks = action.payload && action.payload.allTasks ? action.payload.allTasks : [];
|
||||
state.grouping = action.payload && action.payload.grouping ? action.payload.grouping : '';
|
||||
state.totalTasks =
|
||||
action.payload && action.payload.totalTasks ? action.payload.totalTasks : 0;
|
||||
})
|
||||
.addCase(fetchBoardTaskGroups.rejected, (state, action) => {
|
||||
state.loadingGroups = false;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ const LanguageSelector = () => {
|
||||
{ key: 'pt', label: 'Português' },
|
||||
{ key: 'alb', label: 'Shqip' },
|
||||
{ key: 'de', label: 'Deutsch' },
|
||||
{ key: 'zh_cn', label: '简体中文' },
|
||||
];
|
||||
|
||||
const languageLabels = {
|
||||
@@ -25,6 +26,7 @@ const LanguageSelector = () => {
|
||||
pt: 'Pt',
|
||||
alb: 'Sq',
|
||||
de: 'de',
|
||||
zh_cn: 'zh_cn',
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum Language {
|
||||
PT = 'pt',
|
||||
ALB = 'alb',
|
||||
DE = 'de',
|
||||
ZH_CN = 'zh_cn',
|
||||
}
|
||||
|
||||
export type ILanguageType = `${Language}`;
|
||||
|
||||
@@ -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,8 @@ 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';
|
||||
import HelpButton from './help/HelpButton';
|
||||
|
||||
const Navbar = () => {
|
||||
const [current, setCurrent] = useState<string>('home');
|
||||
@@ -34,15 +35,18 @@ const Navbar = () => {
|
||||
const authService = useAuthService();
|
||||
const [navRoutesList, setNavRoutesList] = useState<NavRoutesType[]>(navRoutes);
|
||||
const [isOwnerOrAdmin, setIsOwnerOrAdmin] = useState<boolean>(authService.isOwnerOrAdmin());
|
||||
const showUpgradeTypes = [ISUBSCRIPTION_TYPE.TRIAL]
|
||||
const showUpgradeTypes = [ISUBSCRIPTION_TYPE.TRIAL];
|
||||
|
||||
useEffect(() => {
|
||||
authApiService.verify().then(authorizeResponse => {
|
||||
authApiService
|
||||
.verify()
|
||||
.then(authorizeResponse => {
|
||||
if (authorizeResponse.authenticated) {
|
||||
authService.setCurrentSession(authorizeResponse.user);
|
||||
setIsOwnerOrAdmin(!!(authorizeResponse.user.is_admin || authorizeResponse.user.owner));
|
||||
}
|
||||
}).catch(error => {
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error('Error during authorization', error);
|
||||
});
|
||||
}, []);
|
||||
@@ -66,9 +70,13 @@ const Navbar = () => {
|
||||
() =>
|
||||
navRoutesList
|
||||
.filter(route => {
|
||||
if (!route.freePlanFeature && currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE) return false;
|
||||
if (route.adminOnly && !isOwnerOrAdmin) return false;
|
||||
|
||||
if (
|
||||
!route.freePlanFeature &&
|
||||
currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE
|
||||
)
|
||||
return false;
|
||||
if (route.adminOnly && !isOwnerOrAdmin) return false;
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((route, index) => ({
|
||||
@@ -101,14 +109,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%',
|
||||
@@ -145,13 +145,15 @@ const Navbar = () => {
|
||||
<ConfigProvider wave={{ disabled: true }}>
|
||||
{isDesktop && (
|
||||
<Flex gap={20} align="center">
|
||||
{isOwnerOrAdmin && showUpgradeTypes.includes(currentSession?.subscription_type as ISUBSCRIPTION_TYPE) && (
|
||||
<UpgradePlanButton />
|
||||
)}
|
||||
{isOwnerOrAdmin &&
|
||||
showUpgradeTypes.includes(
|
||||
currentSession?.subscription_type as ISUBSCRIPTION_TYPE
|
||||
) && <UpgradePlanButton />}
|
||||
{isOwnerOrAdmin && <InviteButton />}
|
||||
<Flex align="center">
|
||||
<SwitchTeamButton />
|
||||
<NotificationButton />
|
||||
{/* <TimerButton /> */}
|
||||
<HelpButton />
|
||||
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
|
||||
</Flex>
|
||||
|
||||
398
worklenz-frontend/src/features/navbar/timers/timer-button.tsx
Normal file
398
worklenz-frontend/src/features/navbar/timers/timer-button.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const { t } = useTranslation('navbar');
|
||||
const { token } = useToken();
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket } = useSocket();
|
||||
|
||||
const logError = (message: string, error?: any) => {
|
||||
// Production-safe error logging
|
||||
console.error(`[TimerButton] ${message}`, error);
|
||||
setError(message);
|
||||
};
|
||||
|
||||
const fetchRunningTimers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await taskTimeLogsApiService.getRunningTimers();
|
||||
|
||||
if (response && response.done) {
|
||||
const timers = Array.isArray(response.body) ? response.body : [];
|
||||
setRunningTimers(timers);
|
||||
} else {
|
||||
logError('Invalid response from getRunningTimers API');
|
||||
setRunningTimers([]);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Error fetching running timers', error);
|
||||
setRunningTimers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateCurrentTimes = useCallback(() => {
|
||||
try {
|
||||
if (!Array.isArray(runningTimers) || runningTimers.length === 0) return;
|
||||
|
||||
const newTimes: Record<string, string> = {};
|
||||
runningTimers.forEach(timer => {
|
||||
try {
|
||||
if (!timer || !timer.task_id || !timer.start_time) return;
|
||||
|
||||
const startTime = moment(timer.start_time);
|
||||
if (!startTime.isValid()) {
|
||||
logError(`Invalid start time for timer ${timer.task_id}: ${timer.start_time}`);
|
||||
return;
|
||||
}
|
||||
|
||||
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')}`;
|
||||
} catch (error) {
|
||||
logError(`Error updating time for timer ${timer?.task_id}`, error);
|
||||
}
|
||||
});
|
||||
setCurrentTimes(newTimes);
|
||||
} catch (error) {
|
||||
logError('Error in updateCurrentTimes', error);
|
||||
}
|
||||
}, [runningTimers]);
|
||||
|
||||
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, updateCurrentTimes]);
|
||||
|
||||
// Listen for timer start/stop events and project updates to refresh the count
|
||||
useEffect(() => {
|
||||
if (!socket) {
|
||||
logError('Socket not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const handleTimerStart = (data: string) => {
|
||||
try {
|
||||
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
const { id } = parsed || {};
|
||||
if (id) {
|
||||
// Refresh the running timers list when a new timer is started
|
||||
fetchRunningTimers();
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Error parsing timer start event', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimerStop = (data: string) => {
|
||||
try {
|
||||
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
const { id } = parsed || {};
|
||||
if (id) {
|
||||
// Refresh the running timers list when a timer is stopped
|
||||
fetchRunningTimers();
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Error parsing timer stop event', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectUpdates = () => {
|
||||
try {
|
||||
// Refresh timers when project updates are available
|
||||
fetchRunningTimers();
|
||||
} catch (error) {
|
||||
logError('Error handling project updates', error);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
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 () => {
|
||||
try {
|
||||
socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
|
||||
socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
|
||||
socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
|
||||
} catch (error) {
|
||||
logError('Error cleaning up socket listeners', error);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logError('Error setting up socket listeners', error);
|
||||
}
|
||||
}, [socket, fetchRunningTimers]);
|
||||
|
||||
const hasRunningTimers = () => {
|
||||
return Array.isArray(runningTimers) && runningTimers.length > 0;
|
||||
};
|
||||
|
||||
const timerCount = () => {
|
||||
return Array.isArray(runningTimers) ? runningTimers.length : 0;
|
||||
};
|
||||
|
||||
const handleStopTimer = (taskId: string) => {
|
||||
if (!socket) {
|
||||
logError('Socket not available for stopping timer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!taskId) {
|
||||
logError('Invalid task ID for stopping timer');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId }));
|
||||
dispatch(updateTaskTimeTracking({ taskId, timeTracking: null }));
|
||||
} catch (error) {
|
||||
logError(`Error stopping timer for task ${taskId}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderDropdownContent = () => {
|
||||
try {
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: 16, textAlign: 'center', width: 350 }}>
|
||||
<Text type="danger">Error loading timers</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 350,
|
||||
maxHeight: 400,
|
||||
overflow: 'auto',
|
||||
backgroundColor: token.colorBgElevated,
|
||||
borderRadius: token.borderRadius,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{!Array.isArray(runningTimers) || runningTimers.length === 0 ? (
|
||||
<div style={{ padding: 16, textAlign: 'center' }}>
|
||||
<Text type="secondary">No running timers</Text>
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
dataSource={runningTimers}
|
||||
renderItem={timer => {
|
||||
if (!timer || !timer.task_id) return null;
|
||||
|
||||
return (
|
||||
<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 || 'Unnamed Task'}
|
||||
</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 || 'Unnamed Project'}
|
||||
</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:{' '}
|
||||
{timer.start_time
|
||||
? 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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasRunningTimers() && (
|
||||
<>
|
||||
<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 }}>
|
||||
{timerCount()} timer{timerCount() !== 1 ? 's' : ''} running
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
logError('Error rendering dropdown content', error);
|
||||
return (
|
||||
<div style={{ padding: 16, textAlign: 'center', width: 350 }}>
|
||||
<Text type="danger">Error rendering timers</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropdownOpenChange = (open: boolean) => {
|
||||
try {
|
||||
setDropdownOpen(open);
|
||||
if (open) {
|
||||
fetchRunningTimers();
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Error handling dropdown open change', error);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
return (
|
||||
<Dropdown
|
||||
popupRender={() => renderDropdownContent()}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
open={dropdownOpen}
|
||||
onOpenChange={handleDropdownOpenChange}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
} catch (error) {
|
||||
logError('Error rendering TimerButton', error);
|
||||
return (
|
||||
<Tooltip title="Timer Error">
|
||||
<Button
|
||||
style={{ height: '62px', width: '60px' }}
|
||||
type="text"
|
||||
icon={<ClockCircleOutlined style={{ fontSize: 20 }} />}
|
||||
disabled
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default TimerButton;
|
||||
@@ -23,7 +23,7 @@ const ProfileButton = ({ isOwnerOrAdmin }: ProfileButtonProps) => {
|
||||
const { t } = useTranslation('navbar');
|
||||
const authService = useAuthService();
|
||||
const currentSession = useAppSelector((state: RootState) => state.userReducer);
|
||||
|
||||
|
||||
const role = getRole();
|
||||
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
||||
|
||||
@@ -49,13 +49,24 @@ const ProfileButton = ({ isOwnerOrAdmin }: ProfileButtonProps) => {
|
||||
<Flex vertical style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text
|
||||
ellipsis={{ tooltip: currentSession?.name }} // Show tooltip on hover
|
||||
style={{ width: '100%', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
style={{
|
||||
width: '100%',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{currentSession?.name}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
ellipsis={{ tooltip: currentSession?.email }} // Show tooltip on hover
|
||||
style={{ fontSize: 12, width: '100%', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
width: '100%',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{currentSession?.email}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -16,15 +16,35 @@ const initialState: IProjectDrawerState = {
|
||||
project: null,
|
||||
};
|
||||
|
||||
|
||||
export const fetchProjectData = createAsyncThunk(
|
||||
'project/fetchProjectData',
|
||||
async (projectId: string, { rejectWithValue, dispatch }) => {
|
||||
try {
|
||||
if (!projectId) {
|
||||
throw new Error('Project ID is required');
|
||||
}
|
||||
|
||||
console.log(`Fetching project data for ID: ${projectId}`);
|
||||
const response = await projectsApiService.getProject(projectId);
|
||||
|
||||
if (!response) {
|
||||
throw new Error('No response received from API');
|
||||
}
|
||||
|
||||
if (!response.done) {
|
||||
throw new Error(response.message || 'API request failed');
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No project data in response body');
|
||||
}
|
||||
|
||||
console.log(`Successfully fetched project data:`, response.body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
return rejectWithValue(error instanceof Error ? error.message : 'Failed to fetch project');
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch project';
|
||||
console.error(`Error fetching project data for ID ${projectId}:`, error);
|
||||
return rejectWithValue(errorMessage);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -45,16 +65,21 @@ const projectDrawerSlice = createSlice({
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
|
||||
.addCase(fetchProjectData.pending, state => {
|
||||
console.log('Starting project data fetch...');
|
||||
state.projectLoading = true;
|
||||
state.project = null; // Clear existing data while loading
|
||||
})
|
||||
.addCase(fetchProjectData.fulfilled, (state, action) => {
|
||||
console.log('Project data fetch completed successfully:', action.payload);
|
||||
state.project = action.payload;
|
||||
state.projectLoading = false;
|
||||
})
|
||||
.addCase(fetchProjectData.rejected, state => {
|
||||
.addCase(fetchProjectData.rejected, (state, action) => {
|
||||
console.error('Project data fetch failed:', action.payload);
|
||||
state.projectLoading = false;
|
||||
state.project = null;
|
||||
// You could add an error field to the state if needed for UI feedback
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
47
worklenz-frontend/src/features/project/project-view-slice.ts
Normal file
47
worklenz-frontend/src/features/project/project-view-slice.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { ProjectGroupBy, ProjectViewType } from '@/types/project/project.types';
|
||||
|
||||
interface ProjectViewState {
|
||||
mode: ProjectViewType;
|
||||
groupBy: ProjectGroupBy;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'project_view_preferences';
|
||||
|
||||
const loadInitialState = (): ProjectViewState => {
|
||||
const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
return saved
|
||||
? JSON.parse(saved)
|
||||
: {
|
||||
mode: ProjectViewType.LIST,
|
||||
groupBy: ProjectGroupBy.CATEGORY,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
const initialState: ProjectViewState = loadInitialState();
|
||||
|
||||
export const projectViewSlice = createSlice({
|
||||
name: 'projectView',
|
||||
initialState,
|
||||
reducers: {
|
||||
setViewMode: (state, action: PayloadAction<ProjectViewType>) => {
|
||||
state.mode = action.payload;
|
||||
state.lastUpdated = new Date().toISOString();
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state));
|
||||
},
|
||||
setGroupBy: (state, action: PayloadAction<ProjectGroupBy>) => {
|
||||
state.groupBy = action.payload;
|
||||
state.lastUpdated = new Date().toISOString();
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state));
|
||||
},
|
||||
resetViewState: () => {
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEY);
|
||||
return loadInitialState();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setViewMode, setGroupBy, resetViewState } = projectViewSlice.actions;
|
||||
export default projectViewSlice.reducer;
|
||||
@@ -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');
|
||||
@@ -171,7 +170,7 @@ const projectSlice = createSlice({
|
||||
}
|
||||
},
|
||||
reset: () => initialState,
|
||||
setRefreshTimestamp: (state) => {
|
||||
setRefreshTimestamp: state => {
|
||||
state.refreshTimestamp = new Date().getTime().toString();
|
||||
},
|
||||
setProjectView: (state, action: PayloadAction<'list' | 'kanban'>) => {
|
||||
@@ -215,7 +214,7 @@ export const {
|
||||
setCreateTaskTemplateDrawerOpen,
|
||||
setProjectView,
|
||||
updatePhaseLabel,
|
||||
setRefreshTimestamp
|
||||
setRefreshTimestamp,
|
||||
} = projectSlice.actions;
|
||||
|
||||
export default projectSlice.reducer;
|
||||
|
||||
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;
|
||||
@@ -5,12 +5,17 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
import { IProjectCategory } from '@/types/project/projectCategory.types';
|
||||
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
|
||||
import { IProjectManager } from '@/types/project/projectManager.types';
|
||||
import { IGroupedProjectsViewModel } from '@/types/project/groupedProjectsViewModel.types';
|
||||
|
||||
interface ProjectState {
|
||||
projects: {
|
||||
data: IProjectViewModel[];
|
||||
total: number;
|
||||
};
|
||||
groupedProjects: {
|
||||
data: IGroupedProjectsViewModel | null;
|
||||
loading: boolean;
|
||||
};
|
||||
categories: IProjectCategory[];
|
||||
loading: boolean;
|
||||
creatingProject: boolean;
|
||||
@@ -29,6 +34,17 @@ interface ProjectState {
|
||||
statuses: string | null;
|
||||
categories: string | null;
|
||||
};
|
||||
groupedRequestParams: {
|
||||
index: number;
|
||||
size: number;
|
||||
field: string;
|
||||
order: string;
|
||||
search: string;
|
||||
groupBy: string;
|
||||
filter: number;
|
||||
statuses: string | null;
|
||||
categories: string | null;
|
||||
};
|
||||
projectManagers: IProjectManager[];
|
||||
projectManagersLoading: boolean;
|
||||
}
|
||||
@@ -38,6 +54,10 @@ const initialState: ProjectState = {
|
||||
data: [],
|
||||
total: 0,
|
||||
},
|
||||
groupedProjects: {
|
||||
data: null,
|
||||
loading: false,
|
||||
},
|
||||
categories: [],
|
||||
loading: false,
|
||||
creatingProject: false,
|
||||
@@ -56,6 +76,17 @@ const initialState: ProjectState = {
|
||||
statuses: null,
|
||||
categories: null,
|
||||
},
|
||||
groupedRequestParams: {
|
||||
index: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
field: 'name',
|
||||
order: 'ascend',
|
||||
search: '',
|
||||
groupBy: '',
|
||||
filter: 0,
|
||||
statuses: null,
|
||||
categories: null,
|
||||
},
|
||||
projectManagers: [],
|
||||
projectManagersLoading: false,
|
||||
};
|
||||
@@ -98,6 +129,46 @@ export const fetchProjects = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
// Create async thunk for fetching grouped projects
|
||||
export const fetchGroupedProjects = createAsyncThunk(
|
||||
'projects/fetchGroupedProjects',
|
||||
async (
|
||||
params: {
|
||||
index: number;
|
||||
size: number;
|
||||
field: string;
|
||||
order: string;
|
||||
search: string;
|
||||
groupBy: string;
|
||||
filter: number;
|
||||
statuses: string | null;
|
||||
categories: string | null;
|
||||
},
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const groupedProjectsResponse = await projectsApiService.getGroupedProjects(
|
||||
params.index,
|
||||
params.size,
|
||||
params.field,
|
||||
params.order,
|
||||
params.search,
|
||||
params.groupBy,
|
||||
params.filter,
|
||||
params.statuses,
|
||||
params.categories
|
||||
);
|
||||
return groupedProjectsResponse.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Grouped Projects', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to fetch grouped projects');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const toggleFavoriteProject = createAsyncThunk(
|
||||
'projects/toggleFavoriteProject',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
@@ -131,7 +202,7 @@ export const createProject = createAsyncThunk(
|
||||
export const updateProject = createAsyncThunk(
|
||||
'projects/updateProject',
|
||||
async ({ id, project }: { id: string; project: IProjectViewModel }, { rejectWithValue }) => {
|
||||
const response = await projectsApiService.updateProject(id, project);
|
||||
const response = await projectsApiService.updateProject({ id, ...project });
|
||||
return response.body;
|
||||
}
|
||||
);
|
||||
@@ -196,6 +267,15 @@ const projectSlice = createSlice({
|
||||
...action.payload,
|
||||
};
|
||||
},
|
||||
setGroupedRequestParams: (
|
||||
state,
|
||||
action: PayloadAction<Partial<ProjectState['groupedRequestParams']>>
|
||||
) => {
|
||||
state.groupedRequestParams = {
|
||||
...state.groupedRequestParams,
|
||||
...action.payload,
|
||||
};
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
@@ -213,6 +293,16 @@ const projectSlice = createSlice({
|
||||
.addCase(fetchProjects.rejected, state => {
|
||||
state.loading = false;
|
||||
})
|
||||
.addCase(fetchGroupedProjects.pending, state => {
|
||||
state.groupedProjects.loading = true;
|
||||
})
|
||||
.addCase(fetchGroupedProjects.fulfilled, (state, action) => {
|
||||
state.groupedProjects.loading = false;
|
||||
state.groupedProjects.data = action.payload;
|
||||
})
|
||||
.addCase(fetchGroupedProjects.rejected, state => {
|
||||
state.groupedProjects.loading = false;
|
||||
})
|
||||
.addCase(createProject.pending, state => {
|
||||
state.creatingProject = true;
|
||||
})
|
||||
@@ -248,5 +338,6 @@ export const {
|
||||
setFilteredCategories,
|
||||
setFilteredStatuses,
|
||||
setRequestParams,
|
||||
setGroupedRequestParams,
|
||||
} = projectSlice.actions;
|
||||
export default projectSlice.reducer;
|
||||
|
||||
@@ -24,7 +24,7 @@ const ConfigPhaseButton = () => {
|
||||
onClick={() => dispatch(toggleDrawer())}
|
||||
icon={
|
||||
<SettingOutlined
|
||||
style={{ color: themeMode === 'dark' ? colors.white : colors.skyBlue }}
|
||||
style={{ color: themeMode === 'dark' ? colors.white : 'black' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -37,6 +37,7 @@ import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
|
||||
import { updatePhaseLabel } from '@/features/project/project.slice';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import { fetchBoardTaskGroups } from '@/features/board/board-slice';
|
||||
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
|
||||
|
||||
interface UpdateSortOrderBody {
|
||||
from_index: number;
|
||||
@@ -67,6 +68,7 @@ const PhaseDrawer = () => {
|
||||
|
||||
const refreshTasks = async () => {
|
||||
if (tab === 'tasks-list') {
|
||||
await dispatch(fetchTasksV3(projectId || ''));
|
||||
await dispatch(fetchTaskGroups(projectId || ''));
|
||||
} else if (tab === 'board') {
|
||||
await dispatch(fetchBoardTaskGroups(projectId || ''));
|
||||
@@ -75,7 +77,7 @@ const PhaseDrawer = () => {
|
||||
|
||||
const handleAddOptions = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
|
||||
await dispatch(addPhaseOption({ projectId: projectId }));
|
||||
await dispatch(fetchPhasesByProjectId(projectId));
|
||||
await refreshTasks();
|
||||
@@ -131,6 +133,8 @@ const PhaseDrawer = () => {
|
||||
if (res.done) {
|
||||
dispatch(updatePhaseLabel(phaseName));
|
||||
setInitialPhaseName(phaseName);
|
||||
// Refresh tasks to update phase label in task list
|
||||
await refreshTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating phase name', error);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { useSelectedProject } from '../../../../hooks/useSelectedProject';
|
||||
import { useAppSelector } from '../../../../hooks/useAppSelector';
|
||||
import { Flex } from 'antd';
|
||||
import ConfigPhaseButton from './ConfigPhaseButton';
|
||||
@@ -10,19 +9,13 @@ const PhaseHeader = () => {
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
|
||||
// get selected project for useSelectedProject hook
|
||||
const selectedProject = useSelectedProject();
|
||||
|
||||
// get phase data from redux
|
||||
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
|
||||
|
||||
//get phases details from phases slice
|
||||
const phase = phaseList.find(el => el.projectId === selectedProject?.projectId);
|
||||
// get project data from redux
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="space-between">
|
||||
{phase?.phase || t('phasesText')}
|
||||
<ConfigPhaseButton color={colors.darkGray} />
|
||||
{project?.phase_label || t('phasesText')}
|
||||
<ConfigPhaseButton />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,12 @@ import { Button, ColorPicker, ConfigProvider, Flex, Input } from 'antd';
|
||||
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
||||
import { nanoid } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { deletePhaseOption, fetchPhasesByProjectId, updatePhaseColor, updatePhaseName } from './phases.slice';
|
||||
import {
|
||||
deletePhaseOption,
|
||||
fetchPhasesByProjectId,
|
||||
updatePhaseColor,
|
||||
updatePhaseName,
|
||||
} from './phases.slice';
|
||||
import { PhaseColorCodes } from '@/shared/constants';
|
||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||
import { TFunction } from 'i18next';
|
||||
@@ -27,7 +32,7 @@ const PhaseOptionItem = ({ option, projectId, t }: PhaseOptionItemProps) => {
|
||||
const { projectView } = useTabSearchParam();
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: option?.id || 'temp-id'
|
||||
id: option?.id || 'temp-id',
|
||||
});
|
||||
|
||||
const style = {
|
||||
@@ -50,14 +55,16 @@ const PhaseOptionItem = ({ option, projectId, t }: PhaseOptionItemProps) => {
|
||||
|
||||
const handlePhaseNameChange = async (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!projectId || !option || phaseName.trim() === option.name.trim()) return;
|
||||
|
||||
|
||||
try {
|
||||
const updatedPhase = { ...option, name: phaseName.trim() };
|
||||
const response = await dispatch(updatePhaseName({
|
||||
phaseId: option.id,
|
||||
phase: updatedPhase,
|
||||
projectId
|
||||
})).unwrap();
|
||||
const response = await dispatch(
|
||||
updatePhaseName({
|
||||
phaseId: option.id,
|
||||
phase: updatedPhase,
|
||||
projectId,
|
||||
})
|
||||
).unwrap();
|
||||
|
||||
if (response.done) {
|
||||
dispatch(fetchPhasesByProjectId(projectId));
|
||||
@@ -71,7 +78,7 @@ const PhaseOptionItem = ({ option, projectId, t }: PhaseOptionItemProps) => {
|
||||
|
||||
const handleDeletePhaseOption = async () => {
|
||||
if (!option?.id || !projectId) return;
|
||||
|
||||
|
||||
try {
|
||||
const response = await dispatch(
|
||||
deletePhaseOption({ phaseOptionId: option.id, projectId })
|
||||
@@ -88,7 +95,7 @@ const PhaseOptionItem = ({ option, projectId, t }: PhaseOptionItemProps) => {
|
||||
|
||||
const handleColorChange = async () => {
|
||||
if (!projectId || !option) return;
|
||||
|
||||
|
||||
try {
|
||||
const updatedPhase = { ...option, color_code: color };
|
||||
const response = await dispatch(updatePhaseColor({ projectId, body: updatedPhase })).unwrap();
|
||||
@@ -112,13 +119,13 @@ const PhaseOptionItem = ({ option, projectId, t }: PhaseOptionItemProps) => {
|
||||
<Input
|
||||
type="text"
|
||||
value={phaseName}
|
||||
onChange={(e) => setPhaseName(e.target.value)}
|
||||
onChange={e => setPhaseName(e.target.value)}
|
||||
onBlur={handlePhaseNameChange}
|
||||
onPressEnter={(e) => e.currentTarget.blur()}
|
||||
onPressEnter={e => e.currentTarget.blur()}
|
||||
placeholder={t('enterPhaseName')}
|
||||
/>
|
||||
<ColorPicker
|
||||
onChange={(value) => setColor(value.toHexString())}
|
||||
onChange={value => setColor(value.toHexString())}
|
||||
onChangeComplete={handleColorChange}
|
||||
value={color}
|
||||
/>
|
||||
|
||||
@@ -16,9 +16,9 @@ const initialState: PhaseState = {
|
||||
|
||||
export const addPhaseOption = createAsyncThunk(
|
||||
'phase/addPhaseOption',
|
||||
async ({ projectId }: { projectId: string }, { rejectWithValue }) => {
|
||||
async ({ projectId, name }: { projectId: string; name?: string }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await phasesApiService.addPhaseOption(projectId);
|
||||
const response = await phasesApiService.addPhaseOption(projectId, name);
|
||||
return response;
|
||||
} catch (error) {
|
||||
return rejectWithValue(error);
|
||||
@@ -40,7 +40,10 @@ export const fetchPhasesByProjectId = createAsyncThunk(
|
||||
|
||||
export const deletePhaseOption = createAsyncThunk(
|
||||
'phase/deletePhaseOption',
|
||||
async ({ phaseOptionId, projectId }: { phaseOptionId: string; projectId: string }, { rejectWithValue }) => {
|
||||
async (
|
||||
{ phaseOptionId, projectId }: { phaseOptionId: string; projectId: string },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await phasesApiService.deletePhaseOption(phaseOptionId, projectId);
|
||||
return response;
|
||||
@@ -64,14 +67,17 @@ export const updatePhaseColor = createAsyncThunk(
|
||||
|
||||
export const updatePhaseOrder = createAsyncThunk(
|
||||
'phases/updatePhaseOrder',
|
||||
async ({ projectId, body }: {
|
||||
projectId: string,
|
||||
body: {
|
||||
async ({
|
||||
projectId,
|
||||
body,
|
||||
}: {
|
||||
projectId: string;
|
||||
body: {
|
||||
from_index: number;
|
||||
to_index: number;
|
||||
phases: ITaskPhase[];
|
||||
project_id: string;
|
||||
}
|
||||
};
|
||||
}) => {
|
||||
try {
|
||||
const response = await phasesApiService.updatePhaseOrder(projectId, body);
|
||||
@@ -84,7 +90,10 @@ export const updatePhaseOrder = createAsyncThunk(
|
||||
|
||||
export const updateProjectPhaseLabel = createAsyncThunk(
|
||||
'phase/updateProjectPhaseLabel',
|
||||
async ({ projectId, phaseLabel }: { projectId: string; phaseLabel: string }, { rejectWithValue }) => {
|
||||
async (
|
||||
{ projectId, phaseLabel }: { projectId: string; phaseLabel: string },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await phasesApiService.updateProjectPhaseLabel(projectId, phaseLabel);
|
||||
return response;
|
||||
@@ -96,7 +105,10 @@ export const updateProjectPhaseLabel = createAsyncThunk(
|
||||
|
||||
export const updatePhaseName = createAsyncThunk(
|
||||
'phase/updatePhaseName',
|
||||
async ({ phaseId, phase, projectId }: { phaseId: string; phase: ITaskPhase; projectId: string }, { rejectWithValue }) => {
|
||||
async (
|
||||
{ phaseId, phase, projectId }: { phaseId: string; phase: ITaskPhase; projectId: string },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await phasesApiService.updateNameOfPhase(phaseId, phase, projectId);
|
||||
return response;
|
||||
@@ -127,13 +139,13 @@ const phaseSlice = createSlice({
|
||||
builder.addCase(fetchPhasesByProjectId.rejected, state => {
|
||||
state.loadingPhases = false;
|
||||
});
|
||||
builder.addCase(updatePhaseOrder.pending, (state) => {
|
||||
builder.addCase(updatePhaseOrder.pending, state => {
|
||||
state.loadingPhases = true;
|
||||
});
|
||||
builder.addCase(updatePhaseOrder.fulfilled, (state, action) => {
|
||||
state.loadingPhases = false;
|
||||
});
|
||||
builder.addCase(updatePhaseOrder.rejected, (state) => {
|
||||
builder.addCase(updatePhaseOrder.rejected, state => {
|
||||
state.loadingPhases = false;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { CustomTableColumnsType } from '../taskListColumns/taskColumnsSlice';
|
||||
import { LabelType } from '../../../../types/label.type';
|
||||
import { LabelType } from '../../../../pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/label-type-column/label-type-column';
|
||||
import { SelectionType } from '../../../../pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/selection-type-column/selection-type-column';
|
||||
|
||||
export type CustomFieldsTypes =
|
||||
@@ -21,7 +21,8 @@ type TaskListCustomColumnsState = {
|
||||
isCustomColumnModalOpen: boolean;
|
||||
customColumnModalType: 'create' | 'edit';
|
||||
customColumnId: string | null;
|
||||
|
||||
currentColumnData: any | null; // Store the current column data for editing
|
||||
|
||||
customFieldType: CustomFieldsTypes;
|
||||
customFieldNumberType: CustomFieldNumberTypes;
|
||||
decimals: number;
|
||||
@@ -39,6 +40,7 @@ const initialState: TaskListCustomColumnsState = {
|
||||
isCustomColumnModalOpen: false,
|
||||
customColumnModalType: 'create',
|
||||
customColumnId: null,
|
||||
currentColumnData: null,
|
||||
|
||||
customFieldType: 'people',
|
||||
customFieldNumberType: 'formatted',
|
||||
@@ -60,9 +62,13 @@ const taskListCustomColumnsSlice = createSlice({
|
||||
toggleCustomColumnModalOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.isCustomColumnModalOpen = action.payload;
|
||||
},
|
||||
setCustomColumnModalAttributes: (state, action: PayloadAction<{modalType: 'create' | 'edit', columnId: string | null}>) => {
|
||||
setCustomColumnModalAttributes: (
|
||||
state,
|
||||
action: PayloadAction<{ modalType: 'create' | 'edit'; columnId: string | null; columnData?: any }>
|
||||
) => {
|
||||
state.customColumnModalType = action.payload.modalType;
|
||||
state.customColumnId = action.payload.columnId;
|
||||
state.currentColumnData = action.payload.columnData || null;
|
||||
},
|
||||
setCustomFieldType: (state, action: PayloadAction<CustomFieldsTypes>) => {
|
||||
state.customFieldType = action.payload;
|
||||
@@ -95,7 +101,19 @@ const taskListCustomColumnsSlice = createSlice({
|
||||
state.selectionsList = action.payload;
|
||||
},
|
||||
resetCustomFieldValues: state => {
|
||||
state = initialState;
|
||||
// Reset all field values to initial state while keeping modal state
|
||||
state.customFieldType = initialState.customFieldType;
|
||||
state.customFieldNumberType = initialState.customFieldNumberType;
|
||||
state.decimals = initialState.decimals;
|
||||
state.label = initialState.label;
|
||||
state.labelPosition = initialState.labelPosition;
|
||||
state.previewValue = initialState.previewValue;
|
||||
state.expression = initialState.expression;
|
||||
state.firstNumericColumn = initialState.firstNumericColumn;
|
||||
state.secondNumericColumn = initialState.secondNumericColumn;
|
||||
state.labelsList = initialState.labelsList;
|
||||
state.selectionsList = initialState.selectionsList;
|
||||
state.currentColumnData = initialState.currentColumnData;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,6 +48,13 @@ const initialState: projectViewTaskListColumnsState = {
|
||||
width: 60,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
name: 'status',
|
||||
columnHeader: 'status',
|
||||
width: 120,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
name: 'members',
|
||||
@@ -69,13 +76,6 @@ const initialState: projectViewTaskListColumnsState = {
|
||||
width: 150,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
name: 'status',
|
||||
columnHeader: 'status',
|
||||
width: 120,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
name: 'priority',
|
||||
|
||||
@@ -24,7 +24,10 @@ const deleteStatusSlice = createSlice({
|
||||
deleteStatusToggleDrawer: state => {
|
||||
state.isDeleteStatusDrawerOpen = !state.isDeleteStatusDrawerOpen;
|
||||
},
|
||||
seletedStatusCategory: (state, action: PayloadAction<{ id: string; name: string; category_id: string; message: string}>) => {
|
||||
seletedStatusCategory: (
|
||||
state,
|
||||
action: PayloadAction<{ id: string; name: string; category_id: string; message: string }>
|
||||
) => {
|
||||
state.status = action.payload;
|
||||
},
|
||||
// deleteStatus: (state, action: PayloadAction<string>) => {
|
||||
@@ -33,9 +36,9 @@ const deleteStatusSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
deleteStatusToggleDrawer,
|
||||
seletedStatusCategory,
|
||||
// deleteStatus
|
||||
export const {
|
||||
deleteStatusToggleDrawer,
|
||||
seletedStatusCategory,
|
||||
// deleteStatus
|
||||
} = deleteStatusSlice.actions;
|
||||
export default deleteStatusSlice.reducer;
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { healthStatusData, projectColors, statusData } from '../../../lib/project/project-constants';
|
||||
import {
|
||||
healthStatusData,
|
||||
projectColors,
|
||||
statusData,
|
||||
} from '../../../lib/project/project-constants';
|
||||
import { PlusCircleOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { colors } from '../../../styles/colors';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
@@ -3,8 +3,15 @@ import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { colors } from '../../../../../styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { fetchTask, setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { ISingleMemberActivityLog, ISingleMemberActivityLogs } from '@/types/reporting/reporting.types';
|
||||
import {
|
||||
fetchTask,
|
||||
setSelectedTaskId,
|
||||
setShowTaskDrawer,
|
||||
} from '@/features/task-drawer/task-drawer.slice';
|
||||
import {
|
||||
ISingleMemberActivityLog,
|
||||
ISingleMemberActivityLogs,
|
||||
} from '@/types/reporting/reporting.types';
|
||||
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||
|
||||
type TaskStatus = {
|
||||
|
||||
@@ -28,7 +28,7 @@ const MembersReportsOverviewTab = ({ memberId }: MembersReportsOverviewTabProps)
|
||||
teamMemberId: memberId,
|
||||
duration: duration,
|
||||
date_range: dateRange,
|
||||
archived
|
||||
archived,
|
||||
};
|
||||
const response = await reportingApiService.getMemberInfo(body);
|
||||
if (response.done) {
|
||||
|
||||
@@ -44,7 +44,7 @@ const MembersOverviewProjectsStatsDrawer = ({
|
||||
archived: false,
|
||||
};
|
||||
const response = await reportingApiService.getSingleMemberProjects(body);
|
||||
if (response.done){
|
||||
if (response.done) {
|
||||
setProjectsData(response.body.projects || []);
|
||||
} else {
|
||||
setProjectsData([]);
|
||||
@@ -74,12 +74,9 @@ const MembersOverviewProjectsStatsDrawer = ({
|
||||
)
|
||||
}
|
||||
>
|
||||
<MembersOverviewProjectsStatsTable
|
||||
projectList={projectsData}
|
||||
loading={loading}
|
||||
/>
|
||||
<MembersOverviewProjectsStatsTable projectList={projectsData} loading={loading} />
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default MembersOverviewProjectsStatsDrawer;
|
||||
export default MembersOverviewProjectsStatsDrawer;
|
||||
|
||||
@@ -78,19 +78,19 @@ const MembersOverviewProjectsStatsTable = ({ projectList, loading }: ProjectRepo
|
||||
key: 'status',
|
||||
title: <CustomTableTitle title={t('statusColumn')} />,
|
||||
// render: record => {
|
||||
// const statusItem = statusData.find(item => item.label === record.status_name);
|
||||
// const statusItem = statusData.find(item => item.label === record.status_name);
|
||||
|
||||
// return statusItem ? (
|
||||
// <Typography.Text
|
||||
// style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
// className="group-hover:text-[#1890ff]"
|
||||
// >
|
||||
// {statusItem.icon}
|
||||
// {t(`${statusItem.value}Text`)}
|
||||
// </Typography.Text>
|
||||
// ) : (
|
||||
// <Typography.Text>-</Typography.Text>
|
||||
// );
|
||||
// return statusItem ? (
|
||||
// <Typography.Text
|
||||
// style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
// className="group-hover:text-[#1890ff]"
|
||||
// >
|
||||
// {statusItem.icon}
|
||||
// {t(`${statusItem.value}Text`)}
|
||||
// </Typography.Text>
|
||||
// ) : (
|
||||
// <Typography.Text>-</Typography.Text>
|
||||
// );
|
||||
// },
|
||||
width: 120,
|
||||
},
|
||||
@@ -156,29 +156,29 @@ const MembersOverviewProjectsStatsTable = ({ projectList, loading }: ProjectRepo
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Table: {
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 8,
|
||||
components: {
|
||||
Table: {
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Skeleton style={{ paddingTop: 16 }} />
|
||||
<Skeleton style={{ paddingTop: 16 }} />
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={projectList}
|
||||
pagination={{ showSizeChanger: true, defaultPageSize: 10 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
onRow={record => {
|
||||
return {
|
||||
style: { height: 38, cursor: 'pointer' },
|
||||
className: 'group even:bg-[#4e4e4e10]',
|
||||
};
|
||||
}}
|
||||
/>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={projectList}
|
||||
pagination={{ showSizeChanger: true, defaultPageSize: 10 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
onRow={record => {
|
||||
return {
|
||||
style: { height: 38, cursor: 'pointer' },
|
||||
className: 'group even:bg-[#4e4e4e10]',
|
||||
};
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -13,10 +13,7 @@ type MembersReportsTasksTableProps = {
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
const MembersReportsTasksTable = ({
|
||||
tasksData,
|
||||
loading,
|
||||
}: MembersReportsTasksTableProps) => {
|
||||
const MembersReportsTasksTable = ({ tasksData, loading }: MembersReportsTasksTableProps) => {
|
||||
// localization
|
||||
const { t } = useTranslation('reporting-members-drawer');
|
||||
|
||||
|
||||
@@ -42,13 +42,16 @@ const BillableFilter = ({ billable, onBillableChange }: BillableFilterProps) =>
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Checkbox
|
||||
id={item.key}
|
||||
checked={billable[item.key as keyof typeof billable]}
|
||||
onChange={() => onBillableChange({
|
||||
...billable,
|
||||
[item.key as keyof typeof billable]: !billable[item.key as keyof typeof billable]
|
||||
})}
|
||||
<Checkbox
|
||||
id={item.key}
|
||||
checked={billable[item.key as keyof typeof billable]}
|
||||
onChange={() =>
|
||||
onBillableChange({
|
||||
...billable,
|
||||
[item.key as keyof typeof billable]:
|
||||
!billable[item.key as keyof typeof billable],
|
||||
})
|
||||
}
|
||||
/>
|
||||
{t(`${item.key}Text`)}
|
||||
</Space>
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Card, ConfigProvider, Tag, Timeline, Typography } from 'antd';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { fetchTask, setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||
import {
|
||||
fetchTask,
|
||||
setSelectedTaskId,
|
||||
setShowTaskDrawer,
|
||||
} from '@/features/task-drawer/task-drawer.slice';
|
||||
import { ISingleMemberLogs } from '@/types/reporting/reporting.types';
|
||||
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,7 +4,12 @@ import { IProjectCategory } from '@/types/project/projectCategory.types';
|
||||
import { IProjectHealth } from '@/types/project/projectHealth.types';
|
||||
import { IProjectManager } from '@/types/project/projectManager.types';
|
||||
import { IProjectStatus } from '@/types/project/projectStatus.types';
|
||||
import { IGetProjectsRequestBody, IRPTOverviewProject, IRPTOverviewProjectMember, IRPTProject } from '@/types/reporting/reporting.types';
|
||||
import {
|
||||
IGetProjectsRequestBody,
|
||||
IRPTOverviewProject,
|
||||
IRPTOverviewProjectMember,
|
||||
IRPTProject,
|
||||
} from '@/types/reporting/reporting.types';
|
||||
import { getFromLocalStorage } from '@/utils/localStorageFunctions';
|
||||
import { createAsyncThunk, createSlice, createAction } from '@reduxjs/toolkit';
|
||||
|
||||
@@ -75,7 +80,7 @@ const initialState: ProjectReportsState = {
|
||||
|
||||
isProjectReportsMembersTaskDrawerOpen: false,
|
||||
selectedMember: null,
|
||||
selectedProject: null,
|
||||
selectedProject: null,
|
||||
|
||||
projectList: [],
|
||||
total: 0,
|
||||
@@ -226,7 +231,7 @@ const projectReportsSlice = createSlice({
|
||||
.addCase(updateProjectCategory, (state, action) => {
|
||||
const { projectId, category } = action.payload;
|
||||
const projectIndex = state.projectList.findIndex(project => project.id === projectId);
|
||||
|
||||
|
||||
if (projectIndex !== -1) {
|
||||
state.projectList[projectIndex].category_id = category.id || null;
|
||||
state.projectList[projectIndex].category_name = category.name ?? '';
|
||||
@@ -236,7 +241,7 @@ const projectReportsSlice = createSlice({
|
||||
.addCase(updateProjectStatus, (state, action) => {
|
||||
const { projectId, status } = action.payload;
|
||||
const projectIndex = state.projectList.findIndex(project => project.id === projectId);
|
||||
|
||||
|
||||
if (projectIndex !== -1) {
|
||||
state.projectList[projectIndex].status_id = status.id || '';
|
||||
state.projectList[projectIndex].status_name = status.name ?? '';
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { PROJECT_LIST_COLUMNS } from '@/shared/constants';
|
||||
import { getJSONFromLocalStorage, saveJSONToLocalStorage, saveToLocalStorage } from '@/utils/localStorageFunctions';
|
||||
import {
|
||||
getJSONFromLocalStorage,
|
||||
saveJSONToLocalStorage,
|
||||
saveToLocalStorage,
|
||||
} from '@/utils/localStorageFunctions';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
type ColumnsVisibilityState = {
|
||||
@@ -8,21 +12,23 @@ type ColumnsVisibilityState = {
|
||||
|
||||
const getInitialState = () => {
|
||||
const savedState = getJSONFromLocalStorage(PROJECT_LIST_COLUMNS);
|
||||
return savedState || {
|
||||
name: true,
|
||||
projectHealth: true,
|
||||
category: true,
|
||||
projectUpdate: true,
|
||||
client: true,
|
||||
team: true,
|
||||
projectManager: true,
|
||||
estimatedVsActual: true,
|
||||
tasksProgress: true,
|
||||
lastActivity: true,
|
||||
status: true,
|
||||
dates: true,
|
||||
daysLeft: true,
|
||||
};
|
||||
return (
|
||||
savedState || {
|
||||
name: true,
|
||||
projectHealth: true,
|
||||
category: true,
|
||||
projectUpdate: true,
|
||||
client: true,
|
||||
team: true,
|
||||
projectManager: true,
|
||||
estimatedVsActual: true,
|
||||
tasksProgress: true,
|
||||
lastActivity: true,
|
||||
status: true,
|
||||
dates: true,
|
||||
daysLeft: true,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const initialState: ColumnsVisibilityState = getInitialState();
|
||||
|
||||
@@ -23,7 +23,7 @@ const ProjectReportsMembersTab = ({ projectId = null }: ProjectReportsMembersTab
|
||||
|
||||
const fetchMembersData = async () => {
|
||||
if (!projectId || loading) return;
|
||||
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await reportingProjectsApiService.getProjectMembers(projectId);
|
||||
|
||||
@@ -2,7 +2,10 @@ import { Progress, Table, TableColumnsType } from 'antd';
|
||||
import React from 'react';
|
||||
import CustomTableTitle from '../../../../../components/CustomTableTitle';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setSelectedMember, toggleProjectReportsMembersTaskDrawer } from '../../project-reports-slice';
|
||||
import {
|
||||
setSelectedMember,
|
||||
toggleProjectReportsMembersTaskDrawer,
|
||||
} from '../../project-reports-slice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ProjectReportsMembersTaskDrawer from './projectReportsMembersTaskDrawer/ProjectReportsMembersTaskDrawer';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -107,7 +110,11 @@ const ProjectReportsMembersTable = ({ membersData, loading }: ProjectReportsMemb
|
||||
};
|
||||
}}
|
||||
/>
|
||||
{createPortal(<ProjectReportsMembersTaskDrawer />, document.body, 'project-reports-members-task-drawer')}
|
||||
{createPortal(
|
||||
<ProjectReportsMembersTaskDrawer />,
|
||||
document.body,
|
||||
'project-reports-members-task-drawer'
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,6 @@ const ProjectReportsMembersTaskDrawer = () => {
|
||||
|
||||
const handleAfterOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@@ -61,9 +60,7 @@ const ProjectReportsMembersTaskDrawer = () => {
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
|
||||
<ProjectReportsMembersTasksTable
|
||||
tasksData={filteredTaskData}
|
||||
/>
|
||||
<ProjectReportsMembersTasksTable tasksData={filteredTaskData} />
|
||||
</Flex>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,11 @@ import CustomTableTitle from '@/components/CustomTableTitle';
|
||||
import { colors } from '@/styles/colors';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setShowTaskDrawer, fetchTask, setSelectedTaskId } from '@/features/task-drawer/task-drawer.slice';
|
||||
import {
|
||||
setShowTaskDrawer,
|
||||
fetchTask,
|
||||
setSelectedTaskId,
|
||||
} from '@/features/task-drawer/task-drawer.slice';
|
||||
import { DoubleRightOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||
@@ -33,11 +37,13 @@ const ProjectReportsTasksTable = ({
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(()=>{
|
||||
useEffect(() => {
|
||||
dispatch(fetchPriorities());
|
||||
dispatch(fetchLabels());
|
||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
||||
},[dispatch])
|
||||
dispatch(
|
||||
getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true })
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
// function to handle task drawer open
|
||||
const handleUpdateTaskDrawer = (id: string) => {
|
||||
|
||||
@@ -31,9 +31,9 @@ const ProjectReportsTasksTab = ({ projectId = null }: ProjectReportsTasksTabProp
|
||||
.filter(item => item.tasks.length > 0)
|
||||
.map(item => ({
|
||||
...item,
|
||||
tasks: item.tasks.filter(task =>
|
||||
tasks: item.tasks.filter(task =>
|
||||
task.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
),
|
||||
}))
|
||||
.filter(item => item.tasks.length > 0);
|
||||
}, [groups, searchQuery]);
|
||||
|
||||
@@ -197,11 +197,18 @@ const roadmapSlice = createSlice({
|
||||
|
||||
state.tasksList = updateTask(state.tasksList);
|
||||
},
|
||||
updateTaskProgress: (state, action: PayloadAction<{ taskId: string; progress: number, totalTasksCount: number, completedCount: number }>) => {
|
||||
updateTaskProgress: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
taskId: string;
|
||||
progress: number;
|
||||
totalTasksCount: number;
|
||||
completedCount: number;
|
||||
}>
|
||||
) => {
|
||||
const { taskId, progress, totalTasksCount, completedCount } = action.payload;
|
||||
const updateTask = (tasks: NewTaskType[]) => {
|
||||
tasks.forEach(task => {
|
||||
|
||||
if (task.id === taskId) {
|
||||
task.progress = progress;
|
||||
} else if (task.subTasks) {
|
||||
|
||||
@@ -8,14 +8,26 @@ import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { getDayName } from '@/utils/schedule';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const ProjectTimelineModal = ({ setIsModalOpen, defaultData, projectId, memberId }: { setIsModalOpen: (x: boolean) => void, defaultData?: ScheduleData, projectId?:string, memberId?:string }) => {
|
||||
const ProjectTimelineModal = ({
|
||||
setIsModalOpen,
|
||||
defaultData,
|
||||
projectId,
|
||||
memberId,
|
||||
}: {
|
||||
setIsModalOpen: (x: boolean) => void;
|
||||
defaultData?: ScheduleData;
|
||||
projectId?: string;
|
||||
memberId?: string;
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const { t } = useTranslation('schedule');
|
||||
const { workingDays } = useAppSelector(state => state.scheduleReducer);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleFormSubmit = async (values: any) => {
|
||||
dispatch(createSchedule({schedule:{ ...values, project_id:projectId, team_member_id:memberId }}));
|
||||
dispatch(
|
||||
createSchedule({ schedule: { ...values, project_id: projectId, team_member_id: memberId } })
|
||||
);
|
||||
form.resetFields();
|
||||
setIsModalOpen(false);
|
||||
dispatch(fetchTeamData());
|
||||
@@ -25,13 +37,13 @@ const ProjectTimelineModal = ({ setIsModalOpen, defaultData, projectId, memberId
|
||||
const startDate = form.getFieldValue('allocated_from'); // Start date
|
||||
const endDate = form.getFieldValue('allocated_to'); // End date
|
||||
const secondsPerDay = form.getFieldValue('seconds_per_day'); // Seconds per day
|
||||
|
||||
|
||||
if (startDate && endDate && secondsPerDay && !isNaN(Number(secondsPerDay))) {
|
||||
const start: any = new Date(startDate);
|
||||
const end: any = new Date(endDate);
|
||||
|
||||
|
||||
if (start > end) {
|
||||
console.error("Start date cannot be after end date");
|
||||
console.error('Start date cannot be after end date');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,11 +53,11 @@ const ProjectTimelineModal = ({ setIsModalOpen, defaultData, projectId, memberId
|
||||
totalWorkingDays++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const hoursPerDay = secondsPerDay;
|
||||
|
||||
|
||||
const totalHours = totalWorkingDays * hoursPerDay;
|
||||
|
||||
|
||||
form.setFieldsValue({ total_seconds: totalHours.toFixed(2) });
|
||||
} else {
|
||||
form.setFieldsValue({ total_seconds: 0 });
|
||||
@@ -65,7 +77,6 @@ const ProjectTimelineModal = ({ setIsModalOpen, defaultData, projectId, memberId
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({ allocated_from: dayjs(defaultData?.allocated_from) });
|
||||
form.setFieldsValue({ allocated_to: dayjs(defaultData?.allocated_to) });
|
||||
|
||||
}, [defaultData]);
|
||||
|
||||
return (
|
||||
@@ -95,7 +106,7 @@ const ProjectTimelineModal = ({ setIsModalOpen, defaultData, projectId, memberId
|
||||
>
|
||||
<span>{t('endDate')}</span>
|
||||
<Form.Item name="allocated_to">
|
||||
<DatePicker disabledDate={disabledEndDate} onChange={e => calTotalHours()}/>
|
||||
<DatePicker disabledDate={disabledEndDate} onChange={e => calTotalHours()} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -103,14 +114,26 @@ const ProjectTimelineModal = ({ setIsModalOpen, defaultData, projectId, memberId
|
||||
<Col span={12} style={{ paddingRight: '20px' }}>
|
||||
<span>{t('hoursPerDay')}</span>
|
||||
<Form.Item name="seconds_per_day">
|
||||
<Input max={24} onChange={e => calTotalHours()} defaultValue={defaultData?.seconds_per_day} type="number" suffix="hours" />
|
||||
<Input
|
||||
max={24}
|
||||
onChange={e => calTotalHours()}
|
||||
defaultValue={defaultData?.seconds_per_day}
|
||||
type="number"
|
||||
suffix="hours"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col span={12} style={{ paddingLeft: '20px' }}>
|
||||
<span>{t('totalHours')}</span>
|
||||
<Form.Item name="total_seconds">
|
||||
<Input readOnly max={24} defaultValue={defaultData?.total_seconds} type="number" suffix="hours" />
|
||||
<Input
|
||||
readOnly
|
||||
max={24}
|
||||
defaultValue={defaultData?.total_seconds}
|
||||
type="number"
|
||||
suffix="hours"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -124,7 +147,7 @@ const ProjectTimelineModal = ({ setIsModalOpen, defaultData, projectId, memberId
|
||||
<Button type="link">{t('deleteButton')}</Button>
|
||||
<div style={{ display: 'flex', gap: '5px' }}>
|
||||
<Button onClick={() => setIsModalOpen(false)}>{t('cancelButton')}</Button>
|
||||
<Button htmlType='submit' type="primary">
|
||||
<Button htmlType="submit" type="primary">
|
||||
{t('saveButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Button, Checkbox, Col, Drawer, Form, Input, Row } from 'antd';
|
||||
import React, { ReactHTMLElement, useEffect, useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { fetchDateList, fetchTeamData, getWorking, toggleSettingsDrawer, updateSettings, updateWorking } from './scheduleSlice';
|
||||
import {
|
||||
fetchDateList,
|
||||
fetchTeamData,
|
||||
getWorking,
|
||||
toggleSettingsDrawer,
|
||||
updateSettings,
|
||||
updateWorking,
|
||||
} from './scheduleSlice';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { scheduleAPIService } from '@/api/schedule/schedule.api.service';
|
||||
@@ -17,7 +24,6 @@ const ScheduleSettingsDrawer: React.FC = () => {
|
||||
const { workingDays, workingHours, loading } = useAppSelector(state => state.scheduleReducer);
|
||||
const { date, type } = useAppSelector(state => state.scheduleReducer);
|
||||
|
||||
|
||||
const handleFormSubmit = async (values: any) => {
|
||||
await dispatch(updateWorking(values));
|
||||
dispatch(toggleSettingsDrawer());
|
||||
|
||||
@@ -43,7 +43,10 @@ export const fetchTeamData = createAsyncThunk('schedule/fetchTeamData', async ()
|
||||
export const fetchDateList = createAsyncThunk(
|
||||
'schedule/fetchDateList',
|
||||
async ({ date, type }: { date: Date; type: string }) => {
|
||||
const response = await scheduleAPIService.fetchScheduleDates({ date: date.toISOString(), type });
|
||||
const response = await scheduleAPIService.fetchScheduleDates({
|
||||
date: date.toISOString(),
|
||||
type,
|
||||
});
|
||||
if (!response.done) {
|
||||
throw new Error('Failed to fetch date list');
|
||||
}
|
||||
@@ -97,7 +100,7 @@ export const fetchMemberProjects = createAsyncThunk(
|
||||
|
||||
export const createSchedule = createAsyncThunk(
|
||||
'schedule/createSchedule',
|
||||
async ({ schedule }: { schedule: ScheduleData}) => {
|
||||
async ({ schedule }: { schedule: ScheduleData }) => {
|
||||
const response = await scheduleAPIService.submitScheduleData({ schedule });
|
||||
if (!response.done) {
|
||||
throw new Error('Failed to fetch date list');
|
||||
@@ -187,7 +190,8 @@ const scheduleSlice = createSlice({
|
||||
.addCase(getWorking.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.error.message || 'Failed to fetch list';
|
||||
}).addCase(fetchMemberProjects.pending, state => {
|
||||
})
|
||||
.addCase(fetchMemberProjects.pending, state => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
@@ -196,15 +200,16 @@ const scheduleSlice = createSlice({
|
||||
|
||||
state.teamData.find((team: any) => {
|
||||
if (team.id === data.id) {
|
||||
team.projects = data.projects||[];
|
||||
team.projects = data.projects || [];
|
||||
}
|
||||
})
|
||||
});
|
||||
state.loading = false;
|
||||
})
|
||||
.addCase(fetchMemberProjects.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.error.message || 'Failed to fetch date list';
|
||||
}).addCase(createSchedule.pending, state => {
|
||||
})
|
||||
.addCase(createSchedule.pending, state => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
@@ -218,6 +223,13 @@ const scheduleSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
export const { toggleSettingsDrawer, updateSettings, toggleScheduleDrawer, getWorkingSettings, setDate, setType, setDayCount } =
|
||||
scheduleSlice.actions;
|
||||
export const {
|
||||
toggleSettingsDrawer,
|
||||
updateSettings,
|
||||
toggleScheduleDrawer,
|
||||
getWorkingSettings,
|
||||
setDate,
|
||||
setType,
|
||||
setDayCount,
|
||||
} = scheduleSlice.actions;
|
||||
export default scheduleSlice.reducer;
|
||||
|
||||
@@ -45,9 +45,6 @@ const memberSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
toggleInviteMemberDrawer,
|
||||
toggleUpdateMemberDrawer,
|
||||
triggerTeamMembersRefresh,
|
||||
} = memberSlice.actions;
|
||||
export const { toggleInviteMemberDrawer, toggleUpdateMemberDrawer, triggerTeamMembersRefresh } =
|
||||
memberSlice.actions;
|
||||
export default memberSlice.reducer;
|
||||
|
||||
@@ -62,7 +62,7 @@ const taskDrawerSlice = createSlice({
|
||||
if (state.taskFormViewModel?.task && state.taskFormViewModel.task.id === taskId) {
|
||||
state.taskFormViewModel.task.status_id = status_id;
|
||||
state.taskFormViewModel.task.status_color = color_code;
|
||||
state.taskFormViewModel.task.status_color_dark = color_code_dark
|
||||
state.taskFormViewModel.task.status_color_dark = color_code_dark;
|
||||
}
|
||||
},
|
||||
setStartDate: (state, action: PayloadAction<IProjectTask>) => {
|
||||
@@ -99,12 +99,30 @@ const taskDrawerSlice = createSlice({
|
||||
setTaskSubscribers: (state, action: PayloadAction<InlineMember[]>) => {
|
||||
state.subscribers = action.payload;
|
||||
},
|
||||
setTimeLogEditing: (state, action: PayloadAction<{
|
||||
isEditing: boolean;
|
||||
logBeingEdited: ITaskLogViewModel | null;
|
||||
}>) => {
|
||||
setTimeLogEditing: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
isEditing: boolean;
|
||||
logBeingEdited: ITaskLogViewModel | null;
|
||||
}>
|
||||
) => {
|
||||
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;
|
||||
}
|
||||
},
|
||||
resetTaskDrawer: state => {
|
||||
return initialState;
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(fetchTask.pending, state => {
|
||||
@@ -133,5 +151,8 @@ export const {
|
||||
setTaskLabels,
|
||||
setTaskSubscribers,
|
||||
setTimeLogEditing,
|
||||
setTaskRecurringSchedule,
|
||||
resetTaskDrawer,
|
||||
setConvertToSubtaskDrawerOpen,
|
||||
} = taskDrawerSlice.actions;
|
||||
export default taskDrawerSlice.reducer;
|
||||
|
||||
287
worklenz-frontend/src/features/task-management/grouping.slice.ts
Normal file
287
worklenz-frontend/src/features/task-management/grouping.slice.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { createSlice, PayloadAction, createSelector } from '@reduxjs/toolkit';
|
||||
import { TaskGroup } from '@/types/task-management.types';
|
||||
import { RootState } from '@/app/store';
|
||||
import { selectAllTasksArray } from './task-management.slice';
|
||||
|
||||
type GroupingType = 'status' | 'priority' | 'phase';
|
||||
|
||||
interface LocalGroupingState {
|
||||
currentGrouping: GroupingType | null;
|
||||
customPhases: string[];
|
||||
groupOrder: {
|
||||
status: string[];
|
||||
priority: string[];
|
||||
phase: string[];
|
||||
};
|
||||
groupStates: Record<string, { collapsed: boolean }>;
|
||||
collapsedGroups: string[];
|
||||
}
|
||||
|
||||
// Local storage constants
|
||||
const LOCALSTORAGE_GROUP_KEY = 'worklenz.tasklist.group_by';
|
||||
|
||||
// Utility functions for local storage
|
||||
const loadGroupingFromLocalStorage = (): GroupingType | null => {
|
||||
try {
|
||||
const stored = localStorage.getItem(LOCALSTORAGE_GROUP_KEY);
|
||||
if (stored && ['status', 'priority', 'phase'].includes(stored)) {
|
||||
return stored as GroupingType;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load grouping from localStorage:', error);
|
||||
}
|
||||
return 'status'; // Default to 'status' instead of null
|
||||
};
|
||||
|
||||
const saveGroupingToLocalStorage = (grouping: GroupingType | null): void => {
|
||||
try {
|
||||
if (grouping) {
|
||||
localStorage.setItem(LOCALSTORAGE_GROUP_KEY, grouping);
|
||||
} else {
|
||||
localStorage.removeItem(LOCALSTORAGE_GROUP_KEY);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to save grouping to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const initialState: LocalGroupingState = {
|
||||
currentGrouping: loadGroupingFromLocalStorage(),
|
||||
customPhases: ['Planning', 'Development', 'Testing', 'Deployment'],
|
||||
groupOrder: {
|
||||
status: ['todo', 'doing', 'done'],
|
||||
priority: ['critical', 'high', 'medium', 'low'],
|
||||
phase: ['Planning', 'Development', 'Testing', 'Deployment'],
|
||||
},
|
||||
groupStates: {},
|
||||
collapsedGroups: [],
|
||||
};
|
||||
|
||||
const groupingSlice = createSlice({
|
||||
name: 'grouping',
|
||||
initialState,
|
||||
reducers: {
|
||||
setCurrentGrouping: (state, action: PayloadAction<GroupingType | null>) => {
|
||||
state.currentGrouping = action.payload;
|
||||
saveGroupingToLocalStorage(action.payload);
|
||||
},
|
||||
|
||||
addCustomPhase: (state, action: PayloadAction<string>) => {
|
||||
const phase = action.payload.trim();
|
||||
if (phase && !state.customPhases.includes(phase)) {
|
||||
state.customPhases.push(phase);
|
||||
state.groupOrder.phase.push(phase);
|
||||
}
|
||||
},
|
||||
|
||||
removeCustomPhase: (state, action: PayloadAction<string>) => {
|
||||
const phase = action.payload;
|
||||
state.customPhases = state.customPhases.filter(p => p !== phase);
|
||||
state.groupOrder.phase = state.groupOrder.phase.filter(p => p !== phase);
|
||||
},
|
||||
|
||||
updateCustomPhases: (state, action: PayloadAction<string[]>) => {
|
||||
state.customPhases = action.payload;
|
||||
state.groupOrder.phase = action.payload;
|
||||
},
|
||||
|
||||
updateGroupOrder: (state, action: PayloadAction<{ groupType: keyof LocalGroupingState['groupOrder']; order: string[] }>) => {
|
||||
const { groupType, order } = action.payload;
|
||||
state.groupOrder[groupType] = order;
|
||||
},
|
||||
|
||||
toggleGroupCollapsed: (state, action: PayloadAction<string>) => {
|
||||
const groupId = action.payload;
|
||||
const isCollapsed = state.collapsedGroups.includes(groupId);
|
||||
if (isCollapsed) {
|
||||
state.collapsedGroups = state.collapsedGroups.filter(id => id !== groupId);
|
||||
} else {
|
||||
state.collapsedGroups.push(groupId);
|
||||
}
|
||||
},
|
||||
|
||||
setGroupCollapsed: (state, action: PayloadAction<{ groupId: string; collapsed: boolean }>) => {
|
||||
const { groupId, collapsed } = action.payload;
|
||||
if (!state.groupStates[groupId]) {
|
||||
state.groupStates[groupId] = { collapsed: false };
|
||||
}
|
||||
state.groupStates[groupId].collapsed = collapsed;
|
||||
},
|
||||
|
||||
collapseAllGroups: (state, action: PayloadAction<string[]>) => {
|
||||
state.collapsedGroups = action.payload;
|
||||
},
|
||||
|
||||
expandAllGroups: state => {
|
||||
state.collapsedGroups = [];
|
||||
},
|
||||
|
||||
resetGrouping: () => initialState,
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setCurrentGrouping,
|
||||
addCustomPhase,
|
||||
removeCustomPhase,
|
||||
updateCustomPhases,
|
||||
updateGroupOrder,
|
||||
toggleGroupCollapsed,
|
||||
setGroupCollapsed,
|
||||
collapseAllGroups,
|
||||
expandAllGroups,
|
||||
resetGrouping,
|
||||
} = groupingSlice.actions;
|
||||
|
||||
// Selectors
|
||||
export const selectCurrentGrouping = (state: RootState) => state.grouping.currentGrouping;
|
||||
export const selectCustomPhases = (state: RootState) => state.grouping.customPhases;
|
||||
export const selectGroupOrder = (state: RootState) => state.grouping.groupOrder;
|
||||
export const selectGroupStates = (state: RootState) => state.grouping.groupStates;
|
||||
export const selectCollapsedGroupsArray = (state: RootState) => state.grouping.collapsedGroups;
|
||||
|
||||
// Memoized selector to prevent unnecessary re-renders
|
||||
export const selectCollapsedGroups = createSelector(
|
||||
[selectCollapsedGroupsArray],
|
||||
(collapsedGroupsArray) => new Set(collapsedGroupsArray)
|
||||
);
|
||||
|
||||
export const selectIsGroupCollapsed = (state: RootState, groupId: string) =>
|
||||
state.grouping.collapsedGroups.includes(groupId);
|
||||
|
||||
// Complex selectors using createSelector for memoization
|
||||
export const selectCurrentGroupOrder = createSelector(
|
||||
[selectCurrentGrouping, selectGroupOrder],
|
||||
(currentGrouping, groupOrder) => {
|
||||
if (!currentGrouping) return [];
|
||||
return groupOrder[currentGrouping] || [];
|
||||
}
|
||||
);
|
||||
|
||||
export const selectTaskGroups = createSelector(
|
||||
[selectAllTasksArray, selectCurrentGrouping, selectCurrentGroupOrder, selectGroupStates],
|
||||
(tasks, currentGrouping, groupOrder, groupStates) => {
|
||||
const groups: TaskGroup[] = [];
|
||||
|
||||
if (!currentGrouping) return groups;
|
||||
|
||||
// Get unique values for the current grouping
|
||||
const groupValues =
|
||||
groupOrder.length > 0
|
||||
? groupOrder
|
||||
: Array.from(new Set(
|
||||
tasks.map(task => {
|
||||
if (currentGrouping === 'status') return task.status;
|
||||
if (currentGrouping === 'priority') return task.priority;
|
||||
if (currentGrouping === 'phase') {
|
||||
// For phase grouping, use 'Unmapped' for tasks without a phase
|
||||
if (!task.phase || task.phase.trim() === '') {
|
||||
return 'Unmapped';
|
||||
} else {
|
||||
return task.phase;
|
||||
}
|
||||
}
|
||||
return task.phase;
|
||||
})
|
||||
));
|
||||
|
||||
groupValues.forEach(value => {
|
||||
if (!value) return; // Skip undefined values
|
||||
|
||||
const tasksInGroup = tasks
|
||||
.filter(task => {
|
||||
if (currentGrouping === 'status') return task.status === value;
|
||||
if (currentGrouping === 'priority') return task.priority === value;
|
||||
if (currentGrouping === 'phase') {
|
||||
if (value === 'Unmapped') {
|
||||
return !task.phase || task.phase.trim() === '';
|
||||
} else {
|
||||
return task.phase === value;
|
||||
}
|
||||
}
|
||||
return task.phase === value;
|
||||
})
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
|
||||
const groupId = `${currentGrouping}-${value}`;
|
||||
|
||||
groups.push({
|
||||
id: groupId,
|
||||
title: value.charAt(0).toUpperCase() + value.slice(1),
|
||||
taskIds: tasksInGroup.map(task => task.id),
|
||||
type: currentGrouping,
|
||||
color: getGroupColor(currentGrouping, value),
|
||||
collapsed: groupStates[groupId]?.collapsed || false,
|
||||
groupValue: value,
|
||||
});
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
);
|
||||
|
||||
export const selectTasksByCurrentGrouping = createSelector(
|
||||
[selectAllTasksArray, selectCurrentGrouping],
|
||||
(tasks, currentGrouping) => {
|
||||
const grouped: Record<string, typeof tasks> = {};
|
||||
|
||||
if (!currentGrouping) return grouped;
|
||||
|
||||
tasks.forEach(task => {
|
||||
let key: string;
|
||||
if (currentGrouping === 'status') {
|
||||
key = task.status;
|
||||
} else if (currentGrouping === 'priority') {
|
||||
key = task.priority;
|
||||
} else if (currentGrouping === 'phase') {
|
||||
// For phase grouping, use 'Unmapped' for tasks without a phase
|
||||
if (!task.phase || task.phase.trim() === '') {
|
||||
key = 'Unmapped';
|
||||
} else {
|
||||
key = task.phase;
|
||||
}
|
||||
} else {
|
||||
key = task.phase || 'Development';
|
||||
}
|
||||
|
||||
if (!grouped[key]) grouped[key] = [];
|
||||
grouped[key].push(task);
|
||||
});
|
||||
|
||||
// Sort tasks within each group by order
|
||||
Object.keys(grouped).forEach(key => {
|
||||
grouped[key].sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
);
|
||||
|
||||
// Helper function to get group colors
|
||||
const getGroupColor = (groupType: GroupingType, value: string): string => {
|
||||
const colorMaps = {
|
||||
status: {
|
||||
todo: '#f0f0f0',
|
||||
doing: '#1890ff',
|
||||
done: '#52c41a',
|
||||
},
|
||||
priority: {
|
||||
critical: '#ff4d4f',
|
||||
high: '#ff7a45',
|
||||
medium: '#faad14',
|
||||
low: '#52c41a',
|
||||
},
|
||||
phase: {
|
||||
Planning: '#722ed1',
|
||||
Development: '#1890ff',
|
||||
Testing: '#faad14',
|
||||
Deployment: '#52c41a',
|
||||
Unmapped: '#fbc84c69',
|
||||
},
|
||||
};
|
||||
|
||||
const colorMap = colorMaps[groupType];
|
||||
return (colorMap as any)?.[value] || '#d9d9d9';
|
||||
};
|
||||
|
||||
export default groupingSlice.reducer;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { TaskSelection } from '@/types/task-management.types';
|
||||
import { RootState } from '@/app/store';
|
||||
|
||||
const initialState: TaskSelection = {
|
||||
selectedTaskIds: [],
|
||||
lastSelectedTaskId: null,
|
||||
};
|
||||
|
||||
const selectionSlice = createSlice({
|
||||
name: 'taskManagementSelection',
|
||||
initialState,
|
||||
reducers: {
|
||||
selectTask: (state, action: PayloadAction<string>) => {
|
||||
const taskId = action.payload;
|
||||
if (!state.selectedTaskIds.includes(taskId)) {
|
||||
state.selectedTaskIds.push(taskId);
|
||||
}
|
||||
state.lastSelectedTaskId = taskId;
|
||||
},
|
||||
deselectTask: (state, action: PayloadAction<string>) => {
|
||||
const taskId = action.payload;
|
||||
state.selectedTaskIds = state.selectedTaskIds.filter(id => id !== taskId);
|
||||
if (state.lastSelectedTaskId === taskId) {
|
||||
state.lastSelectedTaskId = state.selectedTaskIds[state.selectedTaskIds.length - 1] || null;
|
||||
}
|
||||
},
|
||||
toggleTaskSelection: (state, action: PayloadAction<string>) => {
|
||||
const taskId = action.payload;
|
||||
const index = state.selectedTaskIds.indexOf(taskId);
|
||||
if (index === -1) {
|
||||
state.selectedTaskIds.push(taskId);
|
||||
state.lastSelectedTaskId = taskId;
|
||||
} else {
|
||||
state.selectedTaskIds.splice(index, 1);
|
||||
state.lastSelectedTaskId = state.selectedTaskIds[state.selectedTaskIds.length - 1] || null;
|
||||
}
|
||||
},
|
||||
selectRange: (state, action: PayloadAction<string[]>) => {
|
||||
const taskIds = action.payload;
|
||||
const uniqueIds = Array.from(new Set([...state.selectedTaskIds, ...taskIds]));
|
||||
state.selectedTaskIds = uniqueIds;
|
||||
state.lastSelectedTaskId = taskIds[taskIds.length - 1];
|
||||
},
|
||||
clearSelection: state => {
|
||||
state.selectedTaskIds = [];
|
||||
state.lastSelectedTaskId = null;
|
||||
},
|
||||
resetSelection: state => {
|
||||
state.selectedTaskIds = [];
|
||||
state.lastSelectedTaskId = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
selectTask,
|
||||
deselectTask,
|
||||
toggleTaskSelection,
|
||||
selectRange,
|
||||
clearSelection,
|
||||
resetSelection,
|
||||
} = selectionSlice.actions;
|
||||
|
||||
// Selectors
|
||||
export const selectSelectedTaskIds = (state: RootState) => state.taskManagementSelection.selectedTaskIds;
|
||||
export const selectLastSelectedTaskId = (state: RootState) => state.taskManagementSelection.lastSelectedTaskId;
|
||||
export const selectIsTaskSelected = (state: RootState, taskId: string) =>
|
||||
state.taskManagementSelection.selectedTaskIds.includes(taskId);
|
||||
|
||||
export default selectionSlice.reducer;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { updateColumnVisibility } from './task-management.slice';
|
||||
import { ITaskListColumn } from '@/types/tasks/taskList.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
export interface TaskListField {
|
||||
key: string;
|
||||
label: string;
|
||||
visible: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
const DEFAULT_FIELDS: TaskListField[] = [
|
||||
{ key: 'KEY', label: 'Key', visible: false, order: 1 },
|
||||
{ key: 'DESCRIPTION', label: 'Description', visible: false, order: 2 },
|
||||
{ key: 'PROGRESS', label: 'Progress', visible: true, order: 3 },
|
||||
{ key: 'ASSIGNEES', label: 'Assignees', visible: true, order: 4 },
|
||||
{ key: 'LABELS', label: 'Labels', visible: true, order: 5 },
|
||||
{ key: 'PHASE', label: 'Phase', visible: true, order: 6 },
|
||||
{ key: 'STATUS', label: 'Status', visible: true, order: 7 },
|
||||
{ key: 'PRIORITY', label: 'Priority', visible: true, order: 8 },
|
||||
{ key: 'TIME_TRACKING', label: 'Time Tracking', visible: true, order: 9 },
|
||||
{ key: 'ESTIMATION', label: 'Estimation', visible: false, order: 10 },
|
||||
{ key: 'START_DATE', label: 'Start Date', visible: false, order: 11 },
|
||||
{ key: 'DUE_DATE', label: 'Due Date', visible: true, order: 12 },
|
||||
{ key: 'DUE_TIME', label: 'Due Time', visible: false, order: 13 },
|
||||
{ key: 'COMPLETED_DATE', label: 'Completed Date', visible: false, order: 14 },
|
||||
{ key: 'CREATED_DATE', label: 'Created Date', visible: false, order: 15 },
|
||||
{ key: 'LAST_UPDATED', label: 'Last Updated', visible: false, order: 16 },
|
||||
{ key: 'REPORTER', label: 'Reporter', visible: false, order: 17 },
|
||||
];
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'worklenz.taskManagement.fields';
|
||||
|
||||
function loadFields(): TaskListField[] {
|
||||
const stored = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse stored fields, using defaults:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_FIELDS;
|
||||
}
|
||||
|
||||
function saveFields(fields: TaskListField[]) {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(fields));
|
||||
}
|
||||
|
||||
// Async thunk to sync field visibility with database
|
||||
export const syncFieldWithDatabase = createAsyncThunk(
|
||||
'taskManagementFields/syncFieldWithDatabase',
|
||||
async (
|
||||
{ projectId, fieldKey, visible, columns }: {
|
||||
projectId: string;
|
||||
fieldKey: string;
|
||||
visible: boolean;
|
||||
columns: ITaskListColumn[]
|
||||
},
|
||||
{ dispatch }
|
||||
) => {
|
||||
// Find the corresponding backend column
|
||||
const backendColumn = columns.find(c => c.key === fieldKey);
|
||||
if (backendColumn) {
|
||||
// Update the column visibility in the database
|
||||
await dispatch(updateColumnVisibility({
|
||||
projectId,
|
||||
item: {
|
||||
...backendColumn,
|
||||
pinned: visible
|
||||
}
|
||||
}));
|
||||
}
|
||||
return { fieldKey, visible };
|
||||
}
|
||||
);
|
||||
|
||||
// Async thunk to sync all fields with database
|
||||
export const syncAllFieldsWithDatabase = createAsyncThunk(
|
||||
'taskManagementFields/syncAllFieldsWithDatabase',
|
||||
async (
|
||||
{ projectId, fields, columns }: {
|
||||
projectId: string;
|
||||
fields: TaskListField[];
|
||||
columns: ITaskListColumn[]
|
||||
},
|
||||
{ dispatch }
|
||||
) => {
|
||||
// Find fields that need to be synced
|
||||
const fieldsToSync = fields.filter(field => {
|
||||
const backendColumn = columns.find(c => c.key === field.key);
|
||||
return backendColumn && (backendColumn.pinned ?? false) !== field.visible;
|
||||
});
|
||||
|
||||
// Sync each field
|
||||
const syncPromises = fieldsToSync.map(field => {
|
||||
const backendColumn = columns.find(c => c.key === field.key);
|
||||
if (backendColumn) {
|
||||
return dispatch(updateColumnVisibility({
|
||||
projectId,
|
||||
item: {
|
||||
...backendColumn,
|
||||
pinned: field.visible
|
||||
}
|
||||
}));
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await Promise.all(syncPromises);
|
||||
return fieldsToSync.map(f => ({ fieldKey: f.key, visible: f.visible }));
|
||||
}
|
||||
);
|
||||
|
||||
const initialState: TaskListField[] = loadFields();
|
||||
|
||||
const taskListFieldsSlice = createSlice({
|
||||
name: 'taskManagementFields',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleField(state, action: PayloadAction<string>) {
|
||||
const field = state.find(f => f.key === action.payload);
|
||||
if (field) {
|
||||
field.visible = !field.visible;
|
||||
// Save to localStorage immediately after toggle
|
||||
saveFields(state);
|
||||
}
|
||||
},
|
||||
setFields(state, action: PayloadAction<TaskListField[]>) {
|
||||
const newState = action.payload;
|
||||
// Save to localStorage when fields are set
|
||||
saveFields(newState);
|
||||
return newState;
|
||||
},
|
||||
resetFields() {
|
||||
const defaultFields = DEFAULT_FIELDS;
|
||||
// Save to localStorage when fields are reset
|
||||
saveFields(defaultFields);
|
||||
return defaultFields;
|
||||
},
|
||||
// New action to update field visibility from database
|
||||
updateFieldVisibilityFromDatabase(state, action: PayloadAction<{ fieldKey: string; visible: boolean }>) {
|
||||
const { fieldKey, visible } = action.payload;
|
||||
const field = state.find(f => f.key === fieldKey);
|
||||
if (field) {
|
||||
field.visible = visible;
|
||||
// Save to localStorage
|
||||
saveFields(state);
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(syncFieldWithDatabase.fulfilled, (state, action) => {
|
||||
// Field visibility has been synced with database
|
||||
const { fieldKey, visible } = action.payload;
|
||||
const field = state.find(f => f.key === fieldKey);
|
||||
if (field) {
|
||||
field.visible = visible;
|
||||
saveFields(state);
|
||||
}
|
||||
})
|
||||
.addCase(syncAllFieldsWithDatabase.fulfilled, (state, action) => {
|
||||
// All fields have been synced with database
|
||||
action.payload.forEach(({ fieldKey, visible }) => {
|
||||
const field = state.find(f => f.key === fieldKey);
|
||||
if (field) {
|
||||
field.visible = visible;
|
||||
}
|
||||
});
|
||||
saveFields(state);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { toggleField, setFields, resetFields, updateFieldVisibilityFromDatabase } = taskListFieldsSlice.actions;
|
||||
|
||||
// Utility function to force reset fields (can be called from browser console)
|
||||
export const forceResetFields = () => {
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEY);
|
||||
console.log('Cleared localStorage and reset fields to defaults');
|
||||
return DEFAULT_FIELDS;
|
||||
};
|
||||
|
||||
// Make it available globally for debugging
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).forceResetTaskFields = forceResetFields;
|
||||
}
|
||||
|
||||
export default taskListFieldsSlice.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',
|
||||
@@ -78,6 +80,9 @@ interface ITaskState {
|
||||
convertToSubtaskDrawerOpen: boolean;
|
||||
customColumns: ITaskListColumn[];
|
||||
customColumnValues: Record<string, Record<string, any>>;
|
||||
allTasks: IProjectTask[];
|
||||
grouping: string;
|
||||
totalTasks: number;
|
||||
}
|
||||
|
||||
const initialState: ITaskState = {
|
||||
@@ -103,6 +108,9 @@ const initialState: ITaskState = {
|
||||
convertToSubtaskDrawerOpen: false,
|
||||
customColumns: [],
|
||||
customColumnValues: {},
|
||||
allTasks: [],
|
||||
grouping: '',
|
||||
totalTasks: 0,
|
||||
};
|
||||
|
||||
export const COLUMN_KEYS = {
|
||||
@@ -163,7 +171,7 @@ export const fetchTaskGroups = createAsyncThunk(
|
||||
priorities: taskReducer.priorities.join(' '),
|
||||
};
|
||||
|
||||
const response = await tasksApiService.getTaskList(config);
|
||||
const response = await tasksApiService.getTaskListV3(config);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Task Groups', error);
|
||||
@@ -192,6 +200,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)
|
||||
@@ -218,9 +240,9 @@ export const fetchSubTasks = createAsyncThunk(
|
||||
parent_task: taskId,
|
||||
};
|
||||
try {
|
||||
const response = await tasksApiService.getTaskList(config);
|
||||
const response = await tasksApiService.getTaskListV3(config);
|
||||
// Only expand if we actually fetched subtasks
|
||||
if (response.body.length > 0) {
|
||||
if (response.body && response.body.groups && response.body.groups.length > 0) {
|
||||
dispatch(toggleTaskRowExpansion(taskId));
|
||||
}
|
||||
return response.body;
|
||||
@@ -239,12 +261,12 @@ export const fetchTaskListColumns = createAsyncThunk(
|
||||
async (projectId: string, { dispatch }) => {
|
||||
const [standardColumns, customColumns] = await Promise.all([
|
||||
tasksApiService.fetchTaskListColumns(projectId),
|
||||
dispatch(fetchCustomColumns(projectId))
|
||||
dispatch(fetchCustomColumns(projectId)),
|
||||
]);
|
||||
|
||||
return {
|
||||
standard: standardColumns.body,
|
||||
custom: customColumns.payload
|
||||
custom: customColumns.payload,
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -484,11 +506,11 @@ const taskSlice = createSlice({
|
||||
if (task.parent_task_id) {
|
||||
const parentTask = group.tasks.find(t => t.id === task.parent_task_id);
|
||||
// if (parentTask) {
|
||||
// if (!parentTask.sub_tasks) parentTask.sub_tasks = [];
|
||||
// parentTask.sub_tasks.push({ ...task });
|
||||
// parentTask.sub_tasks_count = parentTask.sub_tasks.length; // Update the sub_tasks_count based on the actual length
|
||||
// Ensure sub-tasks are visible when adding a new one
|
||||
// parentTask.show_sub_tasks = true;
|
||||
// if (!parentTask.sub_tasks) parentTask.sub_tasks = [];
|
||||
// parentTask.sub_tasks.push({ ...task });
|
||||
// parentTask.sub_tasks_count = parentTask.sub_tasks.length; // Update the sub_tasks_count based on the actual length
|
||||
// Ensure sub-tasks are visible when adding a new one
|
||||
// parentTask.show_sub_tasks = true;
|
||||
// }
|
||||
} else {
|
||||
// Handle main task addition
|
||||
@@ -572,14 +594,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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -632,7 +670,8 @@ const taskSlice = createSlice({
|
||||
},
|
||||
|
||||
updateTaskStatus: (state, action: PayloadAction<ITaskListStatusChangeResponse>) => {
|
||||
const { id, status_id, color_code, color_code_dark, complete_ratio, statusCategory } = action.payload;
|
||||
const { id, status_id, color_code, color_code_dark, complete_ratio, statusCategory } =
|
||||
action.payload;
|
||||
|
||||
// Find the task in any group
|
||||
const taskInfo = findTaskInGroups(state.taskGroups, id);
|
||||
@@ -878,11 +917,14 @@ const taskSlice = createSlice({
|
||||
// Also add to columns array to maintain visibility
|
||||
state.columns.push({
|
||||
...action.payload,
|
||||
pinned: true // New columns are visible by default
|
||||
pinned: true, // New columns are visible by default
|
||||
});
|
||||
},
|
||||
|
||||
updateCustomColumn: (state, action: PayloadAction<{ key: string; column: ITaskListColumn }>) => {
|
||||
updateCustomColumn: (
|
||||
state,
|
||||
action: PayloadAction<{ key: string; column: ITaskListColumn }>
|
||||
) => {
|
||||
const { key, column } = action.payload;
|
||||
const index = state.customColumns.findIndex(col => col.key === key);
|
||||
if (index !== -1) {
|
||||
@@ -927,7 +969,7 @@ const taskSlice = createSlice({
|
||||
}>
|
||||
) => {
|
||||
const { taskId, columnKey, value } = action.payload;
|
||||
|
||||
|
||||
// Update in task groups
|
||||
for (const group of state.taskGroups) {
|
||||
// Check in main tasks
|
||||
@@ -939,7 +981,7 @@ const taskSlice = createSlice({
|
||||
group.tasks[taskIndex].custom_column_values[columnKey] = value;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
// Check in subtasks
|
||||
for (const parentTask of group.tasks) {
|
||||
if (parentTask.sub_tasks) {
|
||||
@@ -954,7 +996,7 @@ const taskSlice = createSlice({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Also update in the customColumnValues state if needed
|
||||
if (!state.customColumnValues[taskId]) {
|
||||
state.customColumnValues[taskId] = {};
|
||||
@@ -962,7 +1004,10 @@ const taskSlice = createSlice({
|
||||
state.customColumnValues[taskId][columnKey] = value;
|
||||
},
|
||||
|
||||
updateCustomColumnPinned: (state, action: PayloadAction<{ columnId: string; isVisible: boolean }>) => {
|
||||
updateCustomColumnPinned: (
|
||||
state,
|
||||
action: PayloadAction<{ columnId: string; isVisible: boolean }>
|
||||
) => {
|
||||
const { columnId, isVisible } = action.payload;
|
||||
const customColumn = state.customColumns.find(col => col.id === columnId);
|
||||
const column = state.columns.find(col => col.id === columnId);
|
||||
@@ -975,6 +1020,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 => {
|
||||
@@ -985,7 +1039,11 @@ const taskSlice = createSlice({
|
||||
})
|
||||
.addCase(fetchTaskGroups.fulfilled, (state, action) => {
|
||||
state.loadingGroups = false;
|
||||
state.taskGroups = action.payload;
|
||||
state.taskGroups = action.payload && action.payload.groups ? action.payload.groups : [];
|
||||
state.allTasks = action.payload && action.payload.allTasks ? action.payload.allTasks : [];
|
||||
state.grouping = action.payload && action.payload.grouping ? action.payload.grouping : '';
|
||||
state.totalTasks =
|
||||
action.payload && action.payload.totalTasks ? action.payload.totalTasks : 0;
|
||||
})
|
||||
.addCase(fetchTaskGroups.rejected, (state, action) => {
|
||||
state.loadingGroups = false;
|
||||
@@ -994,14 +1052,16 @@ const taskSlice = createSlice({
|
||||
.addCase(fetchSubTasks.pending, state => {
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchSubTasks.fulfilled, (state, action: PayloadAction<IProjectTask[]>) => {
|
||||
if (action.payload.length > 0) {
|
||||
const taskId = action.payload[0].parent_task_id;
|
||||
.addCase(fetchSubTasks.fulfilled, (state, action) => {
|
||||
if (action.payload && action.payload.groups && action.payload.groups.length > 0) {
|
||||
// Assuming subtasks are in the first group for this context
|
||||
const subtasks = action.payload.groups[0].tasks;
|
||||
const taskId = subtasks.length > 0 ? subtasks[0].parent_task_id : null;
|
||||
if (taskId) {
|
||||
for (const group of state.taskGroups) {
|
||||
const task = group.tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.sub_tasks = action.payload;
|
||||
task.sub_tasks = subtasks;
|
||||
task.show_sub_tasks = true;
|
||||
break;
|
||||
}
|
||||
@@ -1134,6 +1194,7 @@ export const {
|
||||
updateSubTasks,
|
||||
updateCustomColumnValue,
|
||||
updateCustomColumnPinned,
|
||||
updateRecurringChange,
|
||||
} = taskSlice.actions;
|
||||
|
||||
export default taskSlice.reducer;
|
||||
|
||||
@@ -62,7 +62,6 @@ export const editTeamName = createAsyncThunk(
|
||||
} catch (error) {
|
||||
logger.error('Edit Team Name', error);
|
||||
|
||||
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ConfigProvider, theme } from 'antd';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, memo, useMemo, useCallback } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { initializeTheme } from './themeSlice';
|
||||
@@ -9,12 +9,51 @@ type ChildrenProp = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const ThemeWrapper = ({ children }: ChildrenProp) => {
|
||||
const ThemeWrapper = memo(({ children }: ChildrenProp) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const isInitialized = useAppSelector(state => state.themeReducer.isInitialized);
|
||||
const configRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Memoize theme configuration to prevent unnecessary re-renders
|
||||
const themeConfig = useMemo(
|
||||
() => ({
|
||||
algorithm: themeMode === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
components: {
|
||||
Layout: {
|
||||
colorBgLayout: themeMode === 'dark' ? colors.darkGray : colors.white,
|
||||
headerBg: themeMode === 'dark' ? colors.darkGray : colors.white,
|
||||
},
|
||||
Menu: {
|
||||
colorBgContainer: colors.transparent,
|
||||
},
|
||||
Table: {
|
||||
rowHoverBg: themeMode === 'dark' ? '#000' : '#edebf0',
|
||||
},
|
||||
Select: {
|
||||
controlHeight: 32,
|
||||
},
|
||||
},
|
||||
token: {
|
||||
borderRadius: 4,
|
||||
},
|
||||
}),
|
||||
[themeMode]
|
||||
);
|
||||
|
||||
// Memoize the theme class name
|
||||
const themeClassName = useMemo(() => `theme-${themeMode}`, [themeMode]);
|
||||
|
||||
// Memoize the media query change handler
|
||||
const handleMediaQueryChange = useCallback(
|
||||
(e: MediaQueryListEvent) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
dispatch(initializeTheme());
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Initialize theme after mount
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
@@ -26,15 +65,9 @@ const ThemeWrapper = ({ children }: ChildrenProp) => {
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
dispatch(initializeTheme());
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [dispatch]);
|
||||
mediaQuery.addEventListener('change', handleMediaQueryChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleMediaQueryChange);
|
||||
}, [handleMediaQueryChange]);
|
||||
|
||||
// Add CSS transition classes to prevent flash
|
||||
useEffect(() => {
|
||||
@@ -44,34 +77,12 @@ const ThemeWrapper = ({ children }: ChildrenProp) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={configRef} className={`theme-${themeMode}`}>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: themeMode === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
components: {
|
||||
Layout: {
|
||||
colorBgLayout: themeMode === 'dark' ? colors.darkGray : colors.white,
|
||||
headerBg: themeMode === 'dark' ? colors.darkGray : colors.white,
|
||||
},
|
||||
Menu: {
|
||||
colorBgContainer: colors.transparent,
|
||||
},
|
||||
Table: {
|
||||
rowHoverBg: themeMode === 'dark' ? '#000' : '#edebf0',
|
||||
},
|
||||
Select: {
|
||||
controlHeight: 32,
|
||||
},
|
||||
},
|
||||
token: {
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
<div ref={configRef} className={themeClassName}>
|
||||
<ConfigProvider theme={themeConfig}>{children}</ConfigProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
ThemeWrapper.displayName = 'ThemeWrapper';
|
||||
|
||||
export default ThemeWrapper;
|
||||
|
||||
@@ -30,5 +30,6 @@ const timeLogSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
export const { toggleTimeLogDrawer, setSelectedLabel, setLabelAndToggleDrawer } = timeLogSlice.actions;
|
||||
export const { toggleTimeLogDrawer, setSelectedLabel, setLabelAndToggleDrawer } =
|
||||
timeLogSlice.actions;
|
||||
export default timeLogSlice.reducer;
|
||||
|
||||
@@ -30,4 +30,4 @@ const userSlice = createSlice({
|
||||
});
|
||||
|
||||
export const { changeUserName, setUser } = userSlice.actions;
|
||||
export default userSlice.reducer;
|
||||
export default userSlice.reducer;
|
||||
|
||||
Reference in New Issue
Block a user