Merge branch 'main' of https://github.com/Worklenz/worklenz into feature/task-activities-by-user

This commit is contained in:
chamikaJ
2025-07-14 12:46:18 +05:30
1166 changed files with 82289 additions and 15230 deletions

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ export enum Language {
PT = 'pt',
ALB = 'alb',
DE = 'de',
ZH_CN = 'zh_cn',
}
export type ILanguageType = `${Language}`;

View File

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

View File

@@ -5,7 +5,6 @@ import { Col, ConfigProvider, Flex, Menu, MenuProps, Alert } from 'antd';
import { createPortal } from 'react-dom';
import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members';
import HelpButton from './help/HelpButton';
import InviteButton from './invite/InviteButton';
import MobileMenuButton from './mobileMenu/MobileMenuButton';
import NavbarLogo from './navbar-logo';
@@ -22,6 +21,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>

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ?? '';

View File

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

View File

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

View File

@@ -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'
)}
</>
);
};

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -62,7 +62,6 @@ export const editTeamName = createAsyncThunk(
} catch (error) {
logger.error('Edit Team Name', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}

View File

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

View File

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

View File

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

View File

@@ -30,4 +30,4 @@ const userSlice = createSlice({
});
export const { changeUserName, setUser } = userSlice.actions;
export default userSlice.reducer;
export default userSlice.reducer;