This commit is contained in:
chamikaJ
2025-04-17 18:28:54 +05:30
parent f583291d8a
commit 8825b0410a
2837 changed files with 241385 additions and 127578 deletions

View File

@@ -0,0 +1,67 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Task {
id: number;
value: string;
}
interface Email {
id: number;
value: string;
}
interface AccountSetupState {
organizationName: string;
projectName: string;
templateId: string | null;
tasks: Task[];
teamMembers: Email[];
currentStep: number;
}
const initialState: AccountSetupState = {
organizationName: '',
projectName: '',
templateId: null,
tasks: [{ id: 0, value: '' }],
teamMembers: [{ id: 0, value: '' }],
currentStep: 0,
};
const accountSetupSlice = createSlice({
name: 'accountSetup',
initialState,
reducers: {
setOrganizationName: (state, action: PayloadAction<string>) => {
state.organizationName = action.payload;
},
setProjectName: (state, action: PayloadAction<string>) => {
state.projectName = action.payload;
},
setTemplateId: (state, action: PayloadAction<string | null>) => {
state.templateId = action.payload;
},
setTasks: (state, action: PayloadAction<Task[]>) => {
state.tasks = action.payload;
},
setTeamMembers: (state, action: PayloadAction<Email[]>) => {
state.teamMembers = action.payload;
},
setCurrentStep: (state, action: PayloadAction<number>) => {
state.currentStep = action.payload;
},
resetAccountSetup: () => initialState,
},
});
export const {
setOrganizationName,
setProjectName,
setTemplateId,
setTasks,
setTeamMembers,
setCurrentStep,
resetAccountSetup,
} = accountSetupSlice.actions;
export default accountSetupSlice.reducer;

View File

@@ -0,0 +1,22 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface ButtonState {
isButtonDisable: boolean;
}
const initialState: ButtonState = {
isButtonDisable: true,
};
const buttonSlice = createSlice({
name: 'button',
initialState,
reducers: {
setButtonDisabled: (state, action: PayloadAction<boolean>) => {
state.isButtonDisable = action.payload;
},
},
});
export const { setButtonDisabled } = buttonSlice.actions;
export default buttonSlice.reducer;

View File

@@ -0,0 +1,83 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { IBillingAccountInfo, IBillingAccountStorage, IFreePlanSettings } from '@/types/admin-center/admin-center.types';
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
interface adminCenterState {
isRedeemCodeDrawerOpen: boolean;
isUpgradeModalOpen: boolean;
loadingBillingInfo: boolean;
billingInfo: IBillingAccountInfo | null;
freePlanSettings: IFreePlanSettings | null;
storageInfo: IBillingAccountStorage | null;
loadingStorageInfo: boolean;
}
const initialState: adminCenterState = {
isRedeemCodeDrawerOpen: false,
isUpgradeModalOpen: false,
loadingBillingInfo: false,
billingInfo: null,
freePlanSettings: null,
storageInfo: null,
loadingStorageInfo: false,
};
export const fetchBillingInfo = createAsyncThunk('adminCenter/fetchBillingInfo', async () => {
const res = await adminCenterApiService.getBillingAccountInfo();
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();
return res.body;
});
const adminCenterSlice = createSlice({
name: 'adminCenterReducer',
initialState,
reducers: {
toggleRedeemCodeDrawer: state => {
state.isRedeemCodeDrawerOpen ? (state.isRedeemCodeDrawerOpen = false) : (state.isRedeemCodeDrawerOpen = true);
},
toggleUpgradeModal: state => {
state.isUpgradeModalOpen ? (state.isUpgradeModalOpen = false) : (state.isUpgradeModalOpen = true);
},
},
extraReducers: builder => {
builder.addCase(fetchBillingInfo.pending, (state, action) => {
state.loadingBillingInfo = true;
});
builder.addCase(fetchBillingInfo.fulfilled, (state, action) => {
state.billingInfo = action.payload;
state.loadingBillingInfo = false;
});
builder.addCase(fetchBillingInfo.rejected, (state, action) => {
state.loadingBillingInfo = false;
});
builder.addCase(fetchFreePlanSettings.fulfilled, (state, action) => {
state.freePlanSettings = action.payload;
});
builder.addCase(fetchStorageInfo.fulfilled, (state, action) => {
state.storageInfo = action.payload;
});
builder.addCase(fetchStorageInfo.rejected, (state, action) => {
state.loadingStorageInfo = false;
});
builder.addCase(fetchStorageInfo.pending, (state, action) => {
state.loadingStorageInfo = true;
});
},
});
export const { toggleRedeemCodeDrawer, toggleUpgradeModal } = adminCenterSlice.actions;
export default adminCenterSlice.reducer;

View File

@@ -0,0 +1,27 @@
import { createSlice } from '@reduxjs/toolkit';
interface billingState {
isDrawerOpen: boolean;
isModalOpen: boolean;
}
const initialState: billingState = {
isDrawerOpen: false,
isModalOpen: false,
};
const billingSlice = createSlice({
name: 'billingReducer',
initialState,
reducers: {
toggleDrawer: state => {
state.isDrawerOpen ? (state.isDrawerOpen = false) : (state.isDrawerOpen = true);
},
toggleUpgradeModal: state => {
state.isModalOpen ? (state.isModalOpen = false) : (state.isModalOpen = true);
},
},
});
export const { toggleDrawer, toggleUpgradeModal } = billingSlice.actions;
export default billingSlice.reducer;

View File

@@ -0,0 +1,156 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { authApiService } from '@/api/auth/auth.api.service';
import { IAuthState, IUserLoginRequest } from '@/types/auth/login.types';
import { IUserSignUpRequest } from '@/types/auth/signup.types';
import logger from '@/utils/errorLogger';
import { setSession } from '@/utils/session-helper';
// Initial state
const initialState: IAuthState = {
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
teamId: undefined,
projectId: undefined,
};
// Helper function for error handling
const handleAuthError = (error: any, action: string) => {
logger.error(action, error);
return error.response?.data?.message || 'An unknown error has occurred';
};
// Async thunks
export const login = createAsyncThunk(
'auth/login',
async (credentials: IUserLoginRequest, { rejectWithValue }) => {
try {
await authApiService.login(credentials);
const authorizeResponse = await authApiService.verify();
if (!authorizeResponse.authenticated) {
return rejectWithValue(authorizeResponse.auth_error || 'Authorization failed');
}
return authorizeResponse;
} catch (error: any) {
return rejectWithValue(handleAuthError(error, 'Login'));
}
}
);
export const signUp = createAsyncThunk(
'auth/signup',
async (credentials: IUserSignUpRequest, { rejectWithValue }) => {
try {
await authApiService.signUp(credentials);
const authorizeResponse = await authApiService.verify();
if (!authorizeResponse.authenticated) {
return rejectWithValue(authorizeResponse.auth_error || 'Authorization failed');
}
if (authorizeResponse.authenticated) {
localStorage.setItem('session', JSON.stringify(authorizeResponse.user));
}
return authorizeResponse;
} catch (error: any) {
return rejectWithValue(handleAuthError(error, 'SignUp'));
}
}
);
export const logout = createAsyncThunk('secure/logout', async (_, { rejectWithValue }) => {
try {
const response = await authApiService.logout();
if (!response.done) {
return rejectWithValue(response.message || 'Logout failed');
}
return response;
} catch (error: any) {
return rejectWithValue(handleAuthError(error, 'Logout'));
}
});
export const verifyAuthentication = createAsyncThunk('secure/verify', async () => {
return await authApiService.verify();
});
export const resetPassword = createAsyncThunk('auth/resetPassword', async (email: string) => {
return await authApiService.resetPassword(email);
});
export const updatePassword = createAsyncThunk('auth/updatePassword', async (values: any) => {
return await authApiService.updatePassword(values);
});
// Common state updates
const setPending = (state: IAuthState) => {
state.isLoading = true;
state.error = null;
};
const setRejected = (state: IAuthState, action: any) => {
state.isLoading = false;
state.error = action.payload as string;
};
// Slice
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setTeamAndProject: (state, action: { payload: { teamId?: string; projectId?: string } }) => {
state.teamId = action.payload.teamId;
state.projectId = action.payload.projectId;
},
},
extraReducers: builder => {
builder
// Login cases
.addCase(login.pending, setPending)
.addCase(login.fulfilled, (state, action) => {
state.isLoading = false;
state.isAuthenticated = true;
state.user = action.payload.user;
state.error = null;
})
.addCase(login.rejected, (state, action) => {
setRejected(state, action);
state.isAuthenticated = false;
})
// Logout cases
.addCase(logout.pending, setPending)
.addCase(logout.fulfilled, state => {
state.isLoading = false;
state.isAuthenticated = false;
state.user = null;
state.error = null;
state.teamId = undefined;
state.projectId = undefined;
})
.addCase(logout.rejected, setRejected)
// Verify authentication cases
.addCase(verifyAuthentication.pending, state => {
state.isLoading = true;
})
.addCase(verifyAuthentication.fulfilled, (state, action) => {
state.isLoading = false;
state.isAuthenticated = !!action.payload;
state.user = action.payload.user;
setSession(action.payload.user);
})
.addCase(verifyAuthentication.rejected, state => {
state.isLoading = false;
state.isAuthenticated = false;
state.user = null;
});
},
});
export const { setTeamAndProject } = authSlice.actions;
export default authSlice.reducer;

View File

@@ -0,0 +1,872 @@
import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
import {
IGroupByOption,
ILabelsChangeResponse,
ITaskListColumn,
ITaskListConfigV2,
ITaskListGroup,
ITaskListSortableColumn,
} from '@/types/tasks/taskList.types';
import { tasksApiService } from '@/api/tasks/tasks.api.service';
import logger from '@/utils/errorLogger';
import { ITaskListMemberFilter } from '@/types/tasks/taskListFilters.types';
import { IProjectTask, ITaskAssignee } from '@/types/project/projectTasksViewModel.types';
import { ITaskStatusViewModel } from '@/types/tasks/taskStatusGetResponse.types';
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
import { ITaskLabelFilter } from '@/types/tasks/taskLabel.types';
import { ITaskLabel } from '@/types/label.type';
import { ITeamMemberViewModel } from '../taskAttributes/taskMemberSlice';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types';
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
export enum IGroupBy {
STATUS = 'status',
PRIORITY = 'priority',
PHASE = 'phase',
MEMBERS = 'members',
}
export const GROUP_BY_STATUS_VALUE = IGroupBy.STATUS;
export const GROUP_BY_PRIORITY_VALUE = IGroupBy.PRIORITY;
export const GROUP_BY_PHASE_VALUE = IGroupBy.PHASE;
export const GROUP_BY_OPTIONS: IGroupByOption[] = [
{ label: 'Status', value: GROUP_BY_STATUS_VALUE },
{ label: 'Priority', value: GROUP_BY_PRIORITY_VALUE },
{ label: 'Phase', value: GROUP_BY_PHASE_VALUE },
];
const LOCALSTORAGE_BOARD_GROUP_KEY = 'worklenz.board.group_by';
export const getCurrentGroupBoard = (): IGroupByOption => {
const key = localStorage.getItem(LOCALSTORAGE_BOARD_GROUP_KEY);
if (key) {
const group = GROUP_BY_OPTIONS.find(option => option.value === key);
if (group) return group;
}
setCurrentBoardGroup(GROUP_BY_STATUS_VALUE);
return GROUP_BY_OPTIONS[0];
};
export const setCurrentBoardGroup = (groupBy: IGroupBy): void => {
localStorage.setItem(LOCALSTORAGE_BOARD_GROUP_KEY, groupBy);
};
interface BoardState {
search: string | null;
archived: boolean;
groupBy: IGroupBy;
isSubtasksInclude: boolean;
fields: ITaskListSortableColumn[];
tasks: IProjectTask[];
taskGroups: ITaskListGroup[];
loadingColumns: boolean;
columns: ITaskListColumn[];
loadingGroups: boolean;
error: string | null;
taskAssignees: ITaskListMemberFilter[];
loadingAssignees: boolean;
statuses: ITaskStatusViewModel[];
loadingLabels: boolean;
labels: ITaskLabelFilter[];
priorities: string[];
members: string[];
editableSectionId: string | null;
}
const initialState: BoardState = {
search: null,
archived: false,
groupBy: getCurrentGroupBoard().value as IGroupBy,
isSubtasksInclude: false,
fields: [],
tasks: [],
loadingColumns: false,
columns: [],
taskGroups: [],
loadingGroups: false,
error: null,
taskAssignees: [],
loadingAssignees: false,
statuses: [],
labels: [],
loadingLabels: false,
priorities: [],
members: [],
editableSectionId: null,
};
const deleteTaskFromGroup = (
taskGroups: ITaskListGroup[],
task: IProjectTask,
groupId: string,
index: number | null = null
): void => {
const group = taskGroups.find(g => g.id === groupId);
if (!group || !task.id) return;
if (task.is_sub_task) {
const parentTask = group.tasks.find(t => t.id === task.parent_task_id);
if (parentTask) {
const subTaskIndex = parentTask.sub_tasks?.findIndex(t => t.id === task.id);
if (typeof subTaskIndex !== 'undefined' && subTaskIndex !== -1) {
parentTask.sub_tasks_count = Math.max((parentTask.sub_tasks_count || 0) - 1, 0);
parentTask.sub_tasks?.splice(subTaskIndex, 1);
}
}
} else {
const taskIndex = index ?? group.tasks.findIndex(t => t.id === task.id);
if (taskIndex !== -1) {
group.tasks.splice(taskIndex, 1);
}
}
};
const addTaskToGroup = (
taskGroups: ITaskListGroup[],
task: IProjectTask,
groupId: string,
insert = false
): void => {
const group = taskGroups.find(g => g.id === groupId);
if (!group || !task.id) return;
if (task.parent_task_id) {
const parentTask = group.tasks.find(t => t.id === task.parent_task_id);
if (parentTask) {
parentTask.sub_tasks_count = (parentTask.sub_tasks_count || 0) + 1;
if (!parentTask.sub_tasks) parentTask.sub_tasks = [];
parentTask.sub_tasks.push({ ...task });
}
} else {
insert ? group.tasks.push(task) : group.tasks.unshift(task);
}
};
// Async thunk for fetching members data
export const fetchTaskData = createAsyncThunk('board/fetchTaskData', async (endpoint: string) => {
const response = await fetch(endpoint);
if (!response.ok) throw new Error(`Response error: ${response.status}`);
return await response.json();
});
export const fetchBoardTaskGroups = createAsyncThunk(
'board/fetchBoardTaskGroups',
async (projectId: string, { rejectWithValue, getState }) => {
try {
const state = getState() as { boardReducer: BoardState };
const { boardReducer } = state;
const selectedMembers = boardReducer.taskAssignees
.filter(member => member.selected)
.map(member => member.id)
.join(' ');
const selectedLabels = boardReducer.labels
.filter(label => label.selected)
.map(label => label.id)
.join(' ');
const config: ITaskListConfigV2 = {
id: projectId,
archived: boardReducer.archived,
group: boardReducer.groupBy,
field: boardReducer.fields.map(field => `${field.key} ${field.sort_order}`).join(','),
order: '',
search: boardReducer.search || '',
statuses: '',
members: selectedMembers,
projects: '',
isSubtasksInclude: boardReducer.isSubtasksInclude,
labels: selectedLabels,
priorities: boardReducer.priorities.join(' '),
};
const response = await tasksApiService.getTaskList(config);
return response.body;
} catch (error) {
logger.error('Fetch Task Groups', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to fetch task groups');
}
}
);
export const fetchBoardSubTasks = createAsyncThunk(
'board/fetchBoardSubTasks',
async (
{ taskId, projectId }: { taskId: string; projectId: string },
{ rejectWithValue, getState }
) => {
try {
const state = getState() as { boardReducer: BoardState };
const { boardReducer } = state;
// Check if the task is already expanded
const task = boardReducer.taskGroups.flatMap(group => group.tasks).find(t => t.id === taskId);
if (task?.show_sub_tasks) {
// If already expanded, just return without fetching
return [];
}
const selectedMembers = boardReducer.taskAssignees
.filter(member => member.selected)
.map(member => member.id)
.join(' ');
const selectedLabels = boardReducer.labels
.filter(label => label.selected)
.map(label => label.id)
.join(' ');
const config: ITaskListConfigV2 = {
id: projectId,
archived: boardReducer.archived,
group: boardReducer.groupBy,
field: boardReducer.fields.map(field => `${field.key} ${field.sort_order}`).join(','),
order: '',
search: boardReducer.search || '',
statuses: '',
members: selectedMembers,
projects: '',
isSubtasksInclude: false,
labels: selectedLabels,
priorities: boardReducer.priorities.join(' '),
parent_task: taskId,
};
const response = await tasksApiService.getTaskList(config);
return response.body;
} catch (error) {
logger.error('Fetch Sub Tasks', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to fetch sub tasks');
}
}
);
// Helper functions for common operations
const findTaskInAllGroups = (
taskGroups: ITaskListGroup[],
taskId: string
): { task: IProjectTask; group: ITaskListGroup; groupId: string } | null => {
for (const group of taskGroups) {
const task = group.tasks.find(t => t.id === taskId);
if (task) return { task, group, groupId: group.id };
// Check in subtasks
for (const parentTask of group.tasks) {
if (!parentTask.sub_tasks) continue;
const subtask = parentTask.sub_tasks.find(st => st.id === taskId);
if (subtask) return { task: subtask, group, groupId: group.id };
}
}
return null;
};
const findParentTaskInAllGroups = (
taskGroups: ITaskListGroup[],
parentTaskId: string
): { task: IProjectTask; group: ITaskListGroup } | null => {
for (const group of taskGroups) {
const task = group.tasks.find(t => t.id === parentTaskId);
if (task) return { task, group };
}
return null;
};
const getTaskListConfig = (
state: BoardState,
projectId: string,
parentTaskId?: string
): ITaskListConfigV2 => {
const selectedMembers = state.taskAssignees
.filter(member => member.selected)
.map(member => member.id)
.join(' ');
const selectedLabels = state.labels
.filter(label => label.selected)
.map(label => label.id)
.join(' ');
return {
id: projectId,
archived: state.archived,
group: state.groupBy,
field: state.fields.map(field => `${field.key} ${field.sort_order}`).join(','),
order: '',
search: state.search || '',
statuses: '',
members: selectedMembers,
projects: '',
isSubtasksInclude: state.isSubtasksInclude,
labels: selectedLabels,
priorities: state.priorities.join(' '),
parent_task: parentTaskId,
};
};
const boardSlice = createSlice({
name: 'boardReducer',
initialState,
reducers: {
setBoardGroupBy: (state, action: PayloadAction<BoardState['groupBy']>) => {
state.groupBy = action.payload;
setCurrentBoardGroup(action.payload);
},
addBoardSectionCard: (
state,
action: PayloadAction<{
id: string;
name: string;
colorCode: string;
colorCodeDark: string;
}>
) => {
const newSection = {
id: action.payload.id,
name: action.payload.name,
color_code: action.payload.colorCode,
color_code_dark: action.payload.colorCodeDark,
progress: { todo: 0, doing: 0, done: 0 },
tasks: [],
};
state.taskGroups.push(newSection as ITaskListGroup);
state.editableSectionId = newSection.id;
},
setEditableSection: (state, action: PayloadAction<string | null>) => {
state.editableSectionId = action.payload;
},
addTaskCardToTheTop: (
state,
action: PayloadAction<{ sectionId: string; task: IProjectTask }>
) => {
const section = state.taskGroups.find(sec => sec.id === action.payload.sectionId);
if (section) {
section.tasks.unshift(action.payload.task);
}
},
addTaskCardToTheBottom: (
state,
action: PayloadAction<{ sectionId: string; task: IProjectTask }>
) => {
const section = state.taskGroups.find(sec => sec.id === action.payload.sectionId);
if (section) {
section.tasks.push(action.payload.task);
}
},
addSubtask: (
state,
action: PayloadAction<{ sectionId: string; taskId: string; subtask: IProjectTask }>
) => {
const section = state.taskGroups.find(sec => sec.id === action.payload.sectionId);
if (section) {
const task = section.tasks.find(task => task.id === action.payload.taskId);
if (task) {
if (!task.sub_tasks) {
task.sub_tasks = [];
}
task.sub_tasks.push(action.payload.subtask);
task.sub_tasks_count = task.sub_tasks.length;
}
}
},
deleteBoardTask: (state, action: PayloadAction<{ sectionId: string; taskId: string }>) => {
const { sectionId, taskId } = action.payload;
if (sectionId) {
const section = state.taskGroups.find(sec => sec.id === sectionId);
if (section) {
// Check if task is in the main task list
const taskIndex = section.tasks.findIndex(task => task.id === taskId);
if (taskIndex !== -1) {
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);
parentTask.sub_tasks_count = Math.max(0, (parentTask.sub_tasks_count || 1) - 1);
return;
}
}
}
}
// If section not found or task not in section, search all groups
for (const group of state.taskGroups) {
// Check main tasks
const taskIndex = group.tasks.findIndex(task => task.id === taskId);
if (taskIndex !== -1) {
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);
parentTask.sub_tasks_count = Math.max(0, (parentTask.sub_tasks_count || 1) - 1);
return;
}
}
}
},
deleteSection: (state, action: PayloadAction<{ sectionId: string }>) => {
state.taskGroups = state.taskGroups.filter(
section => section.id !== action.payload.sectionId
);
if (state.editableSectionId === action.payload.sectionId) {
state.editableSectionId = null;
}
},
updateBoardTaskAssignee: (
state,
action: PayloadAction<{
body: ITaskAssigneesUpdateResponse;
sectionId: string;
taskId: string;
}>
) => {
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;
}
}
},
reorderTaskGroups: (state, action: PayloadAction<ITaskListGroup[]>) => {
state.taskGroups = action.payload;
},
moveTaskBetweenGroups: (
state,
action: PayloadAction<{
taskId: string;
sourceGroupId: string;
targetGroupId: string;
targetIndex: number;
}>
) => {
const { taskId, sourceGroupId, targetGroupId, targetIndex } = action.payload;
// Find source and target groups
const sourceGroup = state.taskGroups.find(group => group.id === sourceGroupId);
const targetGroup = state.taskGroups.find(group => group.id === targetGroupId);
if (!sourceGroup || !targetGroup) return;
// Find the task to move
const taskIndex = sourceGroup.tasks.findIndex(task => task.id === taskId);
if (taskIndex === -1) return;
// Get the task and remove it from source
const task = { ...sourceGroup.tasks[taskIndex], status_id: targetGroupId };
sourceGroup.tasks.splice(taskIndex, 1);
// Insert task at the target position
if (targetIndex >= 0 && targetIndex <= targetGroup.tasks.length) {
targetGroup.tasks.splice(targetIndex, 0, task);
} else {
// If target index is invalid, append to the end
targetGroup.tasks.push(task);
}
},
resetBoardData: state => {
state.taskGroups = [];
state.columns = [];
state.loadingGroups = false;
state.loadingColumns = false;
state.error = null;
},
setBoardLabels: (state, action: PayloadAction<ITaskLabelFilter[]>) => {
state.labels = action.payload;
},
setBoardMembers: (state, action: PayloadAction<ITaskListMemberFilter[]>) => {
state.taskAssignees = action.payload;
},
setBoardPriorities: (state, action: PayloadAction<string[]>) => {
state.priorities = action.payload;
},
setBoardStatuses: (state, action: PayloadAction<ITaskStatusViewModel[]>) => {
state.statuses = action.payload;
},
setBoardSearch: (state, action: PayloadAction<string | null>) => {
state.search = action.payload;
},
toggleSubtasksInclude: state => {
state.isSubtasksInclude = !state.isSubtasksInclude;
},
setBoardGroupName: (
state,
action: PayloadAction<{
groupId: string;
name: string;
colorCode: string;
colorCodeDark: string;
categoryId: string;
}>
) => {
const { groupId, name, colorCode, colorCodeDark, categoryId } = action.payload;
const group = state.taskGroups.find(group => group.id === groupId);
if (group) {
group.name = name;
group.color_code = colorCode;
group.color_code_dark = colorCodeDark;
group.category_id = categoryId;
}
},
updateTaskAssignees: (
state,
action: PayloadAction<{
groupId: string;
taskId: string;
assignees: ITeamMemberViewModel[];
names: ITeamMemberViewModel[];
}>
) => {
const { groupId, taskId, assignees, names } = action.payload;
// Find the task in the specified group
const group = state.taskGroups.find(group => group.id === groupId);
if (!group) return;
// Try to find the task directly in the group
const task = group.tasks.find(task => task.id === taskId);
if (task) {
task.assignees = assignees as ITaskAssignee[];
task.names = names as InlineMember[];
return;
}
// If not found, look in subtasks
for (const parentTask of group.tasks) {
if (!parentTask.sub_tasks) continue;
const subtask = parentTask.sub_tasks.find(subtask => subtask.id === taskId);
if (subtask) {
subtask.assignees = assignees as ITaskAssignee[];
subtask.names = names as InlineMember[];
return;
}
}
},
updateTaskName: (
state,
action: PayloadAction<{
task: IProjectTask;
}>
) => {
const { task } = action.payload;
// Find the task and update it
const result = findTaskInAllGroups(state.taskGroups, task.id || '');
if (result) {
result.task.name = task.name;
}
},
updateTaskEndDate: (
state,
action: PayloadAction<{
task: IProjectTask;
}>
) => {
const { task } = action.payload;
// Find the task and update it
const result = findTaskInAllGroups(state.taskGroups, task.id || '');
if (result) {
result.task.end_date = task.end_date;
}
},
updateSubtask: (
state,
action: PayloadAction<{
sectionId: string;
subtask: IProjectTask;
mode: 'add' | 'delete';
}>
) => {
const { sectionId, subtask, mode } = action.payload;
const parentTaskId = subtask?.parent_task_id || null;
if (!parentTaskId) return;
// Function to update a task with a new subtask
const updateTaskWithSubtask = (task: IProjectTask): boolean => {
if (!task) return false;
// Initialize sub_tasks array if it doesn't exist
if (!task.sub_tasks) {
task.sub_tasks = [];
}
if (mode === 'add') {
// Increment subtask count
task.sub_tasks_count = (task.sub_tasks_count || 0) + 1;
// Add the subtask
task.sub_tasks.push({ ...subtask });
} else {
// Remove the subtask
task.sub_tasks = task.sub_tasks.filter(t => t.id !== subtask.id);
task.sub_tasks_count = Math.max(0, (task.sub_tasks_count || 1) - 1);
}
return true;
};
// First try to find the task in the specified section
if (sectionId) {
const section = state.taskGroups.find(sec => sec.id === sectionId);
if (section) {
const task = section.tasks.find(task => task.id === parentTaskId);
if (task && updateTaskWithSubtask(task)) {
return;
}
}
}
// If not found in the specified section, try all groups
const result = findParentTaskInAllGroups(state.taskGroups, parentTaskId);
if (result) {
updateTaskWithSubtask(result.task);
}
},
toggleTaskExpansion: (state, action: PayloadAction<string>) => {
const taskId = action.payload;
const result = findTaskInAllGroups(state.taskGroups, taskId);
if (result) {
result.task.show_sub_tasks = !result.task.show_sub_tasks;
}
},
updateBoardTaskStatus: (state, action: PayloadAction<ITaskListStatusChangeResponse>) => {
const { id, status_id, color_code, color_code_dark, complete_ratio, statusCategory } =
action.payload;
// Find the task in any group
const taskInfo = findTaskInAllGroups(state.taskGroups, id);
if (!taskInfo || !status_id) return;
const { task, groupId } = taskInfo;
// Update the task properties
task.status_color = color_code;
task.status_color_dark = color_code_dark;
task.complete_ratio = +complete_ratio;
task.status = status_id;
task.status_category = statusCategory;
// If grouped by status and not a subtask, move the task to the new status group
if (state.groupBy === GROUP_BY_STATUS_VALUE && !task.is_sub_task && groupId !== status_id) {
// Remove from current group
deleteTaskFromGroup(state.taskGroups, task, groupId);
// Add to new status group
addTaskToGroup(state.taskGroups, task, status_id, false);
}
},
updateTaskPriority: (state, action: PayloadAction<ITaskListPriorityChangeResponse>) => {
const { id, priority_id, color_code, color_code_dark } = action.payload;
// Find the task in any group
const taskInfo = findTaskInAllGroups(state.taskGroups, id);
if (!taskInfo || !priority_id) return;
const { task, groupId } = taskInfo;
// Update the task properties
task.priority = priority_id;
task.priority_color = color_code;
task.priority_color_dark = color_code_dark;
// If grouped by priority and not a subtask, move the task to the new priority group
if (
state.groupBy === GROUP_BY_PRIORITY_VALUE &&
!task.is_sub_task &&
groupId !== priority_id
) {
// Remove from current group
deleteTaskFromGroup(state.taskGroups, task, groupId);
// Add to new priority group
addTaskToGroup(state.taskGroups, task, priority_id, false);
}
},
updateBoardTaskLabel: (state, action: PayloadAction<ILabelsChangeResponse>) => {
const label = action.payload;
for (const group of state.taskGroups) {
// Find the task or its subtask
const task =
group.tasks.find(task => task.id === label.id) ||
group.tasks
.flatMap(task => task.sub_tasks || [])
.find(subtask => subtask.id === label.id);
if (task) {
task.labels = label.labels || [];
task.all_labels = label.all_labels || [];
break;
}
}
},
updateTaskProgress: (
state,
action: PayloadAction<{
id: string;
complete_ratio: number;
completed_count: number;
total_tasks_count: number;
parent_task: string;
}>
) => {
const { id, complete_ratio, completed_count, total_tasks_count, parent_task } =
action.payload;
// Find the task in any group
const taskInfo = findTaskInAllGroups(state.taskGroups, parent_task || id);
// Check if taskInfo exists before destructuring
if (!taskInfo) return;
const { task } = taskInfo;
// Update the task properties
task.complete_ratio = +complete_ratio;
task.completed_count = completed_count;
task.total_tasks_count = total_tasks_count;
},
},
extraReducers: builder => {
builder
.addCase(fetchBoardTaskGroups.pending, state => {
state.loadingGroups = true;
state.error = null;
})
.addCase(fetchBoardTaskGroups.fulfilled, (state, action) => {
state.loadingGroups = false;
state.taskGroups = action.payload;
})
.addCase(fetchBoardTaskGroups.rejected, (state, action) => {
state.loadingGroups = false;
state.error = action.error.message || 'Failed to fetch task groups';
})
.addCase(fetchBoardSubTasks.pending, (state, action) => {
state.error = null;
// Find the task and set sub_tasks_loading to true
const taskId = action.meta.arg.taskId;
const result = findTaskInAllGroups(state.taskGroups, taskId);
if (result) {
result.task.sub_tasks_loading = true;
}
})
.addCase(fetchBoardSubTasks.fulfilled, (state, action: PayloadAction<IProjectTask[]>) => {
if (action.payload.length > 0) {
const taskId = action.payload[0].parent_task_id;
if (taskId) {
const result = findTaskInAllGroups(state.taskGroups, taskId);
if (result) {
result.task.sub_tasks = action.payload;
result.task.show_sub_tasks = true;
result.task.sub_tasks_loading = false;
result.task.sub_tasks_count = action.payload.length;
}
}
} else {
// If no subtasks were returned, we still need to set loading to false
const taskId = (action as any).meta?.arg?.taskId;
const result = findTaskInAllGroups(state.taskGroups, taskId);
if (result) {
result.task.sub_tasks_loading = false;
result.task.sub_tasks = [];
result.task.sub_tasks_count = 0;
}
}
})
.addCase(fetchBoardSubTasks.rejected, (state, action) => {
state.error = action.error.message || 'Failed to fetch sub tasks';
// Set loading to false on rejection
const taskId = action.meta.arg.taskId;
const result = findTaskInAllGroups(state.taskGroups, taskId);
if (result) {
result.task.sub_tasks_loading = false;
}
});
},
});
export const {
setBoardGroupBy,
addBoardSectionCard,
setEditableSection,
addTaskCardToTheTop,
addTaskCardToTheBottom,
addSubtask,
deleteSection,
deleteBoardTask,
updateBoardTaskAssignee,
reorderTaskGroups,
moveTaskBetweenGroups,
resetBoardData,
setBoardLabels,
setBoardMembers,
setBoardPriorities,
setBoardStatuses,
setBoardSearch,
setBoardGroupName,
updateTaskAssignees,
updateTaskEndDate,
updateTaskName,
updateSubtask,
toggleSubtasksInclude,
toggleTaskExpansion,
updateBoardTaskStatus,
updateTaskPriority,
updateBoardTaskLabel,
updateTaskProgress,
} = boardSlice.actions;
export default boardSlice.reducer;

View File

@@ -0,0 +1,35 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CreateCardState {
taskCardDisabledStatus: { [group: string]: { top: boolean; bottom: boolean } };
}
const initialState: CreateCardState = {
taskCardDisabledStatus: {},
};
const createCardSlice = createSlice({
name: 'createCard',
initialState,
reducers: {
initializeGroup(state, action: PayloadAction<string>) {
const group = action.payload;
if (!state.taskCardDisabledStatus[group]) {
state.taskCardDisabledStatus[group] = { top: true, bottom: true };
}
},
setTaskCardDisabled: (
state,
action: PayloadAction<{ group: string; position: 'top' | 'bottom'; disabled: boolean }>
) => {
const { group, position, disabled } = action.payload;
if (!state.taskCardDisabledStatus[group]) {
state.taskCardDisabledStatus[group] = { top: true, bottom: true };
}
state.taskCardDisabledStatus[group][position] = disabled;
},
},
});
export const { setTaskCardDisabled, initializeGroup } = createCardSlice.actions;
export default createCardSlice.reducer;

View File

@@ -0,0 +1,24 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
type DateSliceState = {
date: Dayjs;
};
const initialState: DateSliceState = {
date: dayjs(),
};
const dateSlice = createSlice({
name: 'dateReducer',
initialState,
reducers: {
selectedDate: (state, action: PayloadAction<Dayjs>) => {
state.date = action.payload;
},
},
});
export const { selectedDate } = dateSlice.actions;
export default dateSlice.reducer;

View File

@@ -0,0 +1,22 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface GroupByFilterState {
groupBy: string;
}
const initialState: GroupByFilterState = {
groupBy: 'status',
};
const groupByFilterDropdownSlice = createSlice({
name: 'groupByFilterDropdown',
initialState,
reducers: {
setGroupBy(state, action: PayloadAction<string>) {
state.groupBy = action.payload;
},
},
});
export const { setGroupBy } = groupByFilterDropdownSlice.actions;
export default groupByFilterDropdownSlice.reducer;

View File

@@ -0,0 +1,156 @@
import {
useCreatePersonalTaskMutation,
useGetMyTasksQuery,
useGetPersonalTasksQuery,
useGetProjectsByTeamQuery,
} from '@/api/home-page/home-page.api.service';
import { MY_DASHBOARD_ACTIVE_FILTER, MY_DASHBOARD_DEFAULT_VIEW } from '@/shared/constants';
import { IHomeTasksConfig, IHomeTasksModel } from '@/types/home/home-page.types';
import { IMyTask } from '@/types/home/my-tasks.types';
import { IMyDashboardMyTask } from '@/types/home/tasks.types';
import { IProject } from '@/types/project/project.types';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
const getActiveProjectsFilter = () => +(localStorage.getItem(MY_DASHBOARD_ACTIVE_FILTER) || 0);
interface IHomePageState {
loadingProjects: boolean;
projects: IProject[];
loadingPersonalTasks: boolean;
personalTasks: IMyTask[];
homeTasks: IMyDashboardMyTask[];
homeGroups: ITaskListGroup[];
homeTasksLoading: boolean;
homeTasksConfig: IHomeTasksConfig;
homeTasksUpdating: boolean;
model: IHomeTasksModel;
selectedDate: Dayjs;
}
const initialState: IHomePageState = {
loadingProjects: false,
projects: [],
loadingPersonalTasks: false,
personalTasks: [],
homeTasks: [],
homeGroups: [],
homeTasksLoading: false,
homeTasksUpdating: false,
homeTasksConfig: {
tasks_group_by: 0,
current_view: getActiveProjectsFilter(),
current_tab: MY_DASHBOARD_DEFAULT_VIEW,
is_calendar_view: getActiveProjectsFilter() !== 0,
selected_date: getActiveProjectsFilter() === 0 ? dayjs() : null,
time_zone: '',
},
model: {
total: 0,
tasks: [],
today: 0,
upcoming: 0,
overdue: 0,
no_due_date: 0,
},
selectedDate: dayjs(),
};
export const fetchProjects = createAsyncThunk('homePage/fetchProjects', async () => {
const response = useGetProjectsByTeamQuery();
return response.data?.body;
});
export const fetchPersonalTasks = createAsyncThunk('homePage/fetchPersonalTasks', async () => {
const response = useGetPersonalTasksQuery();
return response.data?.body;
});
export const createPersonalTask = createAsyncThunk(
'homePage/createPersonalTask',
async (newTodo: IMyTask) => {
const response = useCreatePersonalTaskMutation();
return response;
}
);
export const fetchHomeTasks = createAsyncThunk(
'homePage/fetchHomeTasks',
async (homeTasksConfig: IHomeTasksConfig) => {
const response = useGetMyTasksQuery(homeTasksConfig);
return response.data?.body;
}
);
export const homePageSlice = createSlice({
name: 'homePage',
initialState,
reducers: {
setProjects: (state, action) => {
state.projects = action.payload;
state.loadingProjects = false;
},
setPersonalTasks: (state, action) => {
state.personalTasks = action.payload;
state.loadingPersonalTasks = false;
},
setHomeTasks: (state, action) => {
state.homeTasks = action.payload;
state.homeTasksLoading = false;
},
setHomeTasksConfig: (state, action) => {
state.homeTasksConfig = action.payload;
},
},
extraReducers: builder => {
builder.addCase(fetchProjects.fulfilled, (state, action) => {
state.projects = action.payload || [];
state.loadingProjects = false;
});
builder.addCase(fetchProjects.pending, state => {
state.loadingProjects = true;
state.projects = [];
});
builder.addCase(fetchProjects.rejected, state => {
state.loadingProjects = false;
state.projects = [];
});
builder.addCase(fetchPersonalTasks.pending, state => {
state.loadingPersonalTasks = true;
});
builder.addCase(fetchPersonalTasks.fulfilled, (state, action) => {
state.personalTasks = action.payload || [];
state.loadingPersonalTasks = false;
});
builder.addCase(fetchPersonalTasks.rejected, state => {
state.loadingPersonalTasks = false;
});
builder.addCase(fetchHomeTasks.fulfilled, (state, action) => {
state.model = action.payload || {
total: 0,
tasks: [],
today: 0,
upcoming: 0,
overdue: 0,
no_due_date: 0,
};
state.homeTasksLoading = false;
});
builder.addCase(fetchHomeTasks.pending, state => {
state.homeTasksLoading = true;
});
builder.addCase(fetchHomeTasks.rejected, state => {
state.homeTasksLoading = false;
});
},
});
export const { setProjects, setHomeTasksConfig } = homePageSlice.actions;
export default homePageSlice.reducer;

View File

@@ -0,0 +1,56 @@
import { Button, Dropdown } from 'antd';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { ILanguageType, setLanguage } from './localesSlice';
const LanguageSelector = () => {
const language = useAppSelector(state => state.localesReducer.lng);
const dispatch = useAppDispatch();
const handleLanguageChange = (lang: ILanguageType) => {
dispatch(setLanguage(lang));
};
const items = [
{ key: 'en', label: 'English' },
{ key: 'es', label: 'Español' },
{ key: 'pt', label: 'Português' },
];
const languageLabels = {
en: 'En',
es: 'Es',
pt: 'Pt',
};
return (
<Dropdown
menu={{
items: items.map(item => ({
...item,
onClick: () => handleLanguageChange(item.key as ILanguageType),
})),
}}
placement="bottom"
trigger={['click']}
>
<Button
shape="circle"
style={{
textTransform: 'capitalize',
fontWeight: 500,
minWidth: '40px',
height: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label="Change language"
>
{languageLabels[language]}
</Button>
</Dropdown>
);
};
export default LanguageSelector;

View File

@@ -0,0 +1,75 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import i18n from '../../i18n';
export enum Language {
EN = 'en',
ES = 'es',
PT = 'pt',
}
export type ILanguageType = `${Language}`;
type LocalesState = {
lng: ILanguageType;
};
const STORAGE_KEY = 'i18nextLng';
/**
* Gets the user's browser language and returns it if supported, otherwise returns English
* @returns The detected supported language or English as fallback
*/
const getDefaultLanguage = (): ILanguageType => {
const browserLang = navigator.language.split('-')[0];
if (Object.values(Language).includes(browserLang as Language)) {
return browserLang as ILanguageType;
}
return Language.EN;
};
const DEFAULT_LANGUAGE: ILanguageType = getDefaultLanguage();
/**
* Gets the current language from local storage
* @returns The stored language or default language if not found
*/
const getLanguageFromLocalStorage = (): ILanguageType => {
const savedLng = localStorage.getItem(STORAGE_KEY);
if (Object.values(Language).includes(savedLng as Language)) {
return savedLng as ILanguageType;
}
return DEFAULT_LANGUAGE;
};
/**
* Saves the current language to local storage
* @param lng Language to save
*/
const saveLanguageInLocalStorage = (lng: ILanguageType): void => {
localStorage.setItem(STORAGE_KEY, lng);
};
const initialState: LocalesState = {
lng: getLanguageFromLocalStorage(),
};
const localesSlice = createSlice({
name: 'localesReducer',
initialState,
reducers: {
toggleLng: state => {
const newLang: ILanguageType = state.lng === Language.EN ? Language.ES : Language.EN;
state.lng = newLang;
saveLanguageInLocalStorage(newLang);
i18n.changeLanguage(newLang);
},
setLanguage: (state, action: PayloadAction<ILanguageType>) => {
state.lng = action.payload;
saveLanguageInLocalStorage(action.payload);
i18n.changeLanguage(action.payload);
},
},
});
export const { toggleLng, setLanguage } = localesSlice.actions;
export default localesSlice.reducer;

View File

@@ -0,0 +1,3 @@
.notification-icon:hover .anticon {
color: #1677ff;
}

View File

@@ -0,0 +1,23 @@
import { QuestionCircleOutlined } from '@ant-design/icons';
import { Button, Tooltip } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
import './HelpButton.css';
const HelpButton = () => {
// localization
const { t } = useTranslation('navbar');
return (
<Tooltip title={t('help')}>
<Button
className="notification-icon"
style={{ height: '62px', width: '60px' }}
type="text"
icon={<QuestionCircleOutlined style={{ fontSize: 20 }} />}
/>
</Tooltip>
);
};
export default HelpButton;

View File

@@ -0,0 +1,32 @@
import { UsergroupAddOutlined } from '@ant-design/icons';
import { Button, Tooltip } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { colors } from '../../../styles/colors';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleInviteMemberDrawer } from '../../settings/member/memberSlice';
const InviteButton = () => {
const dispatch = useAppDispatch();
// localization
const { t } = useTranslation('navbar');
return (
<Tooltip title={t('inviteTooltip')}>
<Button
type="dashed"
icon={<UsergroupAddOutlined />}
style={{
color: colors.skyBlue,
borderColor: colors.skyBlue,
}}
onClick={() => dispatch(toggleInviteMemberDrawer())}
>
{t('invite')}
</Button>
</Tooltip>
);
};
export default InviteButton;

View File

@@ -0,0 +1,99 @@
import {
ClockCircleOutlined,
HomeOutlined,
MenuOutlined,
ProjectOutlined,
QuestionCircleOutlined,
ReadOutlined,
} from '@ant-design/icons';
import { Button, Card, Dropdown, Flex, MenuProps, Space, Typography } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { colors } from '../../../styles/colors';
import { NavLink } from 'react-router-dom';
import InviteButton from '../invite/InviteButton';
import SwitchTeamButton from '../switchTeam/SwitchTeamButton';
// custom css
import './mobileMenu.css';
const MobileMenuButton = () => {
// localization
const { t } = useTranslation('navbar');
const navLinks = [
{
name: 'home',
icon: React.createElement(HomeOutlined),
},
{
name: 'projects',
icon: React.createElement(ProjectOutlined),
},
{
name: 'schedule',
icon: React.createElement(ClockCircleOutlined),
},
{
name: 'reporting',
icon: React.createElement(ReadOutlined),
},
{
name: 'help',
icon: React.createElement(QuestionCircleOutlined),
},
];
const mobileMenu: MenuProps['items'] = [
{
key: '1',
label: (
<Card className="mobile-menu-card" bordered={false} style={{ width: 230 }}>
{navLinks.map((navEl, index) => (
<NavLink key={index} to={`/worklenz/${navEl.name}`}>
<Typography.Text strong>
<Space>
{navEl.icon}
{t(navEl.name)}
</Space>
</Typography.Text>
</NavLink>
))}
<Flex
vertical
gap={12}
style={{
width: '90%',
marginInlineStart: 12,
marginBlock: 6,
}}
>
<Button
style={{
backgroundColor: colors.lightBeige,
color: 'black',
}}
>
{t('upgradePlan')}
</Button>
<InviteButton />
<SwitchTeamButton />
</Flex>
</Card>
),
},
];
return (
<Dropdown
overlayClassName="mobile-menu-dropdown"
menu={{ items: mobileMenu }}
placement="bottomRight"
trigger={['click']}
>
<Button className="borderless-icon-btn" icon={<MenuOutlined style={{ fontSize: 20 }} />} />
</Dropdown>
);
};
export default MobileMenuButton;

View File

@@ -0,0 +1,38 @@
.mobile-menu-dropdown .ant-dropdown-menu {
padding: 0 !important;
margin-top: 2px !important;
}
.mobile-menu-dropdown .ant-dropdown-menu-item {
padding: 0 !important;
}
.mobile-menu-card .ant-card-head {
padding: 0 16px;
}
.mobile-menu-card .ant-card-body {
padding: 4px 0;
}
.mobile-menu-card .ant-card-body a {
display: block;
margin: 0;
padding: 10px 15px;
}
.mobile-menu-card .ant-card-body a:hover {
color: #1890ff !important;
background-color: #edebf0 !important;
box-sizing: border-box;
}
html[data-theme="dark"] .mobile-menu-card .ant-card-body a:hover {
background-color: #333 !important;
color: #1890ff !important;
}
.mobile-menu-card {
border: none !important;
box-shadow: none !important;
}

View File

@@ -0,0 +1,33 @@
export type NavRoutesType = {
name: string;
path: string;
adminOnly: boolean;
freePlanFeature?: boolean;
};
export const navRoutes: NavRoutesType[] = [
{
name: 'home',
path: '/worklenz/home',
adminOnly: false,
freePlanFeature: true,
},
{
name: 'projects',
path: '/worklenz/projects',
adminOnly: false,
freePlanFeature: true,
},
// {
// name: 'schedule',
// path: '/worklenz/schedule',
// adminOnly: true,
// freePlanFeature: false,
// },
{
name: 'reporting',
path: '/worklenz/reporting/overview',
adminOnly: true,
freePlanFeature: false,
},
];

View File

@@ -0,0 +1,45 @@
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 { useAppSelector } from '@/hooks/useAppSelector';
import { useSelector } from 'react-redux';
import { RootState } from '@/app/store';
const NavbarLogo = () => {
const { t } = useTranslation('navbar');
const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
return (
<Link to={'/worklenz/home'}>
<div style={{ position: 'relative', display: 'inline-block' }}>
<img
src={themeMode === 'dark' ? logoDark : logo}
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>
);
};
export default NavbarLogo;

View File

@@ -0,0 +1,186 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
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';
import NotificationButton from '../../components/navbar/notifications/notifications-drawer/notification/notification-button';
import ProfileButton from './user-profile/profile-button';
import SwitchTeamButton from './switchTeam/SwitchTeamButton';
import UpgradePlanButton from './upgradePlan/UpgradePlanButton';
import NotificationDrawer from '../../components/navbar/notifications/notifications-drawer/notification/notfication-drawer';
import { useResponsive } from '@/hooks/useResponsive';
import { getJSONFromLocalStorage } from '@/utils/localStorageFunctions';
import { navRoutes, NavRoutesType } from './navRoutes';
import { useAuthService } from '@/hooks/useAuth';
import { authApiService } from '@/api/auth/auth.api.service';
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
import logger from '@/utils/errorLogger';
const Navbar = () => {
const [current, setCurrent] = useState<string>('home');
const currentSession = useAuthService().getCurrentSession();
const [daysUntilExpiry, setDaysUntilExpiry] = useState<number | null>(null);
const location = useLocation();
const { isDesktop, isMobile, isTablet } = useResponsive();
const { t } = useTranslation('navbar');
const authService = useAuthService();
const [navRoutesList, setNavRoutesList] = useState<NavRoutesType[]>(navRoutes);
const [isOwnerOrAdmin, setIsOwnerOrAdmin] = useState<boolean>(authService.isOwnerOrAdmin());
const showUpgradeTypes = [ISUBSCRIPTION_TYPE.TRIAL]
useEffect(() => {
authApiService.verify().then(authorizeResponse => {
if (authorizeResponse.authenticated) {
authService.setCurrentSession(authorizeResponse.user);
setIsOwnerOrAdmin(!!(authorizeResponse.user.is_admin || authorizeResponse.user.owner));
}
}).catch(error => {
logger.error('Error during authorization', error);
});
}, []);
useEffect(() => {
const storedNavRoutesList: NavRoutesType[] = getJSONFromLocalStorage('navRoutes') || navRoutes;
setNavRoutesList(storedNavRoutesList);
}, []);
useEffect(() => {
if (currentSession?.trial_expire_date) {
const today = new Date();
const expiryDate = new Date(currentSession.trial_expire_date);
const diffTime = expiryDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
setDaysUntilExpiry(diffDays);
}
}, [currentSession?.trial_expire_date]);
const navlinkItems = useMemo(
() =>
navRoutesList
.filter(route => {
if (!route.freePlanFeature && currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE) return false;
if (route.adminOnly && !isOwnerOrAdmin) return false;
return true;
})
.map((route, index) => ({
key: route.path.split('/').pop() || index,
label: (
<Link to={route.path} style={{ fontWeight: 600 }}>
{t(route.name)}
</Link>
),
})),
[navRoutesList, t, isOwnerOrAdmin, currentSession?.subscription_type]
);
useEffect(() => {
const afterWorklenzString = location.pathname.split('/worklenz/')[1];
const pathKey = afterWorklenzString.split('/')[0];
setCurrent(pathKey ?? 'home');
}, [location]);
return (
<Col
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
paddingInline: isDesktop ? 48 : 24,
gap: 12,
alignItems: 'center',
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%',
display: 'flex',
gap: 12,
alignItems: 'center',
justifyContent: 'space-between',
}}
>
{/* logo */}
<NavbarLogo />
<Flex
align="center"
justify={isDesktop ? 'space-between' : 'flex-end'}
style={{ width: '100%' }}
>
{/* navlinks menu */}
{isDesktop && (
<Menu
selectedKeys={[current]}
mode="horizontal"
style={{
flex: 10,
maxWidth: 720,
minWidth: 0,
border: 'none',
}}
items={navlinkItems}
/>
)}
<Flex gap={20} align="center">
<ConfigProvider wave={{ disabled: true }}>
{isDesktop && (
<Flex gap={20} align="center">
{isOwnerOrAdmin && showUpgradeTypes.includes(currentSession?.subscription_type as ISUBSCRIPTION_TYPE) && (
<UpgradePlanButton />
)}
{isOwnerOrAdmin && <InviteButton />}
<Flex align="center">
<SwitchTeamButton />
<NotificationButton />
<HelpButton />
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
</Flex>
</Flex>
)}
{isTablet && !isDesktop && (
<Flex gap={12} align="center">
<SwitchTeamButton />
<NotificationButton />
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
<MobileMenuButton />
</Flex>
)}
{isMobile && (
<Flex gap={12} align="center">
<NotificationButton />
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
<MobileMenuButton />
</Flex>
)}
</ConfigProvider>
</Flex>
</Flex>
</Flex>
{isOwnerOrAdmin && createPortal(<InviteTeamMembers />, document.body, 'invite-team-members')}
{createPortal(<NotificationDrawer />, document.body, 'notification-drawer')}
</Col>
);
};
export default Navbar;

View File

@@ -0,0 +1,117 @@
import { ITeamInvitationViewModel } from '@/types/notifications/notifications.types';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { NotificationsDataModel } from '@/types/notifications/notifications.types';
import { NotificationType } from '../../types/notification.types';
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { notificationsApiService } from '@/api/notifications/notifications.api.service';
type NotificationState = {
notificationType: 'Read' | 'Unread';
loading: boolean;
loadingInvitations: boolean;
notifications: IWorklenzNotification[];
notificationsCount: number;
isDrawerOpen: boolean;
invitations: ITeamInvitationViewModel[];
invitationsCount: number;
showBrowserPush: boolean;
_dataset: NotificationsDataModel;
dataset: NotificationsDataModel;
loadersMap: { [x: string]: boolean };
unreadNotificationsCount: number;
};
const initialState: NotificationState = {
notificationType: 'Unread',
loading: false,
loadingInvitations: false,
notifications: [],
notificationsCount: 0,
isDrawerOpen: false,
invitations: [],
invitationsCount: 0,
showBrowserPush: false,
_dataset: [],
dataset: [],
loadersMap: {},
unreadNotificationsCount: 0,
};
export const fetchInvitations = createAsyncThunk('notification/fetchInvitations', async () => {
const res = await teamsApiService.getInvitations();
return res.body;
});
export const fetchNotifications = createAsyncThunk(
'notification/fetchNotifications',
async (filter: string) => {
const res = await notificationsApiService.getNotifications(filter);
return res.body;
}
);
export const fetchUnreadCount = createAsyncThunk('notification/fetchUnreadCount', async () => {
const res = await notificationsApiService.getUnreadCount();
return res.body;
});
const notificationSlice = createSlice({
name: 'notificationReducer',
initialState,
reducers: {
toggleDrawer: state => {
state.isDrawerOpen ? (state.isDrawerOpen = false) : (state.isDrawerOpen = true);
},
setNotificationType: (state, action) => {
state.notificationType = action.payload;
},
},
extraReducers: builder => {
builder.addCase(fetchInvitations.pending, state => {
state.loading = true;
});
builder.addCase(fetchInvitations.fulfilled, (state, action) => {
state.loading = false;
state.invitations = action.payload;
state.invitationsCount = action.payload.length;
state.invitations.map(invitation => {
state._dataset.push({
type: 'invitation',
data: invitation,
});
});
});
builder.addCase(fetchInvitations.rejected, state => {
state.loading = false;
});
builder.addCase(fetchNotifications.pending, state => {
state.loading = true;
});
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
state.loading = false;
state.notifications = action.payload;
state.notificationsCount = action.payload.length;
state.notifications.map(notification => {
state._dataset.push({
type: 'notification',
data: notification,
});
});
});
builder.addCase(fetchUnreadCount.pending, state => {
state.unreadNotificationsCount = 0;
});
builder.addCase(fetchUnreadCount.fulfilled, (state, action) => {
state.unreadNotificationsCount = action.payload;
});
builder.addCase(fetchUnreadCount.rejected, state => {
state.unreadNotificationsCount = 0;
});
},
});
export const { toggleDrawer, setNotificationType } = notificationSlice.actions;
export default notificationSlice.reducer;

View File

@@ -0,0 +1,137 @@
// Ant Design Icons
import { BankOutlined, CaretDownFilled, CheckCircleFilled } from '@ant-design/icons';
// Ant Design Components
import { Card, Divider, Dropdown, Flex, Tooltip, Typography } from 'antd';
// Redux Hooks
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
// Redux Actions
import { fetchTeams, setActiveTeam } from '@/features/teams/teamSlice';
import { verifyAuthentication } from '@/features/auth/authSlice';
import { setUser } from '@/features/user/userSlice';
// Hooks & Services
import { useAuthService } from '@/hooks/useAuth';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { createAuthService } from '@/services/auth/auth.service';
// Components
import CustomAvatar from '@/components/CustomAvatar';
// Styles
import { colors } from '@/styles/colors';
import './switchTeam.css';
import { useEffect } from 'react';
const SwitchTeamButton = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const authService = createAuthService(navigate);
const { getCurrentSession } = useAuthService();
const session = getCurrentSession();
const { t } = useTranslation('navbar');
// Selectors
const teamsList = useAppSelector(state => state.teamReducer.teamsList);
const themeMode = useAppSelector(state => state.themeReducer.mode);
useEffect(() => {
dispatch(fetchTeams());
}, [dispatch]);
const isActiveTeam = (teamId: string): boolean => {
if (!teamId || !session?.team_id) return false;
return teamId === session.team_id;
};
const handleVerifyAuth = async () => {
const result = await dispatch(verifyAuthentication()).unwrap();
if (result.authenticated) {
dispatch(setUser(result.user));
authService.setCurrentSession(result.user);
}
};
const handleTeamSelect = async (id: string) => {
if (!id) return;
await dispatch(setActiveTeam(id));
await handleVerifyAuth();
window.location.reload();
};
const renderTeamCard = (team: any, index: number) => (
<Card
className="switch-team-card"
onClick={() => handleTeamSelect(team.id)}
bordered={false}
style={{ width: 230 }}
>
<Flex vertical>
<Flex gap={12} align="center" justify="space-between" style={{ padding: '4px 12px' }}>
<Flex gap={8} align="center">
<CustomAvatar avatarName={team.name || ''} />
<Flex vertical>
<Typography.Text style={{ fontSize: 11, fontWeight: 300 }}>
Owned by {team.owns_by}
</Typography.Text>
<Typography.Text>{team.name}</Typography.Text>
</Flex>
</Flex>
<CheckCircleFilled
style={{
fontSize: 16,
color: isActiveTeam(team.id) ? colors.limeGreen : colors.lightGray,
}}
/>
</Flex>
{index < teamsList.length - 1 && <Divider style={{ margin: 0 }} />}
</Flex>
</Card>
);
const dropdownItems =
teamsList?.map((team, index) => ({
key: team.id || '',
label: renderTeamCard(team, index),
type: 'item' as const,
})) || [];
return (
<Dropdown
overlayClassName="switch-team-dropdown"
menu={{ items: dropdownItems }}
trigger={['click']}
placement="bottomRight"
>
<Tooltip title={t('switchTeamTooltip')} trigger={'hover'}>
<Flex
gap={12}
align="center"
justify="center"
style={{
color: themeMode === 'dark' ? '#e6f7ff' : colors.skyBlue,
backgroundColor: themeMode === 'dark' ? '#153450' : colors.paleBlue,
fontWeight: 500,
borderRadius: '50rem',
padding: '10px 16px',
height: '39px',
cursor: 'pointer',
}}
>
<BankOutlined />
<Typography.Text strong style={{ color: colors.skyBlue, cursor: 'pointer' }}>
{session?.team_name}
</Typography.Text>
<CaretDownFilled />
</Flex>
</Tooltip>
</Dropdown>
);
};
export default SwitchTeamButton;

View File

@@ -0,0 +1,26 @@
.switch-team-dropdown .ant-dropdown-menu {
padding: 0 !important;
border-radius: 12px;
max-height: 255px;
overflow: hidden;
overflow-y: auto;
scrollbar-width: thin;
}
.switch-team-dropdown .ant-dropdown-menu::-webkit-scrollbar {
width: 6px;
}
.switch-team-dropdown .ant-dropdown-menu::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.switch-team-dropdown .ant-dropdown-menu-item {
padding: 0 !important;
transition: background-color 0.2s ease;
}
.switch-team-card .ant-card-body {
padding: 0 !important;
}

View File

@@ -0,0 +1,33 @@
import { Button, Tooltip } from 'antd';
import React from 'react';
import { colors } from '../../../styles/colors';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAppSelector } from '@/hooks/useAppSelector';
const UpgradePlanButton = () => {
// localization
const { t } = useTranslation('navbar');
const navigate = useNavigate();
const themeMode = useAppSelector(state => state.themeReducer.mode);
return (
<Tooltip title={t('upgradePlanTooltip')}>
<Button
style={{
backgroundColor: themeMode === 'dark' ? '#b38750' : colors.lightBeige,
color: '#000000d9',
padding: '4px 11px',
}}
size="small"
type="text"
onClick={() => navigate('/worklenz/admin-center/billing')}
>
{t('upgradePlan')}
</Button>
</Tooltip>
);
};
export default UpgradePlanButton;

View File

@@ -0,0 +1,3 @@
.profile-button:hover .anticon {
color: #1677ff;
}

View File

@@ -0,0 +1,117 @@
import { UserOutlined } from '@ant-design/icons';
import { Button, Card, Dropdown, Flex, MenuProps, Tooltip, Typography } from 'antd';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { RootState } from '@/app/store';
import { getRole } from '@/utils/session-helper';
import './profile-dropdown.css';
import './profile-button.css';
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
import { useAuthService } from '@/hooks/useAuth';
import { useEffect, useState } from 'react';
interface ProfileButtonProps {
isOwnerOrAdmin: boolean;
}
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);
const getLinkStyle = () => ({
color: themeMode === 'dark' ? '#ffffffd9' : '#181818',
});
const profile: MenuProps['items'] = [
{
key: '1',
label: (
<Card
className={`profile-card ${themeMode === 'dark' ? 'dark' : ''}`}
title={
<div style={{ paddingBlock: '16px' }}>
<Typography.Text>Account</Typography.Text>
<Flex gap={8} align="center" justify="flex-start" style={{ width: '100%' }}>
<SingleAvatar
avatarUrl={currentSession?.avatar_url}
name={currentSession?.name}
email={currentSession?.email}
/>
<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' }}
>
{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' }}
>
{currentSession?.email}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
({role})
</Typography.Text>
</Flex>
</Flex>
</div>
}
variant="borderless"
style={{ width: 230 }}
>
{isOwnerOrAdmin && (
<Link to="/worklenz/admin-center/overview" style={getLinkStyle()}>
{t('adminCenter')}
</Link>
)}
<Link to="/worklenz/settings/profile" style={getLinkStyle()}>
{t('settings')}
</Link>
<Link to="/auth/logging-out" style={getLinkStyle()}>
{t('logOut')}
</Link>
</Card>
),
},
];
return (
<Dropdown
overlayClassName="profile-dropdown"
menu={{ items: profile }}
placement="bottomRight"
trigger={['click']}
>
<Tooltip title={t('profileTooltip')}>
<Button
className="profile-button"
style={{ height: '62px', width: '60px' }}
type="text"
icon={
currentSession?.avatar_url ? (
<SingleAvatar
avatarUrl={currentSession.avatar_url}
name={currentSession.name}
email={currentSession.email}
/>
) : (
<UserOutlined style={{ fontSize: 20 }} />
)
}
/>
</Tooltip>
</Dropdown>
);
};
export default ProfileButton;

View File

@@ -0,0 +1,39 @@
.profile-dropdown .ant-dropdown-menu {
padding: 0 !important;
margin-top: 2px !important;
}
.profile-dropdown .ant-dropdown-menu-item {
padding: 0 !important;
}
.profile-card .ant-card-head {
padding: 0 16px;
}
.profile-card .ant-card-body {
padding: 4px 0;
}
.profile-card .ant-card-body a {
display: block;
margin: 0;
padding: 10px 15px;
}
.profile-card .ant-card-body a:hover {
color: #1890ff !important;
background-color: #f8f7f9 !important;
box-sizing: border-box;
}
.profile-card.dark .ant-card-body a:hover {
color: #1890ff !important;
background-color: #424242 !important;
box-sizing: border-box;
}
.profile-card {
border: none !important;
box-shadow: none !important;
}

View File

@@ -0,0 +1,63 @@
import { projectsApiService } from '@/api/projects/projects.api.service';
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
interface IProjectDrawerState {
isProjectDrawerOpen: boolean;
projectId: string | null;
projectLoading: boolean;
project: IProjectViewModel | null;
}
const initialState: IProjectDrawerState = {
isProjectDrawerOpen: false,
projectId: null,
projectLoading: false,
project: null,
};
export const fetchProjectData = createAsyncThunk(
'project/fetchProjectData',
async (projectId: string, { rejectWithValue, dispatch }) => {
try {
const response = await projectsApiService.getProject(projectId);
return response.body;
} catch (error) {
return rejectWithValue(error instanceof Error ? error.message : 'Failed to fetch project');
}
}
);
const projectDrawerSlice = createSlice({
name: 'projectDrawer',
initialState,
reducers: {
toggleProjectDrawer: state => {
state.isProjectDrawerOpen = !state.isProjectDrawerOpen;
},
setProjectId: (state, action) => {
state.projectId = action.payload;
},
setProjectData: (state, action) => {
state.project = action.payload;
},
},
extraReducers: builder => {
builder
.addCase(fetchProjectData.pending, state => {
state.projectLoading = true;
})
.addCase(fetchProjectData.fulfilled, (state, action) => {
state.project = action.payload;
state.projectLoading = false;
})
.addCase(fetchProjectData.rejected, state => {
state.projectLoading = false;
});
},
});
export const { toggleProjectDrawer, setProjectId, setProjectData } = projectDrawerSlice.actions;
export default projectDrawerSlice.reducer;

View File

@@ -0,0 +1,221 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITaskListColumn, ITaskListGroup } from '@/types/tasks/taskList.types';
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
import { ITaskPrioritiesGetResponse } from '@/types/apiModels/taskPrioritiesGetResponse.types';
import { ITaskStatusViewModel } from '@/types/tasks/taskStatusGetResponse.types';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { projectsApiService } from '@/api/projects/projects.api.service';
interface TaskListState {
projectId: string | null;
project: IProjectViewModel | null;
projectLoading: boolean;
columns: ITaskListColumn[];
members: ITeamMemberViewModel[];
activeMembers: [];
labels: ITaskLabel[];
statuses: ITaskStatusViewModel[];
priorities: ITaskPrioritiesGetResponse[];
phases: ITaskPhase[];
groups: ITaskListGroup[];
isSubtasksIncluded: boolean;
selectedTasks: IProjectTask[];
isLoading: boolean;
error: string | null;
importTaskTemplateDrawerOpen: boolean;
createTaskTemplateDrawerOpen: boolean;
projectView: 'list' | 'kanban';
refreshTimestamp: string | null;
}
const initialState: TaskListState = {
projectId: null,
project: null,
projectLoading: false,
activeMembers: [],
columns: [],
members: [],
labels: [],
statuses: [],
priorities: [],
phases: [],
groups: [],
isSubtasksIncluded: false,
selectedTasks: [],
isLoading: false,
error: null,
importTaskTemplateDrawerOpen: false,
createTaskTemplateDrawerOpen: false,
projectView: 'list',
refreshTimestamp: null,
};
export const getProject = createAsyncThunk(
'project/getProject',
async (projectId: string, { rejectWithValue, dispatch }) => {
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');
}
}
);
const projectSlice = createSlice({
name: 'project',
initialState,
reducers: {
setProjectId: (state, action: PayloadAction<string | null>) => {
state.projectId = action.payload;
},
setProject: (state, action: PayloadAction<IProjectViewModel>) => {
state.project = action.payload;
},
setColumns: (state, action: PayloadAction<ITaskListColumn[]>) => {
state.columns = action.payload;
},
setMembers: (state, action: PayloadAction<ITeamMemberViewModel[]>) => {
state.members = action.payload;
},
setLabels: (state, action: PayloadAction<ITaskLabel[]>) => {
state.labels = action.payload;
},
setStatuses: (state, action: PayloadAction<ITaskStatusViewModel[]>) => {
state.statuses = action.payload;
},
setPriorities: (state, action: PayloadAction<ITaskPrioritiesGetResponse[]>) => {
state.priorities = action.payload;
},
setPhases: (state, action: PayloadAction<ITaskPhase[]>) => {
state.phases = action.payload;
},
setGroups: (state, action: PayloadAction<ITaskListGroup[]>) => {
state.groups = action.payload;
},
setSubtasksIncluded: (state, action: PayloadAction<boolean>) => {
state.isSubtasksIncluded = action.payload;
},
setSelectedTasks: (state, action: PayloadAction<IProjectTask[]>) => {
state.selectedTasks = action.payload;
},
setActiveMembers: (state, action: PayloadAction<[]>) => {
state.activeMembers = action.payload;
},
setImportTaskTemplateDrawerOpen: (state, action: PayloadAction<boolean>) => {
state.importTaskTemplateDrawerOpen = action.payload;
},
setCreateTaskTemplateDrawerOpen: (state, action: PayloadAction<boolean>) => {
state.createTaskTemplateDrawerOpen = action.payload;
},
updatePhaseLabel: (state, action: PayloadAction<string>) => {
if (state.project) {
state.project.phase_label = action.payload;
}
},
addTask: (
state,
action: PayloadAction<{ task: IProjectTask; groupId: string; insert?: boolean }>
) => {
const { task, groupId, insert = false } = action.payload;
const group = state.groups.find(g => g.id === groupId);
if (!group || !task.id) return;
if (task.parent_task_id) {
const parentTask = group.tasks.find(t => t.id === task.parent_task_id);
if (parentTask) {
parentTask.sub_tasks_count = (parentTask.sub_tasks_count || 0) + 1;
if (!parentTask.sub_tasks) parentTask.sub_tasks = [];
parentTask.sub_tasks.push(task);
// Add subtask to the main tasks array if subtasks are included
if (state.isSubtasksIncluded) {
const parentIndex = group.tasks.indexOf(parentTask);
if (parentIndex !== -1) {
group.tasks.splice(parentIndex + 1, 0, task);
}
}
}
} else {
insert ? group.tasks.unshift(task) : group.tasks.push(task);
}
console.log('addTask', group.tasks);
},
deleteTask: (state, action: PayloadAction<{ taskId: string; index?: number }>) => {
const { taskId, index } = action.payload;
for (const group of state.groups) {
const taskIndex = index ?? group.tasks.findIndex(t => t.id === taskId);
if (taskIndex === -1) continue;
const task = group.tasks[taskIndex];
if (task.is_sub_task) {
const parentTask = group.tasks.find(t => t.id === task.parent_task_id);
if (parentTask?.sub_tasks) {
const subTaskIndex = parentTask.sub_tasks.findIndex(t => t.id === task.id);
if (subTaskIndex !== -1) {
parentTask.sub_tasks_count = Math.max((parentTask.sub_tasks_count || 0) - 1, 0);
parentTask.sub_tasks.splice(subTaskIndex, 1);
}
}
} else {
group.tasks.splice(taskIndex, 1);
}
break;
}
},
reset: () => initialState,
setRefreshTimestamp: (state) => {
state.refreshTimestamp = new Date().getTime().toString();
},
setProjectView: (state, action: PayloadAction<'list' | 'kanban'>) => {
state.projectView = action.payload;
},
},
extraReducers: builder => {
builder
.addCase(getProject.pending, state => {
state.projectLoading = true;
state.error = null;
})
.addCase(getProject.fulfilled, (state, action) => {
state.projectLoading = false;
state.project = action.payload;
})
.addCase(getProject.rejected, (state, action) => {
state.projectLoading = false;
state.error = action.payload as string;
});
},
});
export const {
setProjectId,
setProject,
setColumns,
setMembers,
setLabels,
setStatuses,
setPriorities,
setPhases,
setGroups,
setSubtasksIncluded,
setSelectedTasks,
setActiveMembers,
addTask,
deleteTask,
reset,
setImportTaskTemplateDrawerOpen,
setCreateTaskTemplateDrawerOpen,
setProjectView,
updatePhaseLabel,
setRefreshTimestamp
} = projectSlice.actions;
export default projectSlice.reducer;

View File

@@ -0,0 +1,117 @@
import {
CloseCircleOutlined,
DeleteOutlined,
InboxOutlined,
MoreOutlined,
RetweetOutlined,
TagsOutlined,
UserAddOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons';
import { Button, Flex, Tooltip, Typography } from 'antd';
import { colors } from '../../../styles/colors';
type BulkTasksActionProps = {
selectedTaskIds: string[];
closeContainer: () => void;
};
const BulkTasksActionContainer = ({ selectedTaskIds, closeContainer }: BulkTasksActionProps) => {
const selectedTasksCount = selectedTaskIds.length;
return (
<Flex
gap={12}
align="center"
justify="space-between"
style={{
position: 'fixed',
left: 0,
right: 0,
height: '50px',
display: 'flex',
alignItems: 'center',
zIndex: 2,
width: 'auto',
marginInline: 24,
background: '#252628',
padding: '8px 24px',
borderRadius: '120px',
bottom: '30px',
minWidth: '420px',
}}
>
<Typography.Text
style={{ color: colors.white }}
>{`${selectedTasksCount} task${selectedTasksCount > 1 ? 's' : ''} selected`}</Typography.Text>
<Flex align="center">
<Tooltip title={'Change Status/ Prioriy/ Phases'}>
<Button
icon={<RetweetOutlined />}
className="borderless-icon-btn"
style={{ background: colors.transparent, color: colors.white }}
/>
</Tooltip>
<Tooltip title={'Change Label'}>
<Button
icon={<TagsOutlined />}
className="borderless-icon-btn"
style={{ background: colors.transparent, color: colors.white }}
/>
</Tooltip>
<Tooltip title={'Assign to me'}>
<Button
icon={<UserAddOutlined />}
className="borderless-icon-btn"
style={{ background: colors.transparent, color: colors.white }}
/>
</Tooltip>
<Tooltip title={'Assign members'}>
<Button
icon={<UsergroupAddOutlined />}
className="borderless-icon-btn"
style={{ background: colors.transparent, color: colors.white }}
/>
</Tooltip>
<Tooltip title={'Archive'}>
<Button
icon={<InboxOutlined />}
className="borderless-icon-btn"
style={{ background: colors.transparent, color: colors.white }}
/>
</Tooltip>
<Tooltip title={'Delete'}>
<Button
icon={<DeleteOutlined />}
className="borderless-icon-btn"
style={{ background: colors.transparent, color: colors.white }}
/>
</Tooltip>
</Flex>
<Tooltip title={'More options'}>
<Button
icon={<MoreOutlined />}
className="borderless-icon-btn"
style={{ background: colors.transparent, color: colors.white }}
/>
</Tooltip>
<Tooltip title={'Deselect all'}>
<Button
icon={<CloseCircleOutlined />}
onClick={closeContainer}
className="borderless-icon-btn"
style={{ background: colors.transparent, color: colors.white }}
/>
</Tooltip>
</Flex>
);
};
export default BulkTasksActionContainer;

View File

@@ -0,0 +1,31 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
type BulkActionState = {
selectedTasks: IProjectTask[];
selectedTaskIdsList: string[];
};
const initialState: BulkActionState = {
selectedTasks: [],
selectedTaskIdsList: [],
};
const bulkActionSlice = createSlice({
name: 'bulkActionReducer',
initialState,
reducers: {
selectTaskIds: (state, action: PayloadAction<string[]>) => {
state.selectedTaskIdsList = action.payload;
},
selectTasks: (state, action: PayloadAction<IProjectTask[]>) => {
state.selectedTasks = action.payload;
},
deselectAll: state => {
state.selectedTaskIdsList = [];
state.selectedTasks = [];
},
},
});
export const { selectTaskIds, selectTasks, deselectAll } = bulkActionSlice.actions;
export default bulkActionSlice.reducer;

View File

@@ -0,0 +1,42 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
type SegmentType = 'Overview' | 'Members' | 'Tasks';
type ProjectInsightsState = {
initialized: boolean;
loading: boolean;
activeSegment: SegmentType;
includeArchivedTasks: boolean;
projectId: string;
};
const initialState: ProjectInsightsState = {
initialized: false,
loading: false,
activeSegment: 'Overview',
includeArchivedTasks: false,
projectId: '',
};
const projectInsightsSlice = createSlice({
name: 'projectInsights',
initialState,
reducers: {
setActiveSegment: (state, action: PayloadAction<SegmentType>) => {
state.activeSegment = action.payload;
},
setIncludeArchivedTasks: (state, action: PayloadAction<boolean>) => {
state.includeArchivedTasks = action.payload;
},
setProjectId: (state, action: PayloadAction<string>) => {
state.projectId = action.payload;
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
},
});
export const { setActiveSegment, setIncludeArchivedTasks, setLoading, setProjectId } =
projectInsightsSlice.actions;
export default projectInsightsSlice.reducer;

View File

@@ -0,0 +1,159 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import logger from '@/utils/errorLogger';
import { categoriesApiService } from '@api/settings/categories/categories.api.service';
import { IProjectCategoryViewModel } from '@/types/project/projectCategory.types';
import { setCategories } from '../../projectsSlice';
type ProjectCategoryState = {
initialized: boolean;
projectCategories: IProjectCategoryViewModel[];
loading: boolean;
};
// Async thunks
export const fetchProjectCategories = createAsyncThunk(
'projectCategories/fetchAll',
async (_, { rejectWithValue }) => {
try {
const response = await categoriesApiService.getCategories();
return response.body;
} catch (error) {
logger.error('Fetch Project Categories', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to fetch project categories');
}
}
);
export const createProjectCategory = createAsyncThunk(
'projectCategories/create',
async (category: Partial<IProjectCategoryViewModel>, { rejectWithValue }) => {
try {
const response = await categoriesApiService.createCategory(category);
return response.body;
} catch (error) {
logger.error('Create Project Category', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to create project category');
}
}
);
export const updateProjectCategory = createAsyncThunk(
'projectCategories/update',
async (category: IProjectCategoryViewModel, { rejectWithValue }) => {
try {
const response = await categoriesApiService.updateCategory(category);
return response.body;
} catch (error) {
logger.error('Update Project Category', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to update project category');
}
}
);
export const deleteProjectCategory = createAsyncThunk(
'projectCategories/delete',
async (id: string, { rejectWithValue }) => {
try {
await categoriesApiService.deleteCategory(id);
return id;
} catch (error) {
logger.error('Delete Project Category', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to delete project category');
}
}
);
const initialState: ProjectCategoryState = {
projectCategories: [],
initialized: false,
loading: false,
};
const projectCategoriesSlice = createSlice({
name: 'projectCategories',
initialState,
reducers: {
searchCategories: (state, action: PayloadAction<string>) => {
// If needed, implement local search filtering here
// This is useful if you want to filter already loaded categories client-side
},
addCategory: (state, action: PayloadAction<IProjectCategoryViewModel>) => {
state.projectCategories.push(action.payload);
},
deleteCategory: (state, action: PayloadAction<string>) => {
state.projectCategories = state.projectCategories.filter(cat => cat.id !== action.payload);
},
},
extraReducers: builder => {
// Fetch categories
builder
.addCase(fetchProjectCategories.pending, state => {
state.loading = true;
})
.addCase(fetchProjectCategories.fulfilled, (state, action) => {
state.projectCategories = action.payload;
state.loading = false;
state.initialized = true;
})
.addCase(fetchProjectCategories.rejected, state => {
state.loading = false;
});
// Create category
builder
.addCase(createProjectCategory.pending, state => {
state.loading = true;
})
.addCase(createProjectCategory.fulfilled, (state, action) => {
state.projectCategories.push(action.payload);
state.loading = false;
})
.addCase(createProjectCategory.rejected, state => {
state.loading = false;
});
// Update category
builder
.addCase(updateProjectCategory.pending, state => {
state.loading = true;
})
.addCase(updateProjectCategory.fulfilled, (state, action) => {
const index = state.projectCategories.findIndex(cat => cat.id === action.payload.id);
if (index !== -1) {
state.projectCategories[index] = action.payload;
}
state.loading = false;
})
.addCase(updateProjectCategory.rejected, state => {
state.loading = false;
});
// Delete category
builder
.addCase(deleteProjectCategory.pending, state => {
state.loading = true;
})
.addCase(deleteProjectCategory.fulfilled, (state, action) => {
state.projectCategories = state.projectCategories.filter(cat => cat.id !== action.payload);
state.loading = false;
})
.addCase(deleteProjectCategory.rejected, state => {
state.loading = false;
});
},
});
export const { searchCategories, addCategory, deleteCategory } = projectCategoriesSlice.actions;
export default projectCategoriesSlice.reducer;

View File

@@ -0,0 +1,55 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import logger from '@/utils/errorLogger';
import { IProjectHealth } from '@/types/project/projectHealth.types';
import { projectHealthApiService } from '@/api/projects/lookups/projectHealth.api.service';
type ProjectHealthState = {
initialized: boolean;
projectHealths: IProjectHealth[];
loading: boolean;
};
// Async thunk
export const fetchProjectHealth = createAsyncThunk(
'projectHealth/fetchAll',
async (_, { rejectWithValue }) => {
try {
const response = await projectHealthApiService.getHealthOptions();
return response.body;
} catch (error) {
logger.error('Fetch Project Health Options', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to fetch project statuses');
}
}
);
const initialState: ProjectHealthState = {
projectHealths: [],
initialized: false,
loading: false,
};
const projectHealthSlice = createSlice({
name: 'projectHealth',
initialState,
reducers: {},
extraReducers: builder => {
builder
.addCase(fetchProjectHealth.pending, state => {
state.loading = true;
})
.addCase(fetchProjectHealth.fulfilled, (state, action) => {
state.projectHealths = action.payload;
state.loading = false;
state.initialized = true;
})
.addCase(fetchProjectHealth.rejected, state => {
state.loading = false;
});
},
});
export default projectHealthSlice.reducer;

View File

@@ -0,0 +1,55 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import logger from '@/utils/errorLogger';
import { projectStatusesApiService } from '@/api/projects/lookups/projectStatus.api.service';
import { IProjectStatus } from '@/types/project/projectStatus.types';
type ProjectStatusState = {
initialized: boolean;
projectStatuses: IProjectStatus[];
loading: boolean;
};
// Async thunk
export const fetchProjectStatuses = createAsyncThunk(
'projectStatuses/fetchAll',
async (_, { rejectWithValue }) => {
try {
const response = await projectStatusesApiService.getStatuses();
return response.body;
} catch (error) {
logger.error('Fetch Project Statuses', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to fetch project statuses');
}
}
);
const initialState: ProjectStatusState = {
projectStatuses: [],
initialized: false,
loading: false,
};
const projectStatusesSlice = createSlice({
name: 'projectStatuses',
initialState,
reducers: {},
extraReducers: builder => {
builder
.addCase(fetchProjectStatuses.pending, state => {
state.loading = true;
})
.addCase(fetchProjectStatuses.fulfilled, (state, action) => {
state.projectStatuses = action.payload;
state.loading = false;
state.initialized = true;
})
.addCase(fetchProjectStatuses.rejected, state => {
state.loading = false;
});
},
});
export default projectStatusesSlice.reducer;

View File

@@ -0,0 +1,53 @@
import { createSlice } from '@reduxjs/toolkit';
interface priority {
id: string;
name: string;
category: string;
}
interface priorityState {
priority: priority[];
}
const initialState: priorityState = {
priority: [
{
id: '1',
name: 'Low',
category: 'low',
},
{
id: '2',
name: 'Medium',
category: 'medium',
},
{
id: '3',
name: 'High',
category: 'high',
},
],
};
const prioritySlice = createSlice({
name: 'priorityReducer',
initialState,
reducers: {
addPriority: (state, action) => {
state.priority.push(action.payload);
},
updatePriorityCategory: (state, action) => {
const priority = state.priority.find(priority => priority.id === action.payload.id);
if (priority) {
priority.category = action.payload.category;
}
},
deletePriority: (state, action) => {
state.priority = state.priority.filter(priority => priority.id !== action.payload);
},
},
});
export const { addPriority, updatePriorityCategory, deletePriority } = prioritySlice.actions;
export default prioritySlice.reducer;

View File

@@ -0,0 +1,252 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { projectsApiService } from '@/api/projects/projects.api.service';
import logger from '@/utils/errorLogger';
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';
interface ProjectState {
projects: {
data: IProjectViewModel[];
total: number;
};
categories: IProjectCategory[];
loading: boolean;
creatingProject: boolean;
initialized: boolean;
isProjectDrawerOpen: boolean;
isSaveAsTemplateDrawerOpen: boolean;
filteredCategories: string[];
filteredStatuses: string[];
requestParams: {
index: number;
size: number;
field: string;
order: string;
search: string;
filter: number;
statuses: string | null;
categories: string | null;
};
projectManagers: IProjectManager[];
projectManagersLoading: boolean;
}
const initialState: ProjectState = {
projects: {
data: [],
total: 0,
},
categories: [],
loading: false,
creatingProject: false,
initialized: false,
isProjectDrawerOpen: false,
isSaveAsTemplateDrawerOpen: false,
filteredCategories: [],
filteredStatuses: [],
requestParams: {
index: 1,
size: DEFAULT_PAGE_SIZE,
field: 'name',
order: 'ascend',
search: '',
filter: 0,
statuses: null,
categories: null,
},
projectManagers: [],
projectManagersLoading: false,
};
// Create async thunk for fetching teams
export const fetchProjects = createAsyncThunk(
'projects/fetchProjects',
async (
params: {
index: number;
size: number;
field: string;
order: string;
search: string;
filter: number;
statuses: string | null;
categories: string | null;
},
{ rejectWithValue }
) => {
try {
const projectsResponse = await projectsApiService.getProjects(
params.index,
params.size,
params.field,
params.order,
params.search,
params.filter,
params.statuses,
params.categories
);
return projectsResponse.body;
} catch (error) {
logger.error('Fetch Projects', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to fetch projects');
}
}
);
export const toggleFavoriteProject = createAsyncThunk(
'projects/toggleFavoriteProject',
async (id: string, { rejectWithValue }) => {
try {
const response = await projectsApiService.toggleFavoriteProject(id);
return response.body;
} catch (error) {
logger.error('Toggle Favorite Project', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
}
}
);
export const createProject = createAsyncThunk(
'projects/createProject',
async (project: IProjectViewModel, { rejectWithValue }) => {
try {
const response = await projectsApiService.createProject(project);
return response.body;
} catch (error) {
logger.error('Create Project', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
}
}
);
export const updateProject = createAsyncThunk(
'projects/updateProject',
async ({ id, project }: { id: string; project: IProjectViewModel }, { rejectWithValue }) => {
const response = await projectsApiService.updateProject(id, project);
return response.body;
}
);
export const deleteProject = createAsyncThunk(
'projects/deleteProject',
async (id: string, { rejectWithValue }) => {
const response = await projectsApiService.deleteProject(id);
return response.body;
}
);
export const toggleArchiveProject = createAsyncThunk(
'projects/toggleArchiveProject',
async (id: string, { rejectWithValue }) => {
const response = await projectsApiService.toggleArchiveProject(id);
return response.body;
}
);
export const toggleArchiveProjectForAll = createAsyncThunk(
'projects/toggleArchiveProjectForAll',
async (id: string, { rejectWithValue }) => {
const response = await projectsApiService.toggleArchiveProjectForAll(id);
return response.body;
}
);
export const fetchProjectManagers = createAsyncThunk(
'projects/fetchProjectManagers',
async (_, { rejectWithValue }) => {
const response = await projectsApiService.getProjectManagers();
return response.body;
}
);
const projectSlice = createSlice({
name: 'projectReducer',
initialState,
reducers: {
toggleDrawer: state => {
state.isProjectDrawerOpen = !state.isProjectDrawerOpen;
},
toggleSaveAsTemplateDrawer: state => {
state.isSaveAsTemplateDrawerOpen = !state.isSaveAsTemplateDrawerOpen;
},
createProject: (state, action: PayloadAction<IProjectViewModel>) => {
state.creatingProject = true;
},
deleteProject: (state, action: PayloadAction<string>) => {},
setCategories: (state, action: PayloadAction<IProjectCategory[]>) => {
state.categories = action.payload;
},
setFilteredCategories: (state, action: PayloadAction<string[]>) => {
state.filteredCategories = action.payload;
},
setFilteredStatuses: (state, action: PayloadAction<string[]>) => {
state.filteredStatuses = action.payload;
},
setRequestParams: (state, action: PayloadAction<Partial<ProjectState['requestParams']>>) => {
state.requestParams = {
...state.requestParams,
...action.payload,
};
},
},
extraReducers: builder => {
builder
.addCase(fetchProjects.pending, state => {
state.loading = true;
})
.addCase(fetchProjects.fulfilled, (state, action) => {
state.loading = false;
state.projects = {
data: action.payload?.data || [],
total: action.payload?.total || 0,
};
state.initialized = true;
})
.addCase(fetchProjects.rejected, state => {
state.loading = false;
})
.addCase(createProject.pending, state => {
state.creatingProject = true;
})
.addCase(createProject.fulfilled, state => {
state.creatingProject = false;
})
.addCase(createProject.rejected, state => {
state.creatingProject = false;
})
.addCase(toggleArchiveProject.fulfilled, state => {
state.loading = false;
})
.addCase(toggleArchiveProjectForAll.fulfilled, state => {
state.loading = false;
})
.addCase(fetchProjectManagers.pending, state => {
state.projectManagersLoading = true;
})
.addCase(fetchProjectManagers.fulfilled, (state, action) => {
state.projectManagersLoading = false;
state.projectManagers = action.payload;
})
.addCase(fetchProjectManagers.rejected, state => {
state.projectManagersLoading = false;
});
},
});
export const {
toggleDrawer,
toggleSaveAsTemplateDrawer,
setCategories,
setFilteredCategories,
setFilteredStatuses,
setRequestParams,
} = projectSlice.actions;
export default projectSlice.reducer;

View File

@@ -0,0 +1,150 @@
import { projectMembersApiService } from '@/api/project-members/project-members.api.service';
import { projectsApiService } from '@/api/projects/projects.api.service';
import { IMentionMemberViewModel } from '@/types/project/projectComments.types';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
interface ProjectMembersState {
membersList: IMentionMemberViewModel[];
currentMembersList: IMentionMemberViewModel[];
isDrawerOpen: boolean;
isLoading: boolean;
error: string | null;
}
const initialState: ProjectMembersState = {
membersList: [],
currentMembersList: [],
isDrawerOpen: false,
isLoading: false,
error: null,
};
const getProjectMembers = createAsyncThunk(
'projectMembers/getProjectMembers',
async (params: {
projectId: string;
index: number;
size: number;
field: string;
order: string;
search: string | null;
}) => {
const { projectId, index, size, field, order, search } = params;
const response = await projectsApiService.getMembers(
projectId,
index,
size,
field,
order,
search
);
if (!response.done) {
throw new Error('Failed to fetch project members');
}
return response.body;
}
);
const getAllProjectMembers = createAsyncThunk(
'projectMembers/getAllProjectMembers',
async (projectId: string) => {
const response = await projectMembersApiService.getByProjectId(projectId);
return response.body;
}
);
const deleteProjectMember = createAsyncThunk(
'projectMembers/deleteProjectMember',
async (params: { memberId: string; projectId: string }) => {
const { memberId, projectId } = params;
const response = await projectMembersApiService.deleteProjectMember(memberId, projectId);
return response;
}
);
const addProjectMember = createAsyncThunk(
'projectMembers/addProjectMember',
async (params: { memberId: string; projectId: string }) => {
const { memberId, projectId } = params;
const response = await projectMembersApiService.createProjectMember({
project_id: projectId,
team_member_id: memberId,
});
return response;
}
);
const createByEmail = createAsyncThunk(
'projectMembers/createByEmail',
async (params: { email: string; project_id: string }) => {
const response = await projectMembersApiService.createByEmail(params);
return response;
}
);
const projectMembersSlice = createSlice({
name: 'projectMembers',
initialState,
reducers: {
toggleProjectMemberDrawer: state => {
state.isDrawerOpen = !state.isDrawerOpen;
},
},
extraReducers: builder => {
builder
.addCase(getProjectMembers.pending, state => {
state.isLoading = true;
state.error = null;
})
.addCase(getProjectMembers.fulfilled, (state, action) => {
state.membersList = action.payload as IMentionMemberViewModel[];
state.isLoading = false;
state.error = null;
})
.addCase(getProjectMembers.rejected, (state, action) => {
state.membersList = [];
state.isLoading = false;
state.error = action.error.message || 'Failed to fetch members';
})
.addCase(getAllProjectMembers.pending, state => {
state.isLoading = true;
state.error = null;
})
.addCase(getAllProjectMembers.fulfilled, (state, action) => {
state.currentMembersList = action.payload as IMentionMemberViewModel[];
state.isLoading = false;
state.error = null;
})
.addCase(getAllProjectMembers.rejected, (state, action) => {
state.currentMembersList = [];
state.isLoading = false;
state.error = action.error.message || 'Failed to fetch members';
})
.addCase(deleteProjectMember.pending, state => {
state.isLoading = true;
state.error = null;
})
.addCase(deleteProjectMember.fulfilled, (state, action) => {
state.currentMembersList = state.currentMembersList.filter(
member => member.id !== action.payload.body.id
);
state.isLoading = false;
state.error = null;
})
.addCase(deleteProjectMember.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message || 'Failed to delete member';
});
},
});
export const { toggleProjectMemberDrawer } = projectMembersSlice.actions;
export {
getProjectMembers,
getAllProjectMembers,
deleteProjectMember,
addProjectMember,
createByEmail,
};
export default projectMembersSlice.reducer;

View File

@@ -0,0 +1,35 @@
import { SettingOutlined } from '@ant-design/icons';
import { Button, Tooltip } from 'antd';
import React from 'react';
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
import { toggleDrawer } from './phases.slice';
import { useAppSelector } from '../../../../hooks/useAppSelector';
import { colors } from '../../../../styles/colors';
import { useTranslation } from 'react-i18next';
const ConfigPhaseButton = () => {
// get theme details from redux
const themeMode = useAppSelector(state => state.themeReducer.mode);
// localization
const { t } = useTranslation('task-list-filters');
const dispatch = useAppDispatch();
return (
<Tooltip title={t('configPhaseButtonTooltip')}>
<Button
className="borderless-icon-btn"
style={{ backgroundColor: colors.transparent, boxShadow: 'none' }}
onClick={() => dispatch(toggleDrawer())}
icon={
<SettingOutlined
style={{ color: themeMode === 'dark' ? colors.white : colors.skyBlue }}
/>
}
/>
</Tooltip>
);
};
export default ConfigPhaseButton;

View File

@@ -0,0 +1,209 @@
import { Button, Drawer, Flex, Input, Skeleton, Spin, Typography } from 'antd';
import { useState } from 'react';
import { useAppSelector } from '../../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
import {
addPhaseOption,
fetchPhasesByProjectId,
toggleDrawer,
updatePhaseOrder,
updatePhaseListOrder,
updateProjectPhaseLabel,
} from './phases.slice';
import { Divider } from 'antd/lib';
import { PlusOutlined } from '@ant-design/icons';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import PhaseOptionItem from './PhaseOptionItem';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import logger from '@/utils/errorLogger';
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';
interface UpdateSortOrderBody {
from_index: number;
to_index: number;
phases: ITaskPhase[];
project_id: string;
}
const PhaseDrawer = () => {
const { t } = useTranslation('phases-drawer');
const { tab } = useTabSearchParam();
const isDrawerOpen = useAppSelector(state => state.phaseReducer.isPhaseDrawerOpen);
const dispatch = useAppDispatch();
const { projectId } = useParams();
const { project } = useAppSelector(state => state.projectReducer);
const [phaseName, setPhaseName] = useState<string>(project?.phase_label || '');
const [initialPhaseName, setInitialPhaseName] = useState<string>(project?.phase_label || '');
const { phaseList, loadingPhases } = useAppSelector(state => state.phaseReducer);
const [sorting, setSorting] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const refreshTasks = async () => {
if (tab === 'tasks-list') {
await dispatch(fetchTaskGroups(projectId || ''));
} else if (tab === 'board') {
await dispatch(fetchBoardTaskGroups(projectId || ''));
}
};
const handleAddOptions = async () => {
if (!projectId) return;
await dispatch(addPhaseOption({ projectId: projectId }));
await dispatch(fetchPhasesByProjectId(projectId));
await refreshTasks();
};
const handleDragEnd = async (event: DragEndEvent) => {
if (!projectId) return;
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = phaseList.findIndex(item => item.id === active.id);
const newIndex = phaseList.findIndex(item => item.id === over.id);
const newPhaseList = arrayMove(phaseList, oldIndex, newIndex);
try {
setSorting(true);
dispatch(updatePhaseListOrder(newPhaseList));
const body: UpdateSortOrderBody = {
from_index: oldIndex,
to_index: newIndex,
phases: newPhaseList,
project_id: projectId,
};
// Update the sort order
await dispatch(
updatePhaseOrder({
projectId: projectId,
body,
})
).unwrap();
await refreshTasks();
} catch (error) {
// If there's an error, revert back to the server state
dispatch(fetchPhasesByProjectId(projectId));
logger.error('Error updating phase order', error);
} finally {
setSorting(false);
}
}
};
const handlePhaseNameBlur = async () => {
if (!projectId || phaseName === initialPhaseName) return;
try {
setIsSaving(true);
const res = await dispatch(
updateProjectPhaseLabel({ projectId: projectId || '', phaseLabel: phaseName })
).unwrap();
if (res.done) {
dispatch(updatePhaseLabel(phaseName));
setInitialPhaseName(phaseName);
}
} catch (error) {
logger.error('Error updating phase name', error);
} finally {
setIsSaving(false);
}
};
return (
<Drawer
open={isDrawerOpen}
onClose={() => dispatch(toggleDrawer())}
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{t('configurePhases')}
</Typography.Text>
}
afterOpenChange={() => {
dispatch(fetchPhasesByProjectId(projectId || ''));
setPhaseName(project?.phase_label || '');
setInitialPhaseName(project?.phase_label || '');
}}
>
<Flex vertical gap={8}>
<Typography.Text>{t('phaseLabel')}</Typography.Text>
<Input
placeholder={t('enterPhaseName')}
value={phaseName}
onChange={e => setPhaseName(e.currentTarget.value)}
onPressEnter={handlePhaseNameBlur}
onBlur={handlePhaseNameBlur}
disabled={isSaving}
/>
</Flex>
<Divider style={{ marginBlock: 24 }} />
<Flex vertical gap={16}>
<Flex align="center" justify="space-between">
<Typography.Text>{t('phaseOptions')}</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddOptions}>
{t('addOption')}
</Button>
</Flex>
<Spin spinning={loadingPhases || sorting}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis]}
>
<SortableContext
items={phaseList.map(item => item.id)}
strategy={verticalListSortingStrategy}
>
<Flex vertical gap={16}>
{phaseList.map(option => (
<PhaseOptionItem
key={option.id}
option={option}
projectId={projectId || ''}
t={t}
/>
))}
</Flex>
</SortableContext>
</DndContext>
</Spin>
</Flex>
</Drawer>
);
};
export default PhaseDrawer;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { useSelectedProject } from '../../../../hooks/useSelectedProject';
import { useAppSelector } from '../../../../hooks/useAppSelector';
import { Flex } from 'antd';
import ConfigPhaseButton from './ConfigPhaseButton';
import { colors } from '../../../../styles/colors';
import { useTranslation } from 'react-i18next';
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);
return (
<Flex align="center" justify="space-between">
{phase?.phase || t('phasesText')}
<ConfigPhaseButton color={colors.darkGray} />
</Flex>
);
};
export default PhaseHeader;

View File

@@ -0,0 +1,136 @@
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 { PhaseColorCodes } from '@/shared/constants';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { TFunction } from 'i18next';
import logger from '@/utils/errorLogger';
import { useState, useEffect, useCallback } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { fetchBoardTaskGroups } from '@/features/board/board-slice';
import useTabSearchParam from '@/hooks/useTabSearchParam';
import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
interface PhaseOptionItemProps {
option: ITaskPhase | null;
projectId: string | null;
t: TFunction;
}
const PhaseOptionItem = ({ option, projectId, t }: PhaseOptionItemProps) => {
const [color, setColor] = useState(option?.color_code || PhaseColorCodes[0]);
const [phaseName, setPhaseName] = useState(option?.name || '');
const dispatch = useAppDispatch();
const { projectView } = useTabSearchParam();
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: option?.id || 'temp-id'
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
useEffect(() => {
if (option) {
setPhaseName(option.name);
setColor(option.color_code);
}
}, [option]);
const refreshTasks = useCallback(() => {
if (!projectId) return;
const fetchAction = projectView === 'list' ? fetchTaskGroups : fetchBoardTaskGroups;
dispatch(fetchAction(projectId));
}, [projectId, projectView, dispatch]);
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();
if (response.done) {
dispatch(fetchPhasesByProjectId(projectId));
refreshTasks();
}
} catch (error) {
logger.error('Error updating phase name', error);
setPhaseName(option.name);
}
};
const handleDeletePhaseOption = async () => {
if (!option?.id || !projectId) return;
try {
const response = await dispatch(
deletePhaseOption({ phaseOptionId: option.id, projectId })
).unwrap();
if (response.done) {
dispatch(fetchPhasesByProjectId(projectId));
refreshTasks();
}
} catch (error) {
logger.error('Error deleting phase option', error);
}
};
const handleColorChange = async () => {
if (!projectId || !option) return;
try {
const updatedPhase = { ...option, color_code: color };
const response = await dispatch(updatePhaseColor({ projectId, body: updatedPhase })).unwrap();
if (response.done) {
dispatch(fetchPhasesByProjectId(projectId));
refreshTasks();
}
} catch (error) {
logger.error('Error changing phase color', error);
}
};
return (
<ConfigProvider wave={{ disabled: true }}>
<div ref={setNodeRef} style={style} {...attributes}>
<Flex key={option?.id || nanoid()} align="center" gap={8}>
<div {...listeners} style={{ cursor: 'grab' }}>
<HolderOutlined />
</div>
<Input
type="text"
value={phaseName}
onChange={(e) => setPhaseName(e.target.value)}
onBlur={handlePhaseNameChange}
onPressEnter={(e) => e.currentTarget.blur()}
placeholder={t('enterPhaseName')}
/>
<ColorPicker
onChange={(value) => setColor(value.toHexString())}
onChangeComplete={handleColorChange}
value={color}
/>
<Button
className="borderless-icon-btn"
icon={<CloseCircleOutlined />}
onClick={handleDeletePhaseOption}
/>
</Flex>
</div>
</ConfigProvider>
);
};
export default PhaseOptionItem;

View File

@@ -0,0 +1,143 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
type PhaseState = {
isPhaseDrawerOpen: boolean;
phaseList: ITaskPhase[];
loadingPhases: boolean;
};
const initialState: PhaseState = {
isPhaseDrawerOpen: false,
phaseList: [],
loadingPhases: false,
};
export const addPhaseOption = createAsyncThunk(
'phase/addPhaseOption',
async ({ projectId }: { projectId: string }, { rejectWithValue }) => {
try {
const response = await phasesApiService.addPhaseOption(projectId);
return response;
} catch (error) {
return rejectWithValue(error);
}
}
);
export const fetchPhasesByProjectId = createAsyncThunk(
'phase/fetchPhasesByProjectId',
async (projectId: string, { rejectWithValue }) => {
try {
const response = await phasesApiService.getPhasesByProjectId(projectId);
return response;
} catch (error) {
return rejectWithValue(error);
}
}
);
export const deletePhaseOption = createAsyncThunk(
'phase/deletePhaseOption',
async ({ phaseOptionId, projectId }: { phaseOptionId: string; projectId: string }, { rejectWithValue }) => {
try {
const response = await phasesApiService.deletePhaseOption(phaseOptionId, projectId);
return response;
} catch (error) {
return rejectWithValue(error);
}
}
);
export const updatePhaseColor = createAsyncThunk(
'phase/updatePhaseColor',
async ({ projectId, body }: { projectId: string; body: ITaskPhase }, { rejectWithValue }) => {
try {
const response = await phasesApiService.updatePhaseColor(projectId, body);
return response;
} catch (error) {
return rejectWithValue(error);
}
}
);
export const updatePhaseOrder = createAsyncThunk(
'phases/updatePhaseOrder',
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);
return response;
} catch (error) {
throw error;
}
}
);
export const updateProjectPhaseLabel = createAsyncThunk(
'phase/updateProjectPhaseLabel',
async ({ projectId, phaseLabel }: { projectId: string; phaseLabel: string }, { rejectWithValue }) => {
try {
const response = await phasesApiService.updateProjectPhaseLabel(projectId, phaseLabel);
return response;
} catch (error) {
return rejectWithValue(error);
}
}
);
export const updatePhaseName = createAsyncThunk(
'phase/updatePhaseName',
async ({ phaseId, phase, projectId }: { phaseId: string; phase: ITaskPhase; projectId: string }, { rejectWithValue }) => {
try {
const response = await phasesApiService.updateNameOfPhase(phaseId, phase, projectId);
return response;
} catch (error) {
return rejectWithValue(error);
}
}
);
const phaseSlice = createSlice({
name: 'phaseReducer',
initialState,
reducers: {
toggleDrawer: state => {
state.isPhaseDrawerOpen = !state.isPhaseDrawerOpen;
},
updatePhaseListOrder: (state, action: PayloadAction<ITaskPhase[]>) => {
state.phaseList = action.payload;
},
},
extraReducers: builder => {
builder.addCase(fetchPhasesByProjectId.fulfilled, (state, action) => {
state.phaseList = action.payload.body;
state.loadingPhases = false;
});
builder.addCase(fetchPhasesByProjectId.pending, state => {
state.loadingPhases = true;
});
builder.addCase(fetchPhasesByProjectId.rejected, state => {
state.loadingPhases = false;
});
builder.addCase(updatePhaseOrder.pending, (state) => {
state.loadingPhases = true;
});
builder.addCase(updatePhaseOrder.fulfilled, (state, action) => {
state.loadingPhases = false;
});
builder.addCase(updatePhaseOrder.rejected, (state) => {
state.loadingPhases = false;
});
},
});
export const { toggleDrawer, updatePhaseListOrder } = phaseSlice.actions;
export default phaseSlice.reducer;

View File

@@ -0,0 +1,118 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { CustomTableColumnsType } from '../taskListColumns/taskColumnsSlice';
import { LabelType } from '../../../../types/label.type';
import { SelectionType } from '../../../../pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/selection-type-column/selection-type-column';
export type CustomFieldsTypes =
| 'people'
| 'number'
| 'date'
| 'selection'
| 'checkbox'
| 'labels'
| 'key'
| 'formula';
export type CustomFieldNumberTypes = 'formatted' | 'unformatted' | 'percentage' | 'withLabel';
export type ExpressionType = 'add' | 'substract' | 'divide' | 'multiply';
type TaskListCustomColumnsState = {
isCustomColumnModalOpen: boolean;
customColumnModalType: 'create' | 'edit';
customColumnId: string | null;
customFieldType: CustomFieldsTypes;
customFieldNumberType: CustomFieldNumberTypes;
decimals: number;
label: string;
labelPosition: 'left' | 'right';
previewValue: number;
expression: ExpressionType;
firstNumericColumn: CustomTableColumnsType | null;
secondNumericColumn: CustomTableColumnsType | null;
labelsList: LabelType[];
selectionsList: SelectionType[];
};
const initialState: TaskListCustomColumnsState = {
isCustomColumnModalOpen: false,
customColumnModalType: 'create',
customColumnId: null,
customFieldType: 'people',
customFieldNumberType: 'formatted',
decimals: 0,
label: 'LKR',
labelPosition: 'left',
previewValue: 100,
expression: 'add',
firstNumericColumn: null,
secondNumericColumn: null,
labelsList: [],
selectionsList: [],
};
const taskListCustomColumnsSlice = createSlice({
name: 'taskListCustomColumnsReducer',
initialState,
reducers: {
toggleCustomColumnModalOpen: (state, action: PayloadAction<boolean>) => {
state.isCustomColumnModalOpen = action.payload;
},
setCustomColumnModalAttributes: (state, action: PayloadAction<{modalType: 'create' | 'edit', columnId: string | null}>) => {
state.customColumnModalType = action.payload.modalType;
state.customColumnId = action.payload.columnId;
},
setCustomFieldType: (state, action: PayloadAction<CustomFieldsTypes>) => {
state.customFieldType = action.payload;
},
setCustomFieldNumberType: (state, action: PayloadAction<CustomFieldNumberTypes>) => {
state.customFieldNumberType = action.payload;
},
setDecimals: (state, action: PayloadAction<number>) => {
state.decimals = action.payload;
},
setLabel: (state, action: PayloadAction<string>) => {
state.label = action.payload;
},
setLabelPosition: (state, action: PayloadAction<'left' | 'right'>) => {
state.labelPosition = action.payload;
},
setExpression: (state, action: PayloadAction<ExpressionType>) => {
state.expression = action.payload;
},
setFirstNumericColumn: (state, action: PayloadAction<CustomTableColumnsType>) => {
state.firstNumericColumn = action.payload;
},
setSecondNumericColumn: (state, action: PayloadAction<CustomTableColumnsType>) => {
state.secondNumericColumn = action.payload;
},
setLabelsList: (state, action: PayloadAction<LabelType[]>) => {
state.labelsList = action.payload;
},
setSelectionsList: (state, action: PayloadAction<SelectionType[]>) => {
state.selectionsList = action.payload;
},
resetCustomFieldValues: state => {
state = initialState;
},
},
});
export const {
toggleCustomColumnModalOpen,
setCustomColumnModalAttributes,
setCustomFieldType,
setCustomFieldNumberType,
setDecimals,
setLabel,
setLabelPosition,
setExpression,
setFirstNumericColumn,
setSecondNumericColumn,
setLabelsList,
setSelectionsList,
resetCustomFieldValues,
} = taskListCustomColumnsSlice.actions;
export default taskListCustomColumnsSlice.reducer;

View File

@@ -0,0 +1,194 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import React, { ReactNode } from 'react';
import PhaseHeader from '../phase/PhaseHeader';
import AddCustomColumnButton from '../../../../pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/add-custom-column-button';
export type CustomTableColumnsType = {
id?: string;
key: string; // this key identify each column uniquely
name: string; // this name show the name of the column. this name is used when custom column generated, show in fields filter
columnHeader: ReactNode | null; // this column header used to render the actual column title
width: number;
isVisible: boolean;
custom_column?: boolean;
custom_column_obj?: any; // this object include specific values that are generated based on custom column types
};
export type projectViewTaskListColumnsState = {
columnList: CustomTableColumnsType[];
};
const initialState: projectViewTaskListColumnsState = {
columnList: [
{
key: 'taskId',
name: 'key',
columnHeader: 'key',
width: 20,
isVisible: false,
},
{
key: 'task',
name: 'task',
columnHeader: 'task',
width: 320,
isVisible: true,
},
{
key: 'description',
name: 'description',
columnHeader: 'description',
width: 200,
isVisible: false,
},
{
key: 'progress',
name: 'progress',
columnHeader: 'progress',
width: 60,
isVisible: false,
},
{
key: 'members',
name: 'members',
columnHeader: 'members',
width: 150,
isVisible: true,
},
{
key: 'labels',
name: 'labels',
columnHeader: 'labels',
width: 150,
isVisible: false,
},
{
key: 'phases',
name: 'phases',
columnHeader: React.createElement(PhaseHeader),
width: 150,
isVisible: false,
},
{
key: 'status',
name: 'status',
columnHeader: 'status',
width: 120,
isVisible: true,
},
{
key: 'priority',
name: 'priority',
columnHeader: 'priority',
width: 120,
isVisible: true,
},
{
key: 'timeTracking',
name: 'timeTracking',
columnHeader: 'timeTracking',
width: 150,
isVisible: false,
},
{
key: 'estimation',
name: 'estimation',
columnHeader: 'estimation',
width: 150,
isVisible: false,
},
{
key: 'startDate',
name: 'startDate',
columnHeader: 'startDate',
width: 150,
isVisible: false,
},
{
key: 'dueDate',
name: 'dueDate',
columnHeader: 'dueDate',
width: 150,
isVisible: true,
},
{
key: 'dueTime',
name: 'dueTime',
columnHeader: 'dueTime',
width: 150,
isVisible: false,
},
{
key: 'completedDate',
name: 'completedDate',
columnHeader: 'completedDate',
width: 150,
isVisible: false,
},
{
key: 'createdDate',
name: 'createdDate',
columnHeader: 'createdDate',
width: 150,
isVisible: false,
},
{
key: 'lastUpdated',
name: 'lastUpdated',
columnHeader: 'lastUpdated',
width: 150,
isVisible: false,
},
{
key: 'reporter',
name: 'reporter',
columnHeader: 'reporter',
width: 150,
isVisible: false,
},
],
};
const projectViewTaskListColumnsSlice = createSlice({
name: 'projectViewTaskListColumnsReducer',
initialState,
reducers: {
toggleColumnVisibility: (state, action: PayloadAction<string>) => {
const column = state.columnList.find(col => col.key === action.payload);
if (column) {
column.isVisible = !column.isVisible;
}
},
addCustomColumn: (state, action: PayloadAction<CustomTableColumnsType>) => {
const customColumnCreaterIndex = state.columnList.findIndex(
col => col.key === 'customColumn'
);
if (customColumnCreaterIndex > -1) {
state.columnList.splice(customColumnCreaterIndex, 0, action.payload);
} else {
state.columnList.push(action.payload);
}
},
deleteCustomColumn: (state, action: PayloadAction<string>) => {
state.columnList = state.columnList.filter(col => col.key !== action.payload);
},
updateCustomColumn(
state,
action: PayloadAction<{
key: string;
updatedColumn: CustomTableColumnsType;
}>
) {
const index = state.columnList.findIndex(column => column.key === action.payload.key);
console.log('index', index, action.payload.key);
if (index !== -1) {
state.columnList[index] = action.payload.updatedColumn;
}
},
},
});
export const { toggleColumnVisibility, addCustomColumn, deleteCustomColumn, updateCustomColumn } =
projectViewTaskListColumnsSlice.actions;
export default projectViewTaskListColumnsSlice.reducer;

View File

@@ -0,0 +1,18 @@
import { createSlice } from '@reduxjs/toolkit';
import { UpdatesType } from '../../../../types/updates.types';
type UpdatesState = {
updatesList: UpdatesType[];
};
const initialState = {
updatesList: [],
};
const updatesSlice = createSlice({
name: 'updatesReducer',
initialState,
reducers: {},
});
export default updatesSlice.reducer;

View File

@@ -0,0 +1,41 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Status {
id: string;
name: string;
category_id: string;
message: string;
}
interface StatusState {
isDeleteStatusDrawerOpen: boolean;
status: Status | null;
}
const initialState: StatusState = {
isDeleteStatusDrawerOpen: false,
status: null,
};
const deleteStatusSlice = createSlice({
name: 'deleteStatusReducer',
initialState,
reducers: {
deleteStatusToggleDrawer: state => {
state.isDeleteStatusDrawerOpen = !state.isDeleteStatusDrawerOpen;
},
seletedStatusCategory: (state, action: PayloadAction<{ id: string; name: string; category_id: string; message: string}>) => {
state.status = action.payload;
},
// deleteStatus: (state, action: PayloadAction<string>) => {
// state.status = state.status.filter(status => status.id !== action.payload);
// },
},
});
export const {
deleteStatusToggleDrawer,
seletedStatusCategory,
// deleteStatus
} = deleteStatusSlice.actions;
export default deleteStatusSlice.reducer;

View File

@@ -0,0 +1,42 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Status {
id: string;
name: string;
category: string;
}
interface StatusState {
isCreateStatusDrawerOpen: boolean;
status: Status[];
}
const initialState: StatusState = {
isCreateStatusDrawerOpen: false,
status: [],
};
const statusSlice = createSlice({
name: 'statusReducer',
initialState,
reducers: {
toggleDrawer: state => {
state.isCreateStatusDrawerOpen = !state.isCreateStatusDrawerOpen;
},
addStatus: (state, action: PayloadAction<Status>) => {
state.status.push(action.payload);
},
updateStatusCategory: (state, action: PayloadAction<{ id: string; category: string }>) => {
const status = state.status.find(status => status.id === action.payload.id);
if (status) {
status.category = action.payload.category;
}
},
deleteStatus: (state, action: PayloadAction<string>) => {
state.status = state.status.filter(status => status.id !== action.payload);
},
},
});
export const { toggleDrawer, addStatus, updateStatusCategory, deleteStatus } = statusSlice.actions;
export default statusSlice.reducer;

View File

@@ -0,0 +1,269 @@
import {
Badge,
Button,
DatePicker,
Divider,
Drawer,
Flex,
Form,
Input,
InputRef,
Select,
Tag,
Typography,
} from 'antd';
import React, { useRef, useState } from 'react';
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';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { ProjectType } from '../../../types/project.types';
import { nanoid } from '@reduxjs/toolkit';
import { createProject, toggleDrawer, toggleUpdatedrawer } from '../projectSlice';
import ProjectList from '../../../pages/projects/ProjectList';
import { CategoryType } from '../../../types/categories.types';
const UpdateProjectDrawer = () => {
const currentlyActiveTeamData = useAppSelector(state => state.teamReducer.teamsList).find(
item => item.isActive
);
// get categories list from categories reducer
let categoriesList = useAppSelector(state => state.categoriesReducer.categoriesList);
// state for show category add input box
const [isAddCategoryInputShow, setIsAddCategoryInputShow] = useState<boolean>(false);
const [categoryText, setCategoryText] = useState<string>('');
const isDrawerOpen = useAppSelector(state => state.projectReducer.isUpdateDrawerOpen);
const dispatch = useAppDispatch();
const [form] = Form.useForm();
// status selection options
const statusOptions = [
...statusData.map((status, index) => ({
key: index,
value: status.value,
label: (
<Typography.Text style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{status.icon}
{status.label}
</Typography.Text>
),
})),
];
// health selection options
const healthOptions = [
...healthStatusData.map((status, index) => ({
key: index,
value: status.value,
label: (
<Typography.Text style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Badge color={status.color} /> {status.label}
</Typography.Text>
),
})),
];
// project color options
const projectColorOptions = [
...projectColors.map((color, index) => ({
key: index,
value: color,
label: (
<Tag
color={color}
style={{
display: 'flex',
alignItems: 'center',
width: 20,
height: 20,
borderRadius: '50%',
}}
/>
),
})),
];
// category input ref
const categoryInputRef = useRef<InputRef>(null);
const handleCategoryInputFocus = (open: boolean) => {
setTimeout(() => {
categoryInputRef.current?.focus();
}, 0);
};
// show input to add new category
const handleShowAddCategoryInput = () => {
setIsAddCategoryInputShow(true);
handleCategoryInputFocus(true);
};
// function to handle category add
const handleAddCategoryItem = (category: string) => {
const newCategory: CategoryType = {
categoryId: nanoid(),
categoryName: category,
categoryColor: '#ee87c5',
};
setCategoryText('');
setIsAddCategoryInputShow(false);
};
interface DataType {
key: string;
name: string;
client: string;
category: string;
status: string;
totalTasks: number;
completedTasks: number;
lastUpdated: Date;
startDate: Date | null;
endDate: Date | null;
members: string[];
}
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>Update Project</Typography.Text>
}
open={isDrawerOpen}
onClose={() => dispatch(toggleUpdatedrawer(''))}
>
{/* create project form */}
<Form
form={form}
layout="vertical"
initialValues={{
color: projectColors[0],
status: 'proposed',
health: 'notSet',
client: [],
estWorkingDays: 0,
estManDays: 0,
hrsPerDay: 8,
}}
>
<Form.Item
name="name"
label="Name"
rules={[
{
required: true,
message: 'Please enter a Name',
},
]}
>
<Input placeholder="Name" />
</Form.Item>
<Form.Item name="color" label="Project Color" layout="horizontal" required>
<Select
variant="borderless"
suffixIcon={null}
options={projectColorOptions}
style={{
width: 60,
}}
/>
</Form.Item>
<Form.Item name="status" label="Status">
<Select options={statusOptions} />
</Form.Item>
<Form.Item name="health" label="Health">
<Select options={healthOptions} />
</Form.Item>
<Form.Item name="category" label="Category">
{!isAddCategoryInputShow ? (
<Select
options={categoriesList}
placeholder="Add a category to the project"
dropdownRender={() => (
<Button
style={{ width: '100%' }}
type="dashed"
icon={<PlusOutlined />}
onClick={handleShowAddCategoryInput}
>
New Category
</Button>
)}
/>
) : (
<Flex vertical gap={4}>
<Input
ref={categoryInputRef}
placeholder="Enter a name for the category"
value={categoryText}
onChange={e => setCategoryText(e.currentTarget.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCategoryItem(categoryText)}
/>
<Typography.Text style={{ color: colors.lightGray }}>
Hit enter to create!
</Typography.Text>
</Flex>
)}
</Form.Item>
<Form.Item name="notes" label="Notes">
<Input.TextArea placeholder="Notes" />
</Form.Item>
<Form.Item
name="client"
label={
<Typography.Text>
Client <QuestionCircleOutlined />
</Typography.Text>
}
>
<Input placeholder="Select client" />
</Form.Item>
<Form.Item name="projectManager" label="Project Manager" layout="horizontal">
<Button type="dashed" shape="circle" icon={<PlusCircleOutlined />} />
</Form.Item>
<Form.Item name="date" layout="horizontal">
<Flex gap={8}>
<Form.Item name="startDate" label="Start Date">
<DatePicker />
</Form.Item>
<Form.Item name="endDate" label="End Date">
<DatePicker />
</Form.Item>
</Flex>
</Form.Item>
<Form.Item name="estWorkingDays" label="Estimate working days">
<Input type="number" />
</Form.Item>
<Form.Item name="estManDays" label="Estimate man days">
<Input type="number" />
</Form.Item>
<Form.Item name="hrsPerDay" label="Hours per day">
<Input type="number" />
</Form.Item>
<Button type="primary" style={{ width: '100%' }} htmlType="submit">
Save Changes
</Button>
<Button type="dashed" danger style={{ width: '100%', marginTop: '8px' }} htmlType="submit">
Delete Project
</Button>
</Form>
<Divider style={{ marginTop: '1rem', marginBottom: '0.5rem' }} />
<div style={{ paddingBottom: '0.25rem', display: 'flex', flexDirection: 'column' }}>
<Typography.Text type="secondary">
<small> Created a day ago by Raveesha Dilanka </small>
</Typography.Text>
<Typography.Text type="secondary">
<small> Updated a day ago </small>
</Typography.Text>
</div>
</Drawer>
);
};
export default UpdateProjectDrawer;

View File

@@ -0,0 +1,104 @@
import { Card, ConfigProvider, Tag, Timeline, Typography } from 'antd';
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 { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
type TaskStatus = {
name: string;
color_code: string;
};
type ActivityLogCardProps = {
data: ISingleMemberActivityLogs;
};
const ActivityLogCard = ({ data }: ActivityLogCardProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
const handleUpdateTaskDrawer = (id: string, projectId: string) => {
if (!id || !projectId) return;
dispatch(setSelectedTaskId(id));
dispatch(fetchPhasesByProjectId(projectId));
dispatch(fetchTask({ taskId: id, projectId: projectId }));
dispatch(setShowTaskDrawer(true));
};
// this function format the attribute type
const formatAttributeType = (attribute: string) =>
attribute.replace('_', ' ').replace(/\b\w/g, char => char.toUpperCase());
// this function render the colord tag
const renderStyledTag = (value: TaskStatus | null) => {
if (!value) return <Tag>None</Tag>;
return (
<Tag style={{ color: colors.darkGray, borderRadius: 48 }} color={value.color_code}>
{value.name}
</Tag>
);
};
// this function render the default normal tag
const renderDefaultTag = (value: string | null) => <Tag>{value || 'None'}</Tag>;
// this function render the tag conditionally if type status, priority or phases then return colord tag else return default tag
const renderTag = (log: ISingleMemberActivityLog, type: 'previous' | 'current') => {
if (!log.attribute_type) return null;
const isStatus = log.attribute_type === 'status';
const isPriority = log.attribute_type === 'priority';
const isPhase = log.attribute_type === 'phase';
if (isStatus) {
return renderStyledTag(type === 'previous' ? log.previous_status : log.next_status);
} else if (isPriority) {
return renderStyledTag(type === 'previous' ? log.previous_priority : log.next_priority);
} else if (isPhase) {
return renderStyledTag(type === 'previous' ? log.previous_phase : log.next_phase);
} else {
return renderDefaultTag(type === 'previous' ? log.previous : log.current);
}
};
return (
<ConfigProvider
theme={{
components: {
Timeline: { itemPaddingBottom: 24, dotBorderWidth: '2px' },
},
}}
>
<Card
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{simpleDateFormat(data.log_day)}
</Typography.Text>
}
>
<Timeline>
{data.logs.map((log, index) => (
<Timeline.Item key={index}>
<Typography.Text
className="cursor-pointer hover:text-[#1899ff]"
onClick={() => handleUpdateTaskDrawer(log.task_id, log.project_id)}
>
{t('updatedText')} <strong>{formatAttributeType(log.attribute_type)}</strong>{' '}
{t('fromText')} {renderTag(log, 'previous')} {t('toText')}{' '}
{renderTag(log, 'current')} {t('inText')} <strong>{log.task_name}</strong>{' '}
{t('withinText')} <strong>{log.project_name}</strong> <Tag>{log.task_key}</Tag>
</Typography.Text>
</Timeline.Item>
))}
</Timeline>
</Card>
</ConfigProvider>
);
};
export default ActivityLogCard;

View File

@@ -0,0 +1,73 @@
import { Flex, Skeleton } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
import ActivityLogCard from './activity-log-card';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { ISingleMemberActivityLogs } from '@/types/reporting/reporting.types';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
import { useAuthService } from '@/hooks/useAuth';
import logger from '@/utils/errorLogger';
import { useAppSelector } from '@/hooks/useAppSelector';
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
type MembersReportsActivityLogsTabProps = {
memberId: string | null;
};
const MembersReportsActivityLogsTab = ({ memberId = null }: MembersReportsActivityLogsTabProps) => {
const { t } = useTranslation('reporting-members-drawer');
const currentSession = useAuthService().getCurrentSession();
const [activityLogsData, setActivityLogsData] = useState<ISingleMemberActivityLogs[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const { archived } = useAppSelector(state => state.membersReportsReducer);
const fetchActivityLogsData = async () => {
if (!memberId || !currentSession?.team_id) return;
try {
setLoading(true);
const body = {
team_member_id: memberId,
team_id: currentSession?.team_id as string,
duration: duration,
date_range: dateRange,
archived: archived,
};
const response = await reportingApiService.getSingleMemberActivities(body);
if (response.done) {
setActivityLogsData(response.body);
}
} catch (error) {
logger.error('fetchActivityLogsData', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchActivityLogsData();
}, [memberId, duration, dateRange, archived]);
return (
<Skeleton active loading={loading} paragraph={{ rows: 10 }}>
{activityLogsData.length > 0 ? (
<Flex vertical gap={24}>
{activityLogsData.map(logs => (
<ActivityLogCard key={logs.log_day} data={logs} />
))}
</Flex>
) : (
<EmptyListPlaceholder text={t('activityLogsEmptyPlaceholder')} />
)}
{/* update task drawer */}
{createPortal(<TaskDrawer />, document.body)}
</Skeleton>
);
};
export default MembersReportsActivityLogsTab;

View File

@@ -0,0 +1,62 @@
import { Tabs } from 'antd';
import { TabsProps } from 'antd/lib';
import React from 'react';
import MembersReportsOverviewTab from './overviewTab/MembersReportsOverviewTab';
import MembersReportsTimeLogsTab from './time-log-tab/members-reports-time-logs-tab';
import MembersReportsActivityLogsTab from './activity-log-tab/members-reports-activity-logs-tab';
import MembersReportsTasksTab from './taskTab/MembersReportsTasksTab';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
import { setMemberReportingDrawerActiveTab } from '../membersReportsSlice';
type MembersReportsDrawerProps = {
memberId?: string | null;
};
type TabsType = 'overview' | 'timeLogs' | 'activityLogs' | 'tasks';
const MembersReportsDrawerTabs = ({ memberId = null }: MembersReportsDrawerProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
// get active tab state from member reporting reducer
const activeTab = useAppSelector(state => state.membersReportsReducer.activeTab);
const tabItems: TabsProps['items'] = [
{
key: 'overview',
label: t('overviewTab'),
children: <MembersReportsOverviewTab memberId={memberId} />,
},
{
key: 'timeLogs',
label: t('timeLogsTab'),
children: <MembersReportsTimeLogsTab memberId={memberId} />,
},
{
key: 'activityLogs',
label: t('activityLogsTab'),
children: <MembersReportsActivityLogsTab memberId={memberId} />,
},
{
key: 'tasks',
label: t('tasksTab'),
children: <MembersReportsTasksTab memberId={memberId} />,
},
];
return (
<Tabs
type="card"
items={tabItems}
activeKey={activeTab}
destroyInactiveTabPane
onTabClick={key => dispatch(setMemberReportingDrawerActiveTab(key as TabsType))}
/>
);
};
export default MembersReportsDrawerTabs;

View File

@@ -0,0 +1,166 @@
import { Drawer, Typography, Flex, Button, Space, Dropdown } from 'antd';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleMembersReportsDrawer } from '../membersReportsSlice';
import { DownOutlined } from '@ant-design/icons';
import MembersReportsDrawerTabs from './members-reports-drawer-tabs';
import { useTranslation } from 'react-i18next';
import MembersOverviewTasksStatsDrawer from './overviewTab/members-overview-tasks-stats-drawer/members-overview-tasks-stats-drawer';
import MembersOverviewProjectsStatsDrawer from './overviewTab/members-overview-projects-stats-drawer/members-overview-projects-stats-drawer';
import TimeWiseFilter from '@/components/reporting/time-wise-filter';
import { useState } from 'react';
import { useAuthService } from '@/hooks/useAuth';
import { reportingExportApiService } from '@/api/reporting/reporting-export.api.service';
import logger from '@/utils/errorLogger';
type MembersReportsDrawerProps = {
memberId: string | null;
};
const MembersReportsDrawer = ({ memberId }: MembersReportsDrawerProps) => {
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
const currentSession = useAuthService().getCurrentSession();
const [exporting, setExporting] = useState<boolean>(false);
const isDrawerOpen = useAppSelector(
state => state.membersReportsReducer.isMembersReportsDrawerOpen
);
const { membersList, archived } = useAppSelector(state => state.membersReportsReducer);
const activeTab = useAppSelector(state => state.membersReportsReducer.activeTab);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const selectedMember = membersList?.find(member => member.id === memberId);
const handleClose = () => {
dispatch(toggleMembersReportsDrawer());
};
const exportTimeLogs = () => {
if (!memberId || !currentSession?.team_id) return;
try {
setExporting(true);
const body = {
team_member_id: memberId,
team_id: currentSession?.team_id as string,
duration: duration,
date_range: dateRange,
archived: archived,
member_name: selectedMember?.name,
team_name: currentSession?.team_name,
};
reportingExportApiService.exportMemberTimeLogs(body);
} catch (e) {
logger.error('exportTimeLogs', e);
} finally {
setExporting(false);
}
};
const exportActivityLogs = () => {
if (!memberId || !currentSession?.team_id) return;
try {
setExporting(true);
const body = {
team_member_id: memberId,
team_id: currentSession?.team_id as string,
duration: duration,
date_range: dateRange,
member_name: selectedMember?.name,
team_name: currentSession?.team_name,
archived: archived,
};
reportingExportApiService.exportMemberActivityLogs(body);
} catch (e) {
logger.error('exportActivityLogs', e);
} finally {
setExporting(false);
}
};
const exportTasks = () => {
if (!memberId || !currentSession?.team_id) return;
try {
setExporting(true);
const additionalBody = {
duration: duration,
date_range: dateRange,
only_single_member: true,
archived,
};
reportingExportApiService.exportMemberTasks(
memberId,
selectedMember?.name,
currentSession?.team_name,
additionalBody
);
} catch (e) {
logger.error('exportTasks', e);
} finally {
setExporting(false);
}
};
const handleExport = (key: string) => {
switch (key) {
case '1': // Time Logs
exportTimeLogs();
break;
case '2': // Activity Logs
exportActivityLogs();
break;
case '3': // Tasks
exportTasks();
break;
default:
break;
}
};
return (
<Drawer
open={isDrawerOpen}
onClose={handleClose}
width={900}
destroyOnClose
title={
selectedMember && (
<Flex align="center" justify="space-between">
<Flex gap={8} align="center" style={{ fontWeight: 500 }}>
<Typography.Text>{selectedMember.name}</Typography.Text>
</Flex>
<Space>
<TimeWiseFilter />
<Dropdown
menu={{
items: [
{ key: '1', label: t('timeLogsButton') },
{ key: '2', label: t('activityLogsButton') },
{ key: '3', label: t('tasksButton') },
],
onClick: ({ key }) => handleExport(key),
}}
>
<Button
type="primary"
loading={exporting}
icon={<DownOutlined />}
iconPosition="end"
>
{t('exportButton')}
</Button>
</Dropdown>
</Space>
</Flex>
)
}
>
{selectedMember && <MembersReportsDrawerTabs memberId={selectedMember.id} />}
{selectedMember && <MembersOverviewTasksStatsDrawer memberId={selectedMember.id} />}
{selectedMember && <MembersOverviewProjectsStatsDrawer memberId={selectedMember.id} />}
</Drawer>
);
};
export default MembersReportsDrawer;

View File

@@ -0,0 +1,58 @@
import React, { useEffect } from 'react';
import MembersReportsStatCard from './members-reports-stat-card';
import MembersReportsStatusGraph from './MembersReportsStatusGraph';
import MembersReportsPriorityGraph from './MembersReportsPriorityGraph';
import MembersReportsProjectGraph from './MembersReportsProjectGraph';
import { IRPTOverviewMemberInfo } from '@/types/reporting/reporting.types';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
import logger from '@/utils/errorLogger';
import { useAppSelector } from '@/hooks/useAppSelector';
import { set } from 'date-fns';
type MembersReportsOverviewTabProps = {
memberId: string | null;
};
const MembersReportsOverviewTab = ({ memberId }: MembersReportsOverviewTabProps) => {
const [model, setModel] = React.useState<IRPTOverviewMemberInfo>({});
const [loadingModel, setLoadingModel] = React.useState<boolean>(true);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const { archived } = useAppSelector(state => state.membersReportsReducer);
const fetchStatsModelData = async () => {
if (!memberId || !duration || !dateRange) return;
try {
setLoadingModel(true);
const body = {
teamMemberId: memberId,
duration: duration,
date_range: dateRange,
archived
};
const response = await reportingApiService.getMemberInfo(body);
if (response.done) {
setModel(response.body);
}
} catch (error) {
logger.error('fetchStatsModelData', error);
} finally {
setLoadingModel(false);
}
};
useEffect(() => {
fetchStatsModelData();
}, [memberId, duration, dateRange]);
return (
<div className="grid gap-4 sm:grid-cols-2">
<MembersReportsStatCard statsModel={model.stats} loading={loadingModel} />
<MembersReportsProjectGraph model={model.by_project} loading={loadingModel} />
<MembersReportsStatusGraph model={model.by_status} loading={loadingModel} />
<MembersReportsPriorityGraph model={model.by_priority} loading={loadingModel} />
</div>
);
};
export default MembersReportsOverviewTab;

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip } from 'chart.js';
import { Badge, Card, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewMemberChartData } from '@/types/reporting/reporting.types';
Chart.register(ArcElement, Tooltip);
interface MembersReportsPriorityGraphProps {
model: IRPTOverviewMemberChartData | undefined;
loading: boolean;
}
const MembersReportsPriorityGraph = ({ model, loading }: MembersReportsPriorityGraphProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
const chartData = {
labels: model?.chart.map(item => t(`${item.name}Text`)),
datasets: [
{
label: t('tasksText'),
data: model?.chart.map(item => item.y),
backgroundColor: model?.chart.map(item => item.color),
},
],
};
const options = {
responsive: true,
plugins: {
legend: {
display: false,
position: 'top' as const,
},
datalabels: {
display: false,
},
},
};
return (
<Card
loading={loading}
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasksByPriorityText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#000" />
<Typography.Text ellipsis>
{t('allText')} ({model?.total})
</Typography.Text>
</Flex>
{/* priority-specific tasks */}
{model?.chart.map(item => (
<Flex key={item.name} gap={4} align="center">
<Badge color={item.color} />
<Typography.Text ellipsis>
{t(`${item.name}`)}({item.y})
</Typography.Text>
</Flex>
))}
</div>
</div>
</Card>
);
};
export default MembersReportsPriorityGraph;

View File

@@ -0,0 +1,83 @@
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip } from 'chart.js';
import { Badge, Card, Flex, Typography, Tooltip as AntTooltip } from 'antd';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewMemberChartData } from '@/types/reporting/reporting.types';
Chart.register(ArcElement, Tooltip);
interface MembersReportsProjectGraphProps {
model: IRPTOverviewMemberChartData | undefined;
loading: boolean;
}
const MembersReportsProjectGraph = ({ model, loading }: MembersReportsProjectGraphProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
// chart data
const chartData = {
labels: model?.chart.map(item => item.name),
datasets: [
{
label: t('tasksText'),
data: model?.chart.map(item => item.y),
backgroundColor: model?.chart.map(item => item.color),
},
],
};
const options = {
responsive: true,
plugins: {
legend: {
display: false,
position: 'top' as const,
},
datalabels: {
display: false,
},
},
};
return (
<Card
loading={loading}
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasksByProjectsText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#000" />
<Typography.Text ellipsis>
{t('allText')} ({model?.total})
</Typography.Text>
</Flex>
{/* project-specific tasks */}
{model?.chart.map((item, index) => (
<AntTooltip key={index} title={`${item.name} (${item.y})`}>
<Flex key={item.name} gap={4} align="center" style={{ maxWidth: 120 }}>
<Badge color={item.color} />
<Typography.Text ellipsis>{item.name}</Typography.Text>({item.y})
</Flex>
</AntTooltip>
))}
</div>
</div>
</Card>
);
};
export default MembersReportsProjectGraph;

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip } from 'chart.js';
import { Badge, Card, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewMemberChartData } from '@/types/reporting/reporting.types';
Chart.register(ArcElement, Tooltip);
interface MembersReportsStatusGraphProps {
model: IRPTOverviewMemberChartData | undefined;
loading: boolean;
}
const MembersReportsStatusGraph = ({ model, loading }: MembersReportsStatusGraphProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
// chart data
const chartData = {
labels: model?.chart.map(item => t(`${item.name}Text`)),
datasets: [
{
label: t('tasksText'),
data: model?.chart.map(item => item.y),
backgroundColor: model?.chart.map(item => item.color),
},
],
};
const options = {
responsive: true,
plugins: {
legend: {
display: false,
position: 'top' as const,
},
datalabels: {
display: false,
},
},
};
return (
<Card
loading={loading}
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasksByStatusText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#000" />
<Typography.Text ellipsis>
{t('allText')} ({model?.total})
</Typography.Text>
</Flex>
{/* status-specific tasks */}
{model?.chart.map(item => (
<Flex key={item.name} gap={4} align="center">
<Badge color={item.color} />
<Typography.Text ellipsis>
{t(`${item.name}`)}({item.y})
</Typography.Text>
</Flex>
))}
</div>
</div>
</Card>
);
};
export default MembersReportsStatusGraph;

View File

@@ -0,0 +1,85 @@
import { Drawer, Typography } from 'antd';
import React, { useEffect, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { toggleMembersOverviewProjectsStatsDrawer } from '../../../membersReportsSlice';
import MembersOverviewProjectsStatsTable from './members-overview-projects-stats-table';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
type MembersOverviewProjectsStatsDrawerProps = {
memberId: string | null;
};
const MembersOverviewProjectsStatsDrawer = ({
memberId,
}: MembersOverviewProjectsStatsDrawerProps) => {
const [projectsData, setProjectsData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// localization
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
const isDrawerOpen = useAppSelector(
state => state.membersReportsReducer.isMembersOverviewProjectsStatsDrawerOpen
);
const { membersList } = useAppSelector(state => state.membersReportsReducer);
const selectedMember = membersList.find(member => member.id === memberId);
const handleClose = () => {
dispatch(toggleMembersOverviewProjectsStatsDrawer());
};
useEffect(() => {
const fetchProjectsData = async () => {
if (!memberId || !isDrawerOpen) return;
try {
setLoading(true);
const body = {
team_member_id: memberId,
archived: false,
};
const response = await reportingApiService.getSingleMemberProjects(body);
if (response.done){
setProjectsData(response.body.projects || []);
} else {
setProjectsData([]);
}
} catch (error) {
console.error('Error fetching member projects:', error);
setProjectsData([]);
} finally {
setLoading(false);
}
};
fetchProjectsData();
}, [memberId, isDrawerOpen]);
return (
<Drawer
open={isDrawerOpen}
onClose={handleClose}
width={900}
title={
selectedMember && (
<Typography.Text>
{selectedMember.name}
{t('projectsStatsOverviewDrawerTitle')}
</Typography.Text>
)
}
>
<MembersOverviewProjectsStatsTable
projectList={projectsData}
loading={loading}
/>
</Drawer>
);
};
export default MembersOverviewProjectsStatsDrawer;

View File

@@ -0,0 +1,187 @@
import { memo } from 'react';
import { ConfigProvider, Flex, Skeleton, Spin, Table, TableColumnsType, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import CustomTableTitle from '@components/CustomTableTitle';
import { simpleDateFormat } from '@/utils/simpleDateFormat';
import { colors } from '@/styles/colors';
import { toCamelCase } from '@/utils/toCamelCase';
import ProjectCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-cell/project-cell';
import ProjectDaysLeftAndOverdueCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-days-left-and-overdue-cell/project-days-left-and-overdue-cell';
import ProjectManagerCell from '@/pages/reporting/projects-reports/projects-reports-table/table-cells/project-manager-cell/project-manager-cell';
type ProjectReportsTableProps = {
projectList: any[];
loading: Boolean;
};
const MembersOverviewProjectsStatsTable = ({ projectList, loading }: ProjectReportsTableProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
const columns: TableColumnsType = [
{
key: 'name',
title: <CustomTableTitle title={t('nameColumn')} />,
width: 300,
render: record => (
<ProjectCell projectId={record.id} project={record.name} projectColor={record.color_code} />
),
fixed: 'left' as const,
},
{
key: 'startDate',
title: <CustomTableTitle title={t('startDateColumn')} />,
render: record => (
<Typography.Text className="group-hover:text-[#1890ff]">
{record?.start_date ? simpleDateFormat(record?.start_date) : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'endDate',
title: <CustomTableTitle title={t('endDateColumn')} />,
render: record => (
<Typography.Text className="group-hover:text-[#1890ff]">
{record?.start_date ? simpleDateFormat(record?.end_date) : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'daysLeft',
title: <CustomTableTitle title={t('daysLeftColumn')} />,
// render: record => <ProjectDaysLeftAndOverdueCell daysLeft={record.days_left} />,
width: 150,
},
{
key: 'estimatedTime',
title: <CustomTableTitle title={t('estimatedTimeColumn')} />,
render: record => (
<Typography.Text className="group-hover:text-[#1890ff]">
{record.estimated_time_string}
</Typography.Text>
),
width: 120,
},
{
key: 'actualTime',
title: <CustomTableTitle title={t('actualTimeColumn')} />,
render: record => (
<Typography.Text className="group-hover:text-[#1890ff]">
{record.actual_time_string}
</Typography.Text>
),
width: 120,
},
{
key: 'status',
title: <CustomTableTitle title={t('statusColumn')} />,
// render: record => {
// 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>
// );
// },
width: 120,
},
{
key: 'projectHealth',
title: <CustomTableTitle title={t('projectHealthColumn')} />,
render: record => (
<Flex
gap={6}
align="center"
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 8,
height: 30,
backgroundColor: record.health_color,
color: colors.darkGray,
cursor: 'pointer',
}}
>
<Typography.Text
style={{
color: colors.darkGray,
fontSize: 13,
}}
>
{record.health_name ? t(`${toCamelCase(record.health_name)}Text`) : '-'}
</Typography.Text>
</Flex>
),
width: 120,
},
{
key: 'category',
title: <CustomTableTitle title="Category" />,
render: record => (
<Flex
gap={6}
align="center"
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 8,
textTransform: 'capitalize',
fontSize: 13,
height: 22,
backgroundColor: record.category_color,
}}
>
{record.category_name ? record.category_name : '-'}
</Flex>
),
width: 120,
},
{
key: 'projectManager',
title: <CustomTableTitle title={t('projectManagerColumn')} />,
render: record => <ProjectManagerCell manager={record.project_manager} />,
width: 180,
},
];
return (
<ConfigProvider
theme={{
components: {
Table: {
cellPaddingBlock: 8,
cellPaddingInline: 8,
},
},
}}
>
{loading ? (
<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]',
};
}}
/>
)}
</ConfigProvider>
);
};
export default memo(MembersOverviewProjectsStatsTable);

View File

@@ -0,0 +1,74 @@
import { Drawer, Typography } from 'antd';
import React, { useMemo, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { toggleMembersOverviewTasksStatsDrawer } from '../../../membersReportsSlice';
import { fetchData } from '@/utils/fetchData';
import MembersOverviewTasksStatsTable from './members-overview-tasks-stats-table';
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
type MembersOverviewTasksStatsDrawerProps = {
memberId: string | null;
};
const MembersOverviewTasksStatsDrawer = ({ memberId }: MembersOverviewTasksStatsDrawerProps) => {
const [tasksData, setTasksData] = useState<any[]>([]);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
// localization
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
// get drawer open state from the member reports reducer
const isDrawerOpen = useAppSelector(
state => state.membersReportsReducer.isMembersOverviewTasksStatsDrawerOpen
);
const { membersList } = useAppSelector(state => state.membersReportsReducer);
// find the selected member based on memberId
const selectedMember = membersList.find(member => member.id === memberId);
// function to handle drawer close
const handleClose = () => {
dispatch(toggleMembersOverviewTasksStatsDrawer());
};
// useMemo for memoizing the fetch functions
useMemo(() => {
fetchData('/reportingMockData/membersReports/tasksStatsOverview.json', setTasksData);
}, []);
return (
<Drawer
open={isDrawerOpen}
onClose={handleClose}
width={900}
title={
selectedMember && (
<Typography.Text>
{selectedMember.name}
{t('tasksStatsOverviewDrawerTitle')}
</Typography.Text>
)
}
>
{tasksData &&
tasksData.map((item, index) => (
<MembersOverviewTasksStatsTable
key={index}
title={item.name}
color={item.color_code}
tasksData={item.tasks}
setSeletedTaskId={setSelectedTaskId}
/>
))}
<TaskDrawer />
</Drawer>
);
};
export default MembersOverviewTasksStatsDrawer;

View File

@@ -0,0 +1,173 @@
import { Badge, Collapse, Flex, Table, TableColumnsType, Tag, Typography } from 'antd';
import CustomTableTitle from '@components/CustomTableTitle';
import { colors } from '@/styles/colors';
import dayjs from 'dayjs';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { DoubleRightOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
type MembersOverviewTasksStatsTableProps = {
tasksData: any[];
title: string;
color: string;
setSeletedTaskId: (id: string) => void;
};
const MembersOverviewTasksStatsTable = ({
tasksData,
title,
color,
setSeletedTaskId,
}: MembersOverviewTasksStatsTableProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
// function to handle task drawer open
const handleUpdateTaskDrawer = (id: string) => {
setSeletedTaskId(id);
dispatch(setShowTaskDrawer(true));
};
const columns: TableColumnsType = [
{
key: 'task',
title: <CustomTableTitle title={t('taskColumn')} />,
onCell: record => {
return {
onClick: () => handleUpdateTaskDrawer(record.id),
};
},
render: record => (
<Flex>
{Number(record.sub_tasks_count) > 0 && <DoubleRightOutlined />}
<Typography.Text className="group-hover:text-[#1890ff]">{record.name}</Typography.Text>
</Flex>
),
width: 260,
className: 'group-hover:text-[#1890ff]',
fixed: 'left' as const,
},
{
key: 'status',
title: <CustomTableTitle title={t('statusColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.status_color}
children={record.status_name}
/>
),
width: 120,
},
{
key: 'priority',
title: <CustomTableTitle title={t('priorityColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.priority_color}
children={record.priority_name}
/>
),
width: 120,
},
{
key: 'phase',
title: <CustomTableTitle title={t('phaseColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.phase_color}
children={record.phase_name}
/>
),
width: 120,
},
{
key: 'dueDate',
title: <CustomTableTitle title={t('dueDateColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.due_date ? `${dayjs(record.due_date).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'completedOn',
title: <CustomTableTitle title={t('completedOnColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.completed_date ? `${dayjs(record.completed_date).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'daysOverdue',
title: <CustomTableTitle title={t('daysOverdueColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overdue_days',
width: 120,
},
{
key: 'estimatedTime',
title: <CustomTableTitle title={t('estimatedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'total_time_string',
width: 130,
},
{
key: 'loggedTime',
title: <CustomTableTitle title={t('loggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'time_spent_string',
width: 130,
},
{
key: 'overloggedTime',
title: <CustomTableTitle title={t('overloggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overlogged_time_string',
width: 150,
},
];
return (
<Collapse
bordered={false}
ghost={true}
size="small"
items={[
{
key: '1',
label: (
<Flex gap={8} align="center">
<Badge color={color} />
<Typography.Text strong>{`${title} (${tasksData.length})`}</Typography.Text>
</Flex>
),
children: (
<Table
columns={columns}
dataSource={tasksData}
pagination={false}
scroll={{ x: 'max-content' }}
onRow={record => {
return {
style: { height: 38, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
/>
),
},
]}
/>
);
};
export default MembersOverviewTasksStatsTable;

View File

@@ -0,0 +1,114 @@
import {
ClockCircleOutlined,
ExclamationCircleOutlined,
FileExcelOutlined,
} from '@ant-design/icons';
import { Button, Card, Flex } from 'antd';
import React, { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
setMemberReportingDrawerActiveTab,
toggleMembersOverviewTasksStatsDrawer,
toggleMembersOverviewProjectsStatsDrawer,
} from '../../membersReportsSlice';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IRPTOverviewMemberStats } from '@/types/reporting/reporting.types';
interface StatCardProps {
statsModel: IRPTOverviewMemberStats | undefined;
loading: boolean;
}
const MembersReportsStatCard = ({ statsModel, loading }: StatCardProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
const themeMode = useAppSelector(state => state.themeReducer.mode);
// function to handle members overview tasks stat drawer open
const handleMembersOverviewTasksStatsDrawerToggle = () => {
dispatch(toggleMembersOverviewTasksStatsDrawer());
};
// function to handle members overview projects stat drawer open
const handleMembersOverviewProjectsStatsDrawerToggle = () => {
dispatch(toggleMembersOverviewProjectsStatsDrawer());
};
// fuction to handle tab change
const handleNavigateTimeLogsTab = () => {
dispatch(setMemberReportingDrawerActiveTab('timeLogs'));
};
type StatItemsType = {
name: string;
icon: ReactNode;
value: string;
onClick: () => void;
};
// stat items array
const statItems: StatItemsType[] = [
{
name: 'projects',
icon: <FileExcelOutlined style={{ fontSize: 24, color: '#f6ce69' }} />,
value: statsModel?.projects.toString() || '0',
onClick: handleMembersOverviewProjectsStatsDrawerToggle,
},
{
name: 'totalTasks',
icon: <ExclamationCircleOutlined style={{ fontSize: 24, color: '#70eded' }} />,
value: statsModel?.total_tasks.toString() || '0',
onClick: handleMembersOverviewTasksStatsDrawerToggle,
},
{
name: 'assignedTasks',
icon: <ExclamationCircleOutlined style={{ fontSize: 24, color: '#7590c9' }} />,
value: statsModel?.assigned.toString() || '0',
onClick: handleMembersOverviewTasksStatsDrawerToggle,
},
{
name: 'completedTasks',
icon: <ExclamationCircleOutlined style={{ fontSize: 24, color: '#75c997' }} />,
value: statsModel?.completed.toString() || '0',
onClick: handleMembersOverviewTasksStatsDrawerToggle,
},
{
name: 'ongoingTasks',
icon: <ClockCircleOutlined style={{ fontSize: 24, color: '#7cb5ec' }} />,
value: statsModel?.ongoing.toString() || '0',
onClick: handleMembersOverviewTasksStatsDrawerToggle,
},
{
name: 'overdueTasks',
icon: <ClockCircleOutlined style={{ fontSize: 24, color: '#eb6363' }} />,
value: statsModel?.overdue.toString() || '0',
onClick: handleMembersOverviewTasksStatsDrawerToggle,
},
{
name: 'loggedHours',
icon: <ClockCircleOutlined style={{ fontSize: 24, color: '#75c997' }} />,
value: statsModel?.total_logged.toString() || '0',
onClick: handleNavigateTimeLogsTab,
},
];
return (
<Card style={{ width: '100%' }} loading={loading}>
<Flex vertical gap={8} style={{ padding: '12px 24px' }}>
{statItems.map((item, index) => (
<Flex key={index} gap={12} align="center">
{item.icon}
<Button type="text" onClick={item.onClick}>
{item.value} {t(`${item.name}Text`)}
</Button>
</Flex>
))}
</Flex>
</Card>
);
};
export default MembersReportsStatCard;

View File

@@ -0,0 +1,107 @@
import { Flex } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import CustomSearchbar from '../../../../../components/CustomSearchbar';
import { fetchData } from '@/utils/fetchData';
import MembersReportsTasksTable from './MembersReportsTasksTable';
import ProjectFilter from './ProjectFilter';
import { useTranslation } from 'react-i18next';
import { useAuthService } from '@/hooks/useAuth';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
import { IRPTOverviewProject } from '@/types/reporting/reporting.types';
import { useAppSelector } from '@/hooks/useAppSelector';
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
type MembersReportsTasksTabProps = {
memberId: string | null;
};
const MembersReportsTasksTab = ({ memberId }: MembersReportsTasksTabProps) => {
const { t } = useTranslation('reporting-members-drawer');
const currentSession = useAuthService().getCurrentSession();
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const { archived } = useAppSelector(state => state.membersReportsReducer);
const [searchQuery, setSearchQuery] = useState<string>('');
const [tasksList, setTasksList] = useState<any[]>([]);
const [loadingTasks, setLoadingTasks] = useState<boolean>(false);
const [projectsList, setProjectsList] = useState<IRPTOverviewProject[]>([]);
const [loadingProjects, setLoadingProjects] = useState<boolean>(false);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const filteredTasks = useMemo(() => {
return tasksList.filter(task => task.name.toLowerCase().includes(searchQuery.toLowerCase()));
}, [tasksList, searchQuery]);
const fetchProjects = async () => {
if (!currentSession?.team_id) return;
try {
setLoadingProjects(true);
const response = await reportingApiService.getOverviewProjectsByTeam(currentSession.team_id);
if (response.done) {
setProjectsList(response.body);
}
} catch (error) {
console.error(error);
} finally {
setLoadingProjects(false);
}
};
const fetchTasks = async () => {
if (!currentSession?.team_id || !memberId) return;
try {
setLoadingTasks(true);
const additionalBody = {
duration: duration,
date_range: dateRange,
only_single_member: true,
archived,
};
const response = await reportingApiService.getTasksByMember(
memberId,
selectedProjectId,
false,
null,
additionalBody
);
if (response.done) {
setTasksList(response.body);
}
} catch (error) {
console.error(error);
} finally {
setLoadingTasks(false);
}
};
useEffect(() => {
fetchProjects();
fetchTasks();
}, [selectedProjectId, duration, dateRange]);
return (
<Flex vertical gap={24}>
<Flex gap={24} align="center" justify="space-between">
<CustomSearchbar
placeholderText={t('searchByNameInputPlaceholder')}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
<ProjectFilter
projectList={projectsList}
loading={loadingProjects}
onSelect={value => setSelectedProjectId(value)}
/>
</Flex>
<MembersReportsTasksTable tasksData={filteredTasks} loading={loadingTasks} />
<TaskDrawer />
</Flex>
);
};
export default MembersReportsTasksTab;

View File

@@ -0,0 +1,144 @@
import { Badge, Flex, Table, TableColumnsType, Tag, Typography } from 'antd';
import React from 'react';
import dayjs from 'dayjs';
import { DoubleRightOutlined } from '@ant-design/icons';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import CustomTableTitle from '@/components/CustomTableTitle';
import { colors } from '@/styles/colors';
import { useTranslation } from 'react-i18next';
type MembersReportsTasksTableProps = {
tasksData: any[];
loading: boolean;
};
const MembersReportsTasksTable = ({
tasksData,
loading,
}: MembersReportsTasksTableProps) => {
// localization
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
// function to handle task drawer open
const handleUpdateTaskDrawer = (id: string) => {
dispatch(setSelectedTaskId(id));
dispatch(setShowTaskDrawer(true));
};
const columns: TableColumnsType = [
{
key: 'task',
title: <CustomTableTitle title={t('taskColumn')} />,
onCell: record => {
return {
onClick: () => handleUpdateTaskDrawer(record.id),
};
},
render: record => (
<Flex>
{Number(record.sub_tasks_count) > 0 && <DoubleRightOutlined />}
<Typography.Text className="group-hover:text-[#1890ff]">{record.name}</Typography.Text>
</Flex>
),
width: 260,
fixed: 'left' as const,
},
{
key: 'project',
title: <CustomTableTitle title={t('projectColumn')} />,
render: record => (
<Flex gap={8} align="center">
<Badge color={record.project_color} />
<Typography.Text ellipsis={{ expanded: false }}>{record.project_name}</Typography.Text>
</Flex>
),
width: 120,
},
{
key: 'status',
title: <CustomTableTitle title={t('statusColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.status_color}
children={record.status_name}
/>
),
width: 120,
},
{
key: 'priority',
title: <CustomTableTitle title={t('priorityColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.priority_color}
children={record.priority_name}
/>
),
width: 120,
},
{
key: 'dueDate',
title: <CustomTableTitle title={t('dueDateColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.end_date ? `${dayjs(record.end_date).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'completedDate',
title: <CustomTableTitle title={t('completedDateColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.completed_at ? `${dayjs(record.completed_at).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'estimatedTime',
title: <CustomTableTitle title={t('estimatedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'estimated_string',
width: 130,
},
{
key: 'loggedTime',
title: <CustomTableTitle title={t('loggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'time_spent_string',
width: 130,
},
{
key: 'overloggedTime',
title: <CustomTableTitle title={t('overloggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overlogged_time',
width: 150,
},
];
return (
<Table
columns={columns}
dataSource={tasksData}
scroll={{ x: 'max-content' }}
rowKey={record => record.id}
loading={loading}
onRow={record => {
return {
style: { height: 38, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
/>
);
};
export default MembersReportsTasksTable;

View File

@@ -0,0 +1,34 @@
import { IRPTOverviewProject } from '@/types/reporting/reporting.types';
import { Flex, Select, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
type ProjectFilterProps = {
projectList: IRPTOverviewProject[];
loading: boolean;
onSelect: (value: string) => void;
};
const ProjectFilter = ({ projectList, loading, onSelect }: ProjectFilterProps) => {
const { t } = useTranslation('reporting-members-drawer');
const selectOptions = projectList.map(project => ({
key: project.id,
value: project.id,
label: project.name,
}));
return (
<Flex gap={4} align="center">
<Typography.Text>{t('filterByText')}</Typography.Text>
<Select
placeholder={t('selectProjectPlaceholder')}
options={selectOptions}
loading={loading}
onChange={onSelect}
allowClear
/>
</Flex>
);
};
export default ProjectFilter;

View File

@@ -0,0 +1,84 @@
import { CaretDownFilled } from '@ant-design/icons';
import { Button, Card, Checkbox, Dropdown, List, Space } from 'antd';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
interface BillableFilterProps {
billable: { billable: boolean; nonBillable: boolean };
onBillableChange: (value: { billable: boolean; nonBillable: boolean }) => void;
}
const BillableFilter = ({ billable, onBillableChange }: BillableFilterProps) => {
// state to track dropdown open status
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
// localization
const { t } = useTranslation('reporting-members-drawer');
// billable dropdown items
type BillableFieldsType = {
key: string;
label: string;
};
const billableFieldsList: BillableFieldsType[] = [
{ key: 'billable', label: 'Billable' },
{ key: 'nonBillable', label: 'Non Billable' },
];
// custom dropdown content
const billableDropdownContent = (
<Card className="custom-card" styles={{ body: { padding: 0 } }}>
<List style={{ padding: 0 }}>
{billableFieldsList.map(item => (
<List.Item
className="custom-list-item"
key={item.key}
style={{
display: 'flex',
gap: 8,
padding: '4px 8px',
border: 'none',
}}
>
<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]
})}
/>
{t(`${item.key}Text`)}
</Space>
</List.Item>
))}
</List>
</Card>
);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => billableDropdownContent}
onOpenChange={open => setIsDropdownOpen(open)}
>
<Button
icon={<CaretDownFilled />}
iconPosition="end"
style={{ width: 'fit-content' }}
className={`transition-colors duration-300 ${
isDropdownOpen
? 'border-[#1890ff] text-[#1890ff]'
: 'hover:text-[#1890ff hover:border-[#1890ff]'
}`}
>
{t('billableButton')}
</Button>
</Dropdown>
);
};
export default BillableFilter;

View File

@@ -0,0 +1,89 @@
import { Flex, Skeleton } from 'antd';
import React, { useEffect, useState } from 'react';
import BillableFilter from './billable-filter';
import { fetchData } from '@/utils/fetchData';
import TimeLogCard from './time-log-card';
import EmptyListPlaceholder from '../../../../../components/EmptyListPlaceholder';
import { useTranslation } from 'react-i18next';
import { reportingApiService } from '@/api/reporting/reporting.api.service';
import logger from '@/utils/errorLogger';
import { ISingleMemberLogs } from '@/types/reporting/reporting.types';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAuthService } from '@/hooks/useAuth';
import { createPortal } from 'react-dom';
import { reportingExportApiService } from '@/api/reporting/reporting-export.api.service';
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
type MembersReportsTimeLogsTabProps = {
memberId: string | null;
};
const MembersReportsTimeLogsTab = ({ memberId = null }: MembersReportsTimeLogsTabProps) => {
const { t } = useTranslation('reporting-members-drawer');
const currentSession = useAuthService().getCurrentSession();
const [timeLogsData, setTimeLogsData] = useState<ISingleMemberLogs[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const { archived } = useAppSelector(state => state.membersReportsReducer);
const [billable, setBillable] = useState<{ billable: boolean; nonBillable: boolean }>({
billable: true,
nonBillable: true,
});
const fetchTimeLogsData = async () => {
if (!memberId || !currentSession?.team_id) return;
try {
setLoading(true);
const body = {
team_member_id: memberId,
team_id: currentSession?.team_id as string,
duration: duration,
date_range: dateRange,
archived: archived,
billable: billable,
};
const response = await reportingApiService.getSingleMemberTimeLogs(body);
if (response.done) {
response.body.sort((a: any, b: any) => {
const dateA = new Date(a.log_day);
const dateB = new Date(b.log_day);
return dateB.getTime() - dateA.getTime();
});
setTimeLogsData(response.body);
}
} catch (error) {
logger.error('fetchTimeLogsData', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTimeLogsData();
}, [memberId, duration, dateRange, archived, billable]);
return (
<Flex vertical gap={24}>
<BillableFilter billable={billable} onBillableChange={setBillable} />
<Skeleton active loading={loading} paragraph={{ rows: 10 }}>
{timeLogsData.length > 0 ? (
<Flex vertical gap={24}>
{timeLogsData.map((logs, index) => (
<TimeLogCard key={index} data={logs} />
))}
</Flex>
) : (
<EmptyListPlaceholder text={t('timeLogsEmptyPlaceholder')} />
)}
</Skeleton>
{createPortal(<TaskDrawer />, document.body)}
</Flex>
);
};
export default MembersReportsTimeLogsTab;

View File

@@ -0,0 +1,61 @@
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 { ISingleMemberLogs } from '@/types/reporting/reporting.types';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
type TimeLogCardProps = {
data: ISingleMemberLogs;
};
const TimeLogCard = ({ data }: TimeLogCardProps) => {
const { t } = useTranslation('reporting-members-drawer');
const dispatch = useAppDispatch();
const handleUpdateTaskDrawer = (id: string, projectId: string) => {
if (!id || !projectId) return;
dispatch(setSelectedTaskId(id));
dispatch(fetchPhasesByProjectId(projectId));
dispatch(fetchTask({ taskId: id, projectId: projectId }));
dispatch(setShowTaskDrawer(true));
};
return (
<ConfigProvider
theme={{
components: {
Timeline: { dotBorderWidth: '2px' },
},
}}
>
<Card
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{simpleDateFormat(data.log_day)}
</Typography.Text>
}
>
<Timeline>
{data.logs.map((log, index) => (
<Timeline.Item key={index} style={{ paddingBottom: 8 }}>
<Typography.Text
className="cursor-pointer hover:text-[#1899ff]"
onClick={() => handleUpdateTaskDrawer(log.task_id, log.project_id)}
>
{t('loggedText')} <strong>{log.time_spent_string}</strong> {t('forText')}{' '}
<strong>{log.task_name}</strong> {t('inText')} <strong>{log.project_name}</strong>{' '}
<Tag>{log.task_key}</Tag>
</Typography.Text>
</Timeline.Item>
))}
</Timeline>
</Card>
</ConfigProvider>
);
};
export default TimeLogCard;

View File

@@ -0,0 +1,143 @@
import { reportingMembersApiService } from '@/api/reporting/reporting-members.api.service';
import { durations } from '@/shared/constants';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
type MembersReportsState = {
isMembersReportsDrawerOpen: boolean;
isMembersOverviewTasksStatsDrawerOpen: boolean;
isMembersOverviewProjectsStatsDrawerOpen: boolean;
activeTab: 'overview' | 'timeLogs' | 'activityLogs' | 'tasks';
total: number;
membersList: any[];
isLoading: boolean;
error: string | null;
// filters
archived: boolean;
searchQuery: string;
index: number;
pageSize: number;
field: string;
order: string;
duration: string;
dateRange: string;
};
const initialState: MembersReportsState = {
isMembersReportsDrawerOpen: false,
isMembersOverviewTasksStatsDrawerOpen: false,
isMembersOverviewProjectsStatsDrawerOpen: false,
activeTab: 'overview',
total: 0,
membersList: [],
isLoading: false,
error: null,
// filters
archived: false,
searchQuery: '',
index: 1,
pageSize: 10,
field: 'name',
order: 'asc',
duration: durations[1].key,
dateRange: '',
};
export const fetchMembersData = createAsyncThunk(
'membersReports/fetchMembersData',
async ({ duration, dateRange }: { duration: string; dateRange: string[] }, { getState }) => {
const state = (getState() as any).membersReportsReducer;
const body = {
index: state.index,
size: state.pageSize,
field: state.field,
order: state.order,
search: state.searchQuery,
archived: state.archived,
duration: duration || state.duration,
date_range: dateRange || state.dateRange,
};
const response = await reportingMembersApiService.getMembers(body);
return response.body;
}
);
const membersReportsSlice = createSlice({
name: 'membersReportsReducer',
initialState,
reducers: {
toggleMembersReportsDrawer: state => {
state.isMembersReportsDrawerOpen = !state.isMembersReportsDrawerOpen;
},
toggleMembersOverviewTasksStatsDrawer: state => {
state.isMembersOverviewTasksStatsDrawerOpen = !state.isMembersOverviewTasksStatsDrawerOpen;
},
toggleMembersOverviewProjectsStatsDrawer: state => {
state.isMembersOverviewProjectsStatsDrawerOpen =
!state.isMembersOverviewProjectsStatsDrawerOpen;
},
setMemberReportingDrawerActiveTab: (
state,
action: PayloadAction<'overview' | 'timeLogs' | 'activityLogs' | 'tasks'>
) => {
state.activeTab = action.payload;
},
setArchived: (state, action) => {
state.archived = action.payload;
},
setSearchQuery: (state, action) => {
state.searchQuery = action.payload;
},
setIndex: (state, action) => {
state.index = action.payload;
},
setPageSize: (state, action) => {
state.pageSize = action.payload;
},
setField: (state, action) => {
state.field = action.payload;
},
setOrder: (state, action) => {
state.order = action.payload;
},
setDuration: (state, action) => {
state.duration = action.payload;
},
setDateRange: (state, action) => {
state.dateRange = action.payload;
},
},
extraReducers: builder => {
builder
.addCase(fetchMembersData.pending, state => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchMembersData.fulfilled, (state, action) => {
state.isLoading = false;
state.membersList = action.payload.members || [];
state.total = action.payload.total || 0;
})
.addCase(fetchMembersData.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message || 'Failed to fetch members data';
});
},
});
export const {
toggleMembersReportsDrawer,
toggleMembersOverviewTasksStatsDrawer,
toggleMembersOverviewProjectsStatsDrawer,
setMemberReportingDrawerActiveTab,
setArchived,
setSearchQuery,
setIndex,
setPageSize,
setField,
setOrder,
setDuration,
setDateRange,
} = membersReportsSlice.actions;
export default membersReportsSlice.reducer;

View File

@@ -0,0 +1,271 @@
import { reportingProjectsApiService } from '@/api/reporting/reporting-projects.api.service';
import { DEFAULT_PAGE_SIZE, FILTER_INDEX_KEY } from '@/shared/constants';
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 { getFromLocalStorage } from '@/utils/localStorageFunctions';
import { createAsyncThunk, createSlice, createAction } from '@reduxjs/toolkit';
const filterIndex = () => {
return +(getFromLocalStorage(FILTER_INDEX_KEY.toString()) || 0);
};
type ProjectReportsState = {
isProjectReportsDrawerOpen: boolean;
isProjectReportsMembersTaskDrawerOpen: boolean;
selectedMember: IRPTOverviewProjectMember | null;
selectedProject: IRPTOverviewProject | null;
projectList: IRPTProject[];
total: number;
isLoading: boolean;
error: string | null;
// filters
index: number;
pageSize: number;
field: string;
order: string;
searchQuery: string;
filterIndex: number;
archived: boolean;
selectedProjectStatuses: IProjectStatus[];
selectedProjectHealths: IProjectHealth[];
selectedProjectCategories: IProjectCategory[];
selectedProjectManagers: IProjectManager[];
};
export const fetchProjectData = createAsyncThunk(
'projectReports/fetchProjectData',
async (_, { getState }) => {
const state = (getState() as any).projectReportsReducer;
const body: IGetProjectsRequestBody = {
index: state.index,
size: state.pageSize,
field: state.field,
order: state.order,
search: state.searchQuery,
filter: state.filterIndex.toString(),
statuses: state.selectedProjectStatuses.map((s: IProjectStatus) => s.id || ''),
healths: state.selectedProjectHealths.map((h: IProjectHealth) => h.id || ''),
categories: state.selectedProjectCategories.map((c: IProjectCategory) => c.id || ''),
project_managers: state.selectedProjectManagers.map((m: IProjectManager) => m.id || ''),
archived: state.archived,
};
const response = await reportingProjectsApiService.getProjects(body);
return response.body;
}
);
export const updateProjectCategory = createAction<{
projectId: string;
category: IProjectCategory;
}>('projectReports/updateProjectCategory');
export const updateProjectStatus = createAction<{
projectId: string;
status: IProjectStatus;
}>('projectReports/updateProjectStatus');
const initialState: ProjectReportsState = {
isProjectReportsDrawerOpen: false,
isProjectReportsMembersTaskDrawerOpen: false,
selectedMember: null,
selectedProject: null,
projectList: [],
total: 0,
isLoading: false,
error: null,
// filters
index: 1,
pageSize: 10,
field: 'name',
order: 'asc',
searchQuery: '',
filterIndex: filterIndex(),
archived: false,
selectedProjectStatuses: [],
selectedProjectHealths: [],
selectedProjectCategories: [],
selectedProjectManagers: [],
};
const projectReportsSlice = createSlice({
name: 'projectReportsReducer',
initialState,
reducers: {
toggleProjectReportsDrawer: state => {
state.isProjectReportsDrawerOpen = !state.isProjectReportsDrawerOpen;
},
toggleProjectReportsMembersTaskDrawer: state => {
state.isProjectReportsMembersTaskDrawerOpen = !state.isProjectReportsMembersTaskDrawerOpen;
},
setSearchQuery: (state, action) => {
state.searchQuery = action.payload;
state.index = 1;
},
setSelectedProjectStatuses: (state, action) => {
state.selectedProjectStatuses = action.payload;
},
setSelectedProjectHealths: (state, action) => {
state.selectedProjectHealths = action.payload;
},
setSelectedProjectCategories: (state, action) => {
const category = action.payload;
const index = state.selectedProjectCategories.findIndex(c => c.id === category.id);
if (index >= 0) {
state.selectedProjectCategories.splice(index, 1);
} else {
state.selectedProjectCategories.push(category);
}
},
setSelectedProjectManagers: (state, action) => {
const manager = action.payload;
const index = state.selectedProjectManagers.findIndex(m => m.id === manager.id);
if (index >= 0) {
state.selectedProjectManagers.splice(index, 1);
} else {
state.selectedProjectManagers.push(manager);
}
},
setArchived: (state, action) => {
state.archived = action.payload;
},
setIndex: (state, action) => {
state.index = action.payload;
},
setPageSize: (state, action) => {
state.pageSize = action.payload;
},
setField: (state, action) => {
state.field = action.payload;
},
setOrder: (state, action) => {
state.order = action.payload;
},
setProjectHealth: (state, action) => {
const health = action.payload;
const project = state.projectList.find(p => p.id === health.id);
if (project) {
project.project_health = health.id;
project.health_name = health.name;
project.health_color = health.color_code;
}
},
setProjectStatus: (state, action) => {
const status = action.payload;
const project = state.projectList.find(p => p.id === status.id);
if (project) {
project.status_id = status.id;
project.status_name = status.name;
project.status_color = status.color_code;
}
},
setProjectStartDate: (state, action) => {
const project = state.projectList.find(p => p.id === action.payload.id);
if (project) {
project.start_date = action.payload.start_date;
}
},
setProjectEndDate: (state, action) => {
const project = state.projectList.find(p => p.id === action.payload.id);
if (project) {
project.end_date = action.payload.end_date;
}
},
setSelectedMember: (state, action) => {
state.selectedMember = action.payload;
},
setSelectedProject: (state, action) => {
state.selectedProject = action.payload;
},
setSelectedProjectCategory: (state, action) => {
const category = action.payload;
const project = state.projectList.find(p => p.id === category.id);
if (project) {
project.category_id = category.id;
project.category_name = category.name;
project.category_color = category.color_code;
}
},
resetProjectReports: state => {
state.projectList = [];
state.total = 0;
state.isLoading = false;
state.error = null;
state.index = 1;
state.pageSize = 10;
state.field = 'name';
state.order = 'asc';
state.searchQuery = '';
state.filterIndex = filterIndex();
state.archived = false;
},
},
extraReducers: builder => {
builder
.addCase(fetchProjectData.pending, state => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchProjectData.fulfilled, (state, action) => {
state.isLoading = false;
state.total = action.payload.total || 0;
state.projectList = action.payload.projects || [];
})
.addCase(fetchProjectData.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message || 'Failed to fetch project data';
})
.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 ?? '';
state.projectList[projectIndex].category_color = category.color_code ?? '';
}
})
.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 ?? '';
state.projectList[projectIndex].status_color = status.color_code ?? '';
}
});
},
});
export const {
toggleProjectReportsDrawer,
toggleProjectReportsMembersTaskDrawer,
setSearchQuery,
setSelectedProjectStatuses,
setSelectedProjectHealths,
setSelectedProjectCategories,
setSelectedProjectManagers,
setArchived,
setProjectStartDate,
setProjectEndDate,
setIndex,
setPageSize,
setField,
setOrder,
setProjectHealth,
setProjectStatus,
setSelectedMember,
setSelectedProject,
setSelectedProjectCategory,
resetProjectReports,
} = projectReportsSlice.actions;
export default projectReportsSlice.reducer;

View File

@@ -0,0 +1,45 @@
import { PROJECT_LIST_COLUMNS } from '@/shared/constants';
import { getJSONFromLocalStorage, saveJSONToLocalStorage, saveToLocalStorage } from '@/utils/localStorageFunctions';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
type ColumnsVisibilityState = {
[key: string]: boolean;
};
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,
};
};
const initialState: ColumnsVisibilityState = getInitialState();
const projectReportsTableColumnsSlice = createSlice({
name: 'projectReportsTableColumns',
initialState,
reducers: {
toggleColumnHidden: (state, action: PayloadAction<string>) => {
const columnKey = action.payload;
if (columnKey in state) {
state[columnKey] = !state[columnKey];
}
saveJSONToLocalStorage(PROJECT_LIST_COLUMNS, state);
},
},
});
export const { toggleColumnHidden } = projectReportsTableColumnsSlice.actions;
export default projectReportsTableColumnsSlice.reducer;

View File

@@ -0,0 +1,73 @@
import { Drawer, Typography, Flex, Button, Dropdown } from 'antd';
import React, { useState } from 'react';
import { useAppSelector } from '../../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
import { setSelectedProject, toggleProjectReportsDrawer } from '../project-reports-slice';
import { BankOutlined, DownOutlined } from '@ant-design/icons';
import ProjectReportsDrawerTabs from './ProjectReportsDrawerTabs';
import { colors } from '../../../../styles/colors';
import { useTranslation } from 'react-i18next';
import { IRPTProject } from '@/types/reporting/reporting.types';
type ProjectReportsDrawerProps = {
selectedProject: IRPTProject | null;
};
const ProjectReportsDrawer = ({ selectedProject }: ProjectReportsDrawerProps) => {
const { t } = useTranslation('reporting-projects-drawer');
const dispatch = useAppDispatch();
// get drawer open state and project list from the reducer
const isDrawerOpen = useAppSelector(
state => state.projectReportsReducer.isProjectReportsDrawerOpen
);
const { projectList } = useAppSelector(state => state.projectReportsReducer);
// function to handle drawer close
const handleClose = () => {
dispatch(toggleProjectReportsDrawer());
};
const handleAfterOpenChange = (open: boolean) => {
if (open) {
dispatch(setSelectedProject(selectedProject));
}
};
return (
<Drawer
open={isDrawerOpen}
onClose={handleClose}
afterOpenChange={handleAfterOpenChange}
destroyOnClose
width={900}
title={
<Flex align="center" justify="space-between">
<Flex gap={8} align="center" style={{ fontWeight: 500 }}>
<BankOutlined style={{ color: colors.lightGray }} />
<Typography.Text>/</Typography.Text>
<Typography.Text>{selectedProject?.name}</Typography.Text>
</Flex>
<Dropdown
menu={{
items: [
{ key: '1', label: t('membersButton') },
{ key: '2', label: t('tasksButton') },
],
}}
>
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
{t('exportButton')}
</Button>
</Dropdown>
</Flex>
}
>
{selectedProject && <ProjectReportsDrawerTabs projectId={selectedProject.id} />}
</Drawer>
);
};
export default ProjectReportsDrawer;

View File

@@ -0,0 +1,38 @@
import { Tabs } from 'antd';
import { TabsProps } from 'antd/lib';
import { useTranslation } from 'react-i18next';
import ProjectReportsOverviewTab from './overviewTab/ProjectReportsOverviewTab';
import ProjectReportsMembersTab from './membersTab/ProjectReportsMembersTab';
import ProjectReportsTasksTab from './tasksTab/ProjectReportsTasksTab';
type ProjectReportsDrawerProps = {
projectId?: string | null;
};
const ProjectReportsDrawerTabs = ({ projectId = null }: ProjectReportsDrawerProps) => {
// localization
const { t } = useTranslation('reporting-projects-drawer');
const tabItems: TabsProps['items'] = [
{
key: 'overview',
label: t('overviewTab'),
children: <ProjectReportsOverviewTab projectId={projectId} />,
},
{
key: 'members',
label: t('membersTab'),
children: <ProjectReportsMembersTab projectId={projectId} />,
},
{
key: 'tasks',
label: t('tasksTab'),
children: <ProjectReportsTasksTab projectId={projectId} />,
},
];
return <Tabs type="card" items={tabItems} destroyInactiveTabPane />;
};
export default ProjectReportsDrawerTabs;

View File

@@ -0,0 +1,57 @@
import { Flex } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import CustomSearchbar from '../../../../../components/CustomSearchbar';
import ProjectReportsMembersTable from './ProjectReportsMembersTable';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewProjectMember } from '@/types/reporting/reporting.types';
import { reportingProjectsApiService } from '@/api/reporting/reporting-projects.api.service';
type ProjectReportsMembersTabProps = {
projectId?: string | null;
};
const ProjectReportsMembersTab = ({ projectId = null }: ProjectReportsMembersTabProps) => {
const { t } = useTranslation('reporting-projects-drawer');
const [membersData, setMembersData] = useState<IRPTOverviewProjectMember[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState<string>('');
const filteredMembersData = useMemo(() => {
return membersData.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()));
}, [searchQuery, membersData]);
const fetchMembersData = async () => {
if (!projectId || loading) return;
try {
setLoading(true);
const res = await reportingProjectsApiService.getProjectMembers(projectId);
if (res.done) {
setMembersData(res.body);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMembersData();
}, [projectId]);
return (
<Flex vertical gap={24}>
<CustomSearchbar
placeholderText={t('searchByNameInputPlaceholder')}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
<ProjectReportsMembersTable membersData={filteredMembersData} loading={loading} />
</Flex>
);
};
export default ProjectReportsMembersTab;

View File

@@ -0,0 +1,115 @@
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 { useTranslation } from 'react-i18next';
import ProjectReportsMembersTaskDrawer from './projectReportsMembersTaskDrawer/ProjectReportsMembersTaskDrawer';
import { createPortal } from 'react-dom';
import { IRPTOverviewProjectMember } from '@/types/reporting/reporting.types';
type ProjectReportsMembersTableProps = {
membersData: any[];
loading: boolean;
};
const ProjectReportsMembersTable = ({ membersData, loading }: ProjectReportsMembersTableProps) => {
// localization
const { t } = useTranslation('reporting-projects-drawer');
const dispatch = useAppDispatch();
// function to handle task drawer open
const handleProjectReportsMembersTaskDrawer = (record: IRPTOverviewProjectMember) => {
dispatch(setSelectedMember(record));
dispatch(toggleProjectReportsMembersTaskDrawer());
};
const columns: TableColumnsType = [
{
key: 'name',
title: <CustomTableTitle title={t('nameColumn')} />,
onCell: (record: any) => {
return {
onClick: () => handleProjectReportsMembersTaskDrawer(record as IRPTOverviewProjectMember),
};
},
dataIndex: 'name',
width: 260,
className: 'group-hover:text-[#1890ff]',
fixed: 'left' as const,
},
{
key: 'tasksCount',
title: <CustomTableTitle title={t('tasksCountColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'tasks_count',
width: 120,
},
{
key: 'completedTasks',
title: <CustomTableTitle title={t('completedTasksColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'completed',
width: 120,
},
{
key: 'incompleteTasks',
title: <CustomTableTitle title={t('incompleteTasksColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'incompleted',
width: 120,
},
{
key: 'overdueTasks',
title: <CustomTableTitle title={t('overdueTasksColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overdue',
width: 120,
},
{
key: 'contribution',
title: <CustomTableTitle title={t('contributionColumn')} />,
render: record => {
return <Progress percent={record.contribution} />;
},
width: 180,
},
{
key: 'progress',
title: <CustomTableTitle title={t('progressColumn')} />,
render: record => {
return <Progress percent={record.progress} />;
},
width: 180,
},
{
key: 'loggedTime',
title: <CustomTableTitle title={t('loggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'time_logged',
width: 120,
},
];
return (
<>
<Table
columns={columns}
dataSource={membersData}
pagination={false}
scroll={{ x: 'max-content' }}
loading={loading}
onRow={record => {
return {
style: { height: 38, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
/>
{createPortal(<ProjectReportsMembersTaskDrawer />, document.body, 'project-reports-members-task-drawer')}
</>
);
};
export default ProjectReportsMembersTable;

View File

@@ -0,0 +1,72 @@
import { Drawer, Typography, Flex, Button } from 'antd';
import React, { useMemo, useState } from 'react';
import { FileOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { toggleProjectReportsMembersTaskDrawer } from '../../../project-reports-slice';
import { colors } from '@/styles/colors';
import ProjectReportsMembersTasksTable from './ProjectReportsMembersTaskTable';
import CustomSearchbar from '@/components/CustomSearchbar';
const ProjectReportsMembersTaskDrawer = () => {
const { t } = useTranslation('reporting-projects-drawer');
const dispatch = useAppDispatch();
const [taskData, setTaskData] = useState<any[]>([]);
const [searchQuery, setSearchQuery] = useState<string>('');
const { isProjectReportsMembersTaskDrawerOpen, selectedProject, selectedMember } = useAppSelector(
state => state.projectReportsReducer
);
const handleClose = () => {
dispatch(toggleProjectReportsMembersTaskDrawer());
};
const handleAfterOpenChange = (open: boolean) => {
if (open) {
}
};
const filteredTaskData = useMemo(() => {
return taskData.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()));
}, [searchQuery, taskData]);
return (
<Drawer
open={isProjectReportsMembersTaskDrawerOpen}
onClose={handleClose}
afterOpenChange={handleAfterOpenChange}
destroyOnClose
width={900}
title={
<Flex align="center" justify="space-between">
<Flex gap={8} align="center" style={{ fontWeight: 500 }}>
<FileOutlined style={{ color: colors.lightGray }} />
<Typography.Text>{selectedProject?.name} /</Typography.Text>
<Typography.Text>{selectedMember?.name}</Typography.Text>
</Flex>
<Button type="primary">{t('exportButton')}</Button>
</Flex>
}
>
<Flex vertical gap={24}>
<CustomSearchbar
placeholderText={t('searchByNameInputPlaceholder')}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
<ProjectReportsMembersTasksTable
tasksData={filteredTaskData}
/>
</Flex>
</Drawer>
);
};
export default ProjectReportsMembersTaskDrawer;

View File

@@ -0,0 +1,145 @@
import { useTranslation } from 'react-i18next';
import React from 'react';
import { createPortal } from 'react-dom';
import { Badge, Flex, Table, TableColumnsType, Tag, Typography } from 'antd';
import dayjs from 'dayjs';
import { DoubleRightOutlined } from '@ant-design/icons';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import CustomTableTitle from '@components/CustomTableTitle';
import { colors } from '@/styles/colors';
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
type ProjectReportsMembersTasksTableProps = {
tasksData: any[];
};
const ProjectReportsMembersTasksTable = ({ tasksData }: ProjectReportsMembersTasksTableProps) => {
// localization
const { t } = useTranslation('reporting-projects-drawer');
const dispatch = useAppDispatch();
// function to handle task drawer open
const handleUpdateTaskDrawer = (id: string) => {
dispatch(setShowTaskDrawer(true));
};
const columns: TableColumnsType = [
{
key: 'task',
title: <CustomTableTitle title={t('taskColumn')} />,
onCell: record => {
return {
onClick: () => handleUpdateTaskDrawer(record.id),
};
},
render: record => (
<Flex>
{Number(record.sub_tasks_count) > 0 && <DoubleRightOutlined />}
<Typography.Text className="group-hover:text-[#1890ff]">{record.name}</Typography.Text>
</Flex>
),
width: 260,
fixed: 'left' as const,
},
{
key: 'project',
title: <CustomTableTitle title={t('projectColumn')} />,
render: record => (
<Flex gap={8} align="center">
<Badge color={record.project_color} />
<Typography.Text>{record.project_name}</Typography.Text>
</Flex>
),
width: 120,
},
{
key: 'status',
title: <CustomTableTitle title={t('statusColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.status_color}
children={record.status_name}
/>
),
width: 120,
},
{
key: 'priority',
title: <CustomTableTitle title={t('priorityColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.priority_color}
children={record.priority_name}
/>
),
width: 120,
},
{
key: 'dueDate',
title: <CustomTableTitle title={t('dueDateColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.end_date ? `${dayjs(record.end_date).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'completedDate',
title: <CustomTableTitle title={t('completedDateColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.completed_at ? `${dayjs(record.completed_at).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'estimatedTime',
title: <CustomTableTitle title={t('estimatedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'estimated_string',
width: 130,
},
{
key: 'loggedTime',
title: <CustomTableTitle title={t('loggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'time_spent_string',
width: 130,
},
{
key: 'overloggedTime',
title: <CustomTableTitle title={t('overloggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overlogged_time',
width: 150,
},
];
return (
<>
<Table
columns={columns}
dataSource={tasksData}
pagination={false}
scroll={{ x: 'max-content' }}
onRow={record => {
return {
style: { height: 38, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
/>
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
</>
);
};
export default ProjectReportsMembersTasksTable;

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip } from 'chart.js';
import { Badge, Card, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewProjectTasksByDue } from '@/types/reporting/reporting.types';
Chart.register(ArcElement, Tooltip);
const ProjectReportsDueDateGraph = ({
values,
loading,
}: {
values: IRPTOverviewProjectTasksByDue;
loading: boolean;
}) => {
const { t } = useTranslation('reporting-projects-drawer');
// chart data
const chartData = {
labels: values.chart.map(item => t(`${item.name}`)),
datasets: [
{
label: t('tasksText'),
data: values.chart.map(item => item.y),
backgroundColor: values.chart.map(item => item.color),
},
],
};
const options = {
responsive: true,
plugins: {
legend: {
display: false,
position: 'top' as const,
},
datalabels: {
display: false,
},
},
};
return (
<Card
loading={loading}
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasksByDueDateText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#000" />
<Typography.Text ellipsis>
{t('allText')} ({values.all})
</Typography.Text>
</Flex>
{/* due Date-specific tasks */}
{values.chart.map(item => (
<Flex key={item.name} gap={4} align="center">
<Badge color={item.color} />
<Typography.Text ellipsis>
{t(`${item.name}`)} ({item.y})
</Typography.Text>
</Flex>
))}
</div>
</div>
</Card>
);
};
export default ProjectReportsDueDateGraph;

View File

@@ -0,0 +1,92 @@
import React, { useEffect, useState } from 'react';
import ProjectReportsStatCard from './ProjectReportsStatCard';
import ProjectReportsStatusGraph from './ProjectReportsStatusGraph';
import ProjectReportsPriorityGraph from './ProjectReportsPriorityGraph';
import ProjectReportsDueDateGraph from './ProjectReportsDueDateGraph';
import { reportingProjectsApiService } from '@/api/reporting/reporting-projects.api.service';
import { IRPTOverviewProjectInfo } from '@/types/reporting/reporting.types';
type ProjectReportsOverviewTabProps = {
projectId?: string | null;
};
const ProjectReportsOverviewTab = ({ projectId = null }: ProjectReportsOverviewTabProps) => {
const [overviewData, setOverviewData] = useState<IRPTOverviewProjectInfo | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const fetchOverviewData = async () => {
if (!projectId || loading) return;
try {
setLoading(true);
const res = await reportingProjectsApiService.getProjectOverview(projectId);
if (res.done) {
setOverviewData(res.body);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchOverviewData();
}, [projectId]);
return (
<div className="grid gap-4 sm:grid-cols-2">
<ProjectReportsStatCard
loading={loading}
values={
overviewData?.stats || {
completed: 0,
incompleted: 0,
overdue: 0,
total_allocated: 0,
total_logged: 0,
}
}
/>
<ProjectReportsStatusGraph
loading={loading}
values={
overviewData?.by_status || {
todo: 0,
doing: 0,
done: 0,
all: 0,
chart: [],
}
}
/>
<ProjectReportsPriorityGraph
loading={loading}
values={
overviewData?.by_priority || {
high: 0,
medium: 0,
low: 0,
all: 0,
chart: [],
}
}
/>
<ProjectReportsDueDateGraph
loading={loading}
values={
overviewData?.by_due || {
completed: 0,
upcoming: 0,
overdue: 0,
no_due: 0,
all: 0,
chart: [],
}
}
/>
</div>
);
};
export default ProjectReportsOverviewTab;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip } from 'chart.js';
import { Badge, Card, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewProjectTasksByPriority } from '@/types/reporting/reporting.types';
Chart.register(ArcElement, Tooltip);
const ProjectReportsPriorityGraph = ({
values,
loading,
}: {
values: IRPTOverviewProjectTasksByPriority;
loading: boolean;
}) => {
// localization
const { t } = useTranslation('reporting-projects-drawer');
type PriorityGraphItemType = {
name: string;
color: string;
count: number;
};
// chart data
const chartData = {
labels: values.chart.map(item => t(`${item.name}`)),
datasets: [
{
label: t('tasksText'),
data: values.chart.map(item => item.y),
backgroundColor: values.chart.map(item => item.color),
},
],
};
const options = {
responsive: true,
plugins: {
legend: {
display: false,
position: 'top' as const,
},
datalabels: {
display: false,
},
},
};
return (
<Card
loading={loading}
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasksByPriorityText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#000" />
<Typography.Text ellipsis>
{t('allText')} ({values.all})
</Typography.Text>
</Flex>
{/* priority-specific tasks */}
{values.chart.map(item => (
<Flex key={item.name} gap={4} align="center">
<Badge color={item.color} />
<Typography.Text ellipsis>
{t(`${item.name}`)} ({item.y})
</Typography.Text>
</Flex>
))}
</div>
</div>
</Card>
);
};
export default ProjectReportsPriorityGraph;

View File

@@ -0,0 +1,74 @@
import {
CheckCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
FileExcelOutlined,
} from '@ant-design/icons';
import { Card, Flex, Typography } from 'antd';
import React, { ReactNode } from 'react';
import { colors } from '../../../../../styles/colors';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewProjectTasksStats } from '@/types/reporting/reporting.types';
const ProjectReportsStatCard = ({
values,
loading,
}: {
values: IRPTOverviewProjectTasksStats;
loading: boolean;
}) => {
// localization
const { t } = useTranslation('reporting-projects-drawer');
type StatItemsType = {
name: string;
icon: ReactNode;
value: number;
};
// stat items array
const statItems: StatItemsType[] = [
{
name: 'completedTasks',
icon: <CheckCircleOutlined style={{ fontSize: 24, color: '#75c997' }} />,
value: values.completed || 0,
},
{
name: 'incompleteTasks',
icon: <FileExcelOutlined style={{ fontSize: 24, color: '#f6ce69' }} />,
value: values.incompleted || 0,
},
{
name: 'overdueTasks',
icon: <ExclamationCircleOutlined style={{ fontSize: 24, color: '#eb6363' }} />,
value: values.overdue || 0,
},
{
name: 'allocatedHours',
icon: <ClockCircleOutlined style={{ fontSize: 24, color: colors.skyBlue }} />,
value: values.total_allocated || 0,
},
{
name: 'loggedHours',
icon: <ClockCircleOutlined style={{ fontSize: 24, color: '#75c997' }} />,
value: values.total_logged || 0,
},
];
return (
<Card style={{ width: '100%' }} loading={loading}>
<Flex vertical gap={16} style={{ padding: '12px 24px' }}>
{statItems.map(item => (
<Flex gap={12} align="center">
{item.icon}
<Typography.Text>
{item.value} {t(`${item.name}Text`)}
</Typography.Text>
</Flex>
))}
</Flex>
</Card>
);
};
export default ProjectReportsStatCard;

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip } from 'chart.js';
import { Badge, Card, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { IRPTOverviewProjectTasksByStatus } from '@/types/reporting/reporting.types';
Chart.register(ArcElement, Tooltip);
const ProjectReportsStatusGraph = ({
values,
loading,
}: {
values: IRPTOverviewProjectTasksByStatus;
loading: boolean;
}) => {
const { t } = useTranslation('reporting-projects-drawer');
const chartData = {
labels: values.chart.map(item => t(`${item.name}Text`)),
datasets: [
{
label: t('tasksText'),
data: values.chart.map(item => item.y),
backgroundColor: values.chart.map(item => item.color),
},
],
};
const options = {
responsive: true,
plugins: {
legend: {
display: false,
position: 'top' as const,
},
datalabels: {
display: false,
},
},
};
return (
<Card
loading={loading}
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasksByStatusText')}
</Typography.Text>
}
>
<div className="flex flex-wrap items-center justify-center gap-6 xl:flex-nowrap">
<Doughnut
data={chartData}
options={options}
className="max-h-[200px] w-full max-w-[200px]"
/>
<div className="flex flex-row flex-wrap gap-3 xl:flex-col">
{/* total tasks */}
<Flex gap={4} align="center">
<Badge color="#000" />
<Typography.Text ellipsis>
{t('allText')} ({values.all})
</Typography.Text>
</Flex>
{/* status-specific tasks */}
{values.chart.map(item => (
<Flex key={item.name} gap={4} align="center">
<Badge color={item.color} />
<Typography.Text ellipsis>
{t(`${item.name}`)}({item.y})
</Typography.Text>
</Flex>
))}
</div>
</div>
</Card>
);
};
export default ProjectReportsStatusGraph;

View File

@@ -0,0 +1,199 @@
import { Badge, Collapse, Flex, Table, TableColumnsType, Tag, Typography } from 'antd';
import { useEffect } from 'react';
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 { DoubleRightOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
import { getTeamMembers } from '@/features/team-members/team-members.slice';
import { setProjectId } from '@/features/project/project.slice';
type ProjectReportsTasksTableProps = {
tasksData: any[];
title: string;
color: string;
type: string;
projectId: string;
};
const ProjectReportsTasksTable = ({
tasksData,
title,
color,
type,
projectId,
}: ProjectReportsTasksTableProps) => {
// localization
const { t } = useTranslation('reporting-projects-drawer');
const dispatch = useAppDispatch();
useEffect(()=>{
dispatch(fetchPriorities());
dispatch(fetchLabels());
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) => {
if (!id && !projectId) return;
dispatch(setSelectedTaskId(id));
dispatch(setProjectId(projectId));
dispatch(fetchPhasesByProjectId(projectId));
dispatch(fetchTask({ taskId: id, projectId: projectId }));
dispatch(setShowTaskDrawer(true));
};
const columns: TableColumnsType = [
{
key: 'task',
title: <CustomTableTitle title={t('taskColumn')} />,
onCell: record => {
return {
onClick: () => handleUpdateTaskDrawer(record.id),
};
},
render: record => (
<Flex>
{Number(record.sub_tasks_count) > 0 && <DoubleRightOutlined />}
<Typography.Text className="group-hover:text-[#1890ff]">{record.name}</Typography.Text>
</Flex>
),
width: 260,
className: 'group-hover:text-[#1890ff]',
fixed: 'left' as const,
},
{
key: 'status',
title: <CustomTableTitle title={t('statusColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.status_color}
children={record.status_name}
/>
),
width: 120,
},
{
key: 'priority',
title: <CustomTableTitle title={t('priorityColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.priority_color}
children={record.priority_name}
/>
),
width: 120,
},
{
key: 'phase',
title: <CustomTableTitle title={t('phaseColumn')} />,
render: record => (
<Tag
style={{ color: colors.darkGray, borderRadius: 48 }}
color={record.phase_color}
children={record.phase_name}
/>
),
width: 120,
},
{
key: 'dueDate',
title: <CustomTableTitle title={t('dueDateColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.end_date ? `${dayjs(record.end_date).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'completedOn',
title: <CustomTableTitle title={t('completedOnColumn')} />,
render: record => (
<Typography.Text className="text-center group-hover:text-[#1890ff]">
{record.completed_at ? `${dayjs(record.completed_at).format('MMM DD, YYYY')}` : '-'}
</Typography.Text>
),
width: 120,
},
{
key: 'daysOverdue',
title: <CustomTableTitle title={t('daysOverdueColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overdue_days',
width: 120,
},
{
key: 'estimatedTime',
title: <CustomTableTitle title={t('estimatedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'total_time_string',
width: 130,
},
{
key: 'loggedTime',
title: <CustomTableTitle title={t('loggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'time_spent_string',
width: 130,
},
{
key: 'overloggedTime',
title: <CustomTableTitle title={t('overloggedTimeColumn')} />,
className: 'text-center group-hover:text-[#1890ff]',
dataIndex: 'overlogged_time_string',
width: 150,
},
];
// conditionaly show columns with the group type
const visibleColumns = () => {
if (type === 'status') return columns.filter(el => el.key !== 'status');
else if (type === 'priority') return columns.filter(el => el.key !== 'priority');
else if (type === 'phase') return columns.filter(el => el.key !== 'phase');
else return columns;
};
return (
<Collapse
bordered={false}
ghost={true}
size="small"
items={[
{
key: '1',
label: (
<Flex gap={8} align="center">
<Badge color={color} />
<Typography.Text strong>{`${title} (${tasksData.length})`}</Typography.Text>
</Flex>
),
children: (
<Table
columns={visibleColumns()}
dataSource={tasksData}
pagination={false}
scroll={{ x: 'max-content' }}
onRow={record => {
return {
style: { height: 38, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
/>
),
},
]}
/>
);
};
export default ProjectReportsTasksTable;

View File

@@ -0,0 +1,90 @@
import { Flex } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import CustomSearchbar from '@components/CustomSearchbar';
import GroupByFilter from './group-by-filter';
import ProjectReportsTasksTable from './ProjectReportsTaskTable';
import { fetchData } from '@/utils/fetchData';
import { useTranslation } from 'react-i18next';
import logger from '@/utils/errorLogger';
import { reportingProjectsApiService } from '@/api/reporting/reporting-projects.api.service';
import { IGroupByOption, ITaskListGroup } from '@/types/tasks/taskList.types';
import { GROUP_BY_STATUS_VALUE, IGroupBy } from '@/features/board/board-slice';
import { createPortal } from 'react-dom';
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
type ProjectReportsTasksTabProps = {
projectId?: string | null;
};
const ProjectReportsTasksTab = ({ projectId = null }: ProjectReportsTasksTabProps) => {
const [searchQuery, setSearhQuery] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [groups, setGroups] = useState<ITaskListGroup[]>([]);
const [groupBy, setGroupBy] = useState<IGroupBy>(GROUP_BY_STATUS_VALUE);
const { t } = useTranslation('reporting-projects-drawer');
const filteredGroups = useMemo(() => {
return groups
.filter(item => item.tasks.length > 0)
.map(item => ({
...item,
tasks: item.tasks.filter(task =>
task.name?.toLowerCase().includes(searchQuery.toLowerCase())
)
}))
.filter(item => item.tasks.length > 0);
}, [groups, searchQuery]);
const fetchTasksData = async () => {
if (!projectId || loading) return;
try {
setLoading(true);
const res = await reportingProjectsApiService.getTasks(projectId, groupBy);
if (res.done) {
setGroups(res.body);
}
} catch (error) {
logger.error('Error fetching tasks data', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasksData();
}, [projectId, groupBy]);
return (
<Flex vertical gap={24}>
<Flex gap={24} align="center" justify="space-between">
<CustomSearchbar
placeholderText={t('searchByNameInputPlaceholder')}
searchQuery={searchQuery}
setSearchQuery={setSearhQuery}
/>
<GroupByFilter setActiveGroup={setGroupBy} />
</Flex>
<Flex vertical gap={12}>
{filteredGroups.map(item => (
<ProjectReportsTasksTable
key={item.id}
tasksData={item.tasks}
title={item.name}
color={item.color_code}
type={groupBy}
projectId={projectId || ''}
/>
))}
</Flex>
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
</Flex>
);
};
export default ProjectReportsTasksTab;

View File

@@ -0,0 +1,42 @@
import { IGroupBy } from '@/features/board/board-slice';
import { CaretDownFilled } from '@ant-design/icons';
import { Flex, Select } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
type GroupByFilterProps = {
setActiveGroup: (group: IGroupBy) => void;
};
const GroupByFilter = ({ setActiveGroup }: GroupByFilterProps) => {
// localization
const { t } = useTranslation('reporting-projects-drawer');
const handleChange = (value: string) => {
setActiveGroup(value as IGroupBy);
};
const groupDropdownMenuItems = [
{ key: 'status', value: 'status', label: t('statusText') },
{ key: 'priority', value: 'priority', label: t('priorityText') },
{
key: 'phase',
value: 'phase',
label: t('phaseText'),
},
];
return (
<Flex align="center" gap={4} style={{ marginInlineStart: 12 }}>
{t('groupByText')}
<Select
defaultValue={'status'}
options={groupDropdownMenuItems}
onChange={handleChange}
suffixIcon={<CaretDownFilled />}
/>
</Flex>
);
};
export default GroupByFilter;

View File

@@ -0,0 +1,66 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface ReportingState {
includeArchivedProjects: boolean;
selectedProjectIds: string[];
selectedTeamIds: string[];
showOverViewTeamDrawer: boolean;
duration: string;
dateRange: string[];
currentOrganization: string;
}
const initialState: ReportingState = {
includeArchivedProjects: false,
selectedProjectIds: [],
selectedTeamIds: [],
showOverViewTeamDrawer: false,
duration: 'LAST_WEEK', // Default value
dateRange: [],
currentOrganization: '',
};
const reportingSlice = createSlice({
name: 'reporting',
initialState,
reducers: {
toggleIncludeArchived: state => {
state.includeArchivedProjects = !state.includeArchivedProjects;
},
setSelectedProjects: (state, action: PayloadAction<string[]>) => {
state.selectedProjectIds = action.payload;
},
setSelectedTeams: (state, action: PayloadAction<string[]>) => {
state.selectedTeamIds = action.payload;
},
clearSelections: state => {
state.selectedProjectIds = [];
state.selectedTeamIds = [];
},
toggleOverViewTeamDrawer: state => {
state.showOverViewTeamDrawer = !state.showOverViewTeamDrawer;
},
setDuration: (state, action: PayloadAction<string>) => {
state.duration = action.payload;
},
setDateRange: (state, action: PayloadAction<string[]>) => {
state.dateRange = action.payload;
},
setCurrentOrganization: (state, action: PayloadAction<string>) => {
state.currentOrganization = action.payload;
},
},
});
export const {
toggleIncludeArchived,
setSelectedProjects,
setSelectedTeams,
clearSelections,
toggleOverViewTeamDrawer,
setDuration,
setDateRange,
setCurrentOrganization,
} = reportingSlice.actions;
export default reportingSlice.reducer;

View File

@@ -0,0 +1,203 @@
import { reportingApiService } from '@/api/reporting/reporting.api.service';
import {
ISelectableCategory,
ISelectableProject,
ISelectableTeam,
} from '@/types/reporting/reporting-filters.types';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
interface ITimeReportsOverviewState {
archived: boolean;
teams: ISelectableTeam[];
loadingTeams: boolean;
categories: ISelectableCategory[];
noCategory: boolean;
loadingCategories: boolean;
projects: ISelectableProject[];
loadingProjects: boolean;
billable: {
billable: boolean;
nonBillable: boolean;
};
}
const initialState: ITimeReportsOverviewState = {
archived: false,
teams: [],
loadingTeams: false,
categories: [],
noCategory: true,
loadingCategories: false,
projects: [],
loadingProjects: false,
billable: {
billable: true,
nonBillable: true,
},
};
const selectedTeams = (state: ITimeReportsOverviewState) => {
return state.teams.filter(team => team.selected).map(team => team.id) as string[];
};
const selectedCategories = (state: ITimeReportsOverviewState) => {
return state.categories
.filter(category => category.selected)
.map(category => category.id) as string[];
};
export const fetchReportingTeams = createAsyncThunk(
'timeReportsOverview/fetchReportingTeams',
async () => {
const res = await reportingApiService.getOverviewTeams();
return res.body;
}
);
export const fetchReportingCategories = createAsyncThunk(
'timeReportsOverview/fetchReportingCategories',
async (_, { rejectWithValue, getState, dispatch }) => {
const state = getState() as { timeReportsOverviewReducer: ITimeReportsOverviewState };
const { timeReportsOverviewReducer } = state;
const res = await reportingApiService.getCategories(selectedTeams(timeReportsOverviewReducer));
return res.body;
}
);
export const fetchReportingProjects = createAsyncThunk(
'timeReportsOverview/fetchReportingProjects',
async (_, { rejectWithValue, getState, dispatch }) => {
const state = getState() as { timeReportsOverviewReducer: ITimeReportsOverviewState };
const { timeReportsOverviewReducer } = state;
const res = await reportingApiService.getAllocationProjects(
selectedTeams(timeReportsOverviewReducer),
selectedCategories(timeReportsOverviewReducer),
timeReportsOverviewReducer.noCategory
);
return res.body;
}
);
const timeReportsOverviewSlice = createSlice({
name: 'timeReportsOverview',
initialState,
reducers: {
setTeams: (state, action) => {
state.teams = action.payload;
},
setSelectOrDeselectAllTeams: (state, action) => {
state.teams.forEach(team => {
team.selected = action.payload;
});
},
setSelectOrDeselectTeam: (state, action: PayloadAction<{ id: string; selected: boolean }>) => {
const team = state.teams.find(team => team.id === action.payload.id);
if (team) {
team.selected = action.payload.selected;
}
},
setSelectOrDeselectCategory: (
state,
action: PayloadAction<{ id: string; selected: boolean }>
) => {
const category = state.categories.find(category => category.id === action.payload.id);
if (category) {
category.selected = action.payload.selected;
}
},
setSelectOrDeselectAllCategories: (state, action) => {
state.categories.forEach(category => {
category.selected = action.payload;
});
},
setSelectOrDeselectProject: (state, action) => {
const project = state.projects.find(project => project.id === action.payload.id);
if (project) {
project.selected = action.payload.selected;
}
},
setSelectOrDeselectAllProjects: (state, action) => {
state.projects.forEach(project => {
project.selected = action.payload;
});
},
setSelectOrDeselectBillable: (state, action) => {
state.billable = action.payload;
},
setNoCategory: (state, action: PayloadAction<boolean>) => {
state.noCategory = action.payload;
},
setArchived: (state, action: PayloadAction<boolean>) => {
state.archived = action.payload;
},
},
extraReducers: builder => {
builder.addCase(fetchReportingTeams.fulfilled, (state, action) => {
const teams = [];
for (const team of action.payload) {
teams.push({ selected: true, name: team.name, id: team.id });
}
state.teams = teams;
state.loadingTeams = false;
});
builder.addCase(fetchReportingTeams.pending, state => {
state.loadingTeams = true;
});
builder.addCase(fetchReportingTeams.rejected, state => {
state.loadingTeams = false;
});
builder.addCase(fetchReportingCategories.fulfilled, (state, action) => {
const categories = [];
for (const category of action.payload) {
categories.push({ selected: true, name: category.name, id: category.id });
}
state.categories = categories;
state.loadingCategories = false;
});
builder.addCase(fetchReportingCategories.pending, state => {
state.loadingCategories = true;
});
builder.addCase(fetchReportingCategories.rejected, state => {
state.loadingCategories = false;
});
builder.addCase(fetchReportingProjects.fulfilled, (state, action) => {
const projects = [];
for (const project of action.payload) {
projects.push({ selected: true, name: project.name, id: project.id });
}
state.projects = projects;
state.loadingProjects = false;
});
builder.addCase(fetchReportingProjects.pending, state => {
state.loadingProjects = true;
});
builder.addCase(fetchReportingProjects.rejected, state => {
state.loadingProjects = false;
});
},
});
export const {
setTeams,
setSelectOrDeselectAllTeams,
setSelectOrDeselectTeam,
setSelectOrDeselectCategory,
setSelectOrDeselectAllCategories,
setSelectOrDeselectProject,
setSelectOrDeselectAllProjects,
setSelectOrDeselectBillable,
setNoCategory,
setArchived,
} = timeReportsOverviewSlice.actions;
export default timeReportsOverviewSlice.reducer;

View File

@@ -0,0 +1,228 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Task } from 'gantt-task-react';
import { colors } from '../../styles/colors';
export interface NewTaskType extends Task {
subTasks?: Task[];
isExpanded?: boolean;
}
const tasks: NewTaskType[] = [
{
start: new Date(2024, 10, 1),
end: new Date(2024, 10, 5),
name: 'Planning Phase',
id: 'Task_1',
progress: 50,
type: 'task',
styles: {
progressColor: '#1890ff80',
progressSelectedColor: colors.skyBlue,
},
isExpanded: false,
subTasks: [
{
start: new Date(2024, 10, 1),
end: new Date(2024, 10, 2),
name: 'Initial Meeting',
id: 'Task_1_1',
progress: 80,
type: 'task',
dependencies: ['Task_1'],
styles: {
progressColor: '#1890ff80',
progressSelectedColor: colors.skyBlue,
},
},
{
start: new Date(2024, 10, 3),
end: new Date(2024, 10, 5),
name: 'Resource Allocation',
id: 'Task_1_2',
progress: 20,
type: 'task',
dependencies: ['Task_1'],
styles: {
progressColor: '#1890ff80',
progressSelectedColor: colors.skyBlue,
},
},
],
},
{
start: new Date(2024, 10, 6),
end: new Date(2024, 10, 10),
name: 'Development Phase',
id: 'Task_2',
progress: 30,
type: 'task',
styles: {
progressColor: '#1890ff80',
progressSelectedColor: colors.skyBlue,
},
isExpanded: false,
subTasks: [
{
start: new Date(2024, 10, 6),
end: new Date(2024, 10, 8),
name: 'Coding',
id: 'Task_2_1',
progress: 40,
type: 'task',
dependencies: ['Task_2'],
styles: {
progressColor: '#1890ff80',
progressSelectedColor: colors.skyBlue,
},
},
{
start: new Date(2024, 10, 9),
end: new Date(2024, 10, 10),
name: 'Code Review',
id: 'Task_2_2',
progress: 60,
type: 'task',
dependencies: ['Task_2'],
styles: {
progressColor: '#1890ff80',
progressSelectedColor: colors.skyBlue,
},
},
],
},
{
start: new Date(2024, 10, 11),
end: new Date(2024, 10, 12),
name: 'Design Phase',
id: 'Task_3',
progress: 70,
type: 'task',
styles: {
progressColor: '#1890ff80',
progressSelectedColor: colors.skyBlue,
},
isExpanded: false,
},
{
start: new Date(2024, 10, 13),
end: new Date(2024, 10, 17),
name: 'Testing Phase',
id: 'Task_4',
progress: 20,
type: 'task',
styles: {
progressColor: '#1890ff80',
progressSelectedColor: colors.skyBlue,
},
isExpanded: false,
subTasks: [
{
start: new Date(2024, 10, 13),
end: new Date(2024, 10, 14),
name: 'Unit Testing',
id: 'Task_4_1',
progress: 50,
type: 'task',
dependencies: ['Task_4'],
styles: {
progressColor: '#1890ff80',
progressSelectedColor: colors.skyBlue,
},
},
{
start: new Date(2024, 10, 15),
end: new Date(2024, 10, 17),
name: 'Integration Testing',
id: 'Task_4_2',
progress: 30,
type: 'task',
dependencies: ['Task_4'],
styles: {
progressColor: '#1890ff80',
progressSelectedColor: colors.skyBlue,
},
},
],
},
{
start: new Date(2024, 10, 18),
end: new Date(2024, 10, 20),
name: 'Deployment Phase',
id: 'Task_5',
progress: 90,
type: 'task',
styles: {
progressColor: '#1890ff80',
progressSelectedColor: colors.skyBlue,
},
isExpanded: false,
},
];
type RoadmapState = {
tasksList: NewTaskType[];
};
const initialState: RoadmapState = {
tasksList: tasks,
};
const roadmapSlice = createSlice({
name: 'roadmap',
initialState,
reducers: {
updateTaskDate: (state, action: PayloadAction<{ taskId: string; start: Date; end: Date }>) => {
const { taskId, start, end } = action.payload;
const updateTask = (tasks: NewTaskType[]): NewTaskType[] => {
return tasks.map(task => {
if (task.id === taskId) {
return {
...task,
start,
end,
};
}
if (task.subTasks) {
return {
...task,
subTasks: updateTask(task.subTasks),
};
}
return task;
});
};
state.tasksList = updateTask(state.tasksList);
},
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) {
updateTask(task.subTasks);
}
});
};
updateTask(state.tasksList);
},
toggleTaskExpansion: (state, action: PayloadAction<string>) => {
const index = state.tasksList.findIndex(task => task.id === action.payload);
if (index !== -1) {
state.tasksList[index] = {
...state.tasksList[index],
isExpanded: !state.tasksList[index].isExpanded,
};
}
},
},
});
export const { toggleTaskExpansion, updateTaskDate, updateTaskProgress } = roadmapSlice.actions;
export default roadmapSlice.reducer;

View File

@@ -0,0 +1,57 @@
import { Button, Col, DatePicker, Flex, Input, Row } from 'antd';
import React from 'react';
import { useDispatch } from 'react-redux';
import { toggleModal } from './scheduleSlice';
import { useTranslation } from 'react-i18next';
const ProjectTimelineModal = () => {
const dispatch = useDispatch();
const { t } = useTranslation('schedule');
const handleSave = () => {
dispatch(toggleModal());
};
return (
<Flex vertical gap={10} style={{ width: '480px' }}>
<Row>
<Col span={12} style={{ display: 'flex', flexDirection: 'column', paddingRight: '20px' }}>
<span>{t('startDate')}</span>
<DatePicker />
</Col>
<Col span={12} style={{ display: 'flex', flexDirection: 'column', paddingLeft: '20px' }}>
<span>{t('endDate')}</span>
<DatePicker />
</Col>
</Row>
<Row>
<Col span={12} style={{ paddingRight: '20px' }}>
<span>{t('hoursPerDay')}</span>
<Input max={24} defaultValue={8} type="number" suffix="hours" />
</Col>
<Col span={12} style={{ paddingLeft: '20px' }}>
<span>{t('totalHours')}</span>
<Input max={24} defaultValue={8} type="number" suffix="hours" />
</Col>
</Row>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Button type="link">{t('deleteButton')}</Button>
<div style={{ display: 'flex', gap: '5px' }}>
<Button onClick={() => dispatch(toggleModal())}>{t('cancelButton')}</Button>
<Button type="primary" onClick={handleSave}>
{t('saveButton')}
</Button>
</div>
</div>
</Flex>
);
};
export default ProjectTimelineModal;

View File

@@ -0,0 +1,44 @@
import { Avatar, Drawer, Tabs, TabsProps } from 'antd';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleScheduleDrawer } from './scheduleSlice';
import { AvatarNamesMap } from '../../shared/constants';
import WithStartAndEndDates from '../../components/schedule-old/tabs/withStartAndEndDates/WithStartAndEndDates';
import { useTranslation } from 'react-i18next';
const ScheduleDrawer = () => {
const isScheduleDrawerOpen = useAppSelector(state => state.scheduleReducer.isScheduleDrawerOpen);
const dispatch = useAppDispatch();
const { t } = useTranslation('schedule');
const items: TabsProps['items'] = [
{
key: '1',
label: '2024-11-04 - 2024-12-24',
children: <WithStartAndEndDates />,
},
{
key: '2',
label: t('tabTitle'),
children: 'Content of Tab Pane 2',
},
];
return (
<Drawer
width={1000}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<Avatar style={{ backgroundColor: AvatarNamesMap['R'] }}>R</Avatar>
<span>Raveesha Dilanka</span>
</div>
}
onClose={() => dispatch(toggleScheduleDrawer())}
open={isScheduleDrawerOpen}
>
<Tabs defaultActiveKey="1" type="card" items={items} />
</Drawer>
);
};
export default ScheduleDrawer;

View File

@@ -0,0 +1,94 @@
import { Button, Checkbox, Col, Drawer, Form, Input, Row } from 'antd';
import React, { ReactHTMLElement, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { toggleSettingsDrawer, updateSettings } from './scheduleSlice';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
const ScheduleSettingsDrawer: React.FC = () => {
const isDrawerOpen = useAppSelector(state => state.scheduleReducer.isSettingsDrawerOpen);
const dispatch = useDispatch();
const { t } = useTranslation('schedule');
const [workingDays, setWorkingDays] = useState([
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
]);
const [workingHours, setWorkingHours] = useState(8);
const onChangeWorkingDays = (checkedValues: string[]) => {
setWorkingDays(checkedValues);
};
const onChangeWorkingHours = (e: React.ChangeEvent<HTMLInputElement>) => {
setWorkingHours(Number(e.target.value));
};
const onSave = () => {
dispatch(updateSettings({ workingDays, workingHours }));
dispatch(toggleSettingsDrawer());
};
return (
<div>
<Drawer
title={t('settings')}
open={isDrawerOpen}
onClose={() => {
dispatch(toggleSettingsDrawer());
}}
>
<Form layout="vertical">
<Form.Item label={t('workingDays')}>
<Checkbox.Group defaultValue={workingDays} onChange={onChangeWorkingDays}>
<Row>
<Col span={8} style={{ paddingBottom: '8px' }}>
<Checkbox value="Monday">{t('monday')}</Checkbox>
</Col>
<Col span={8} style={{ paddingBottom: '8px' }}>
<Checkbox value="Tuesday">{t('tuesday')}</Checkbox>
</Col>
<Col span={8} style={{ paddingBottom: '8px' }}>
<Checkbox value="Wednesday">{t('wednesday')}</Checkbox>
</Col>
<Col span={8} style={{ paddingBottom: '8px' }}>
<Checkbox value="Thursday">{t('thursday')}</Checkbox>
</Col>
<Col span={8} style={{ paddingBottom: '8px' }}>
<Checkbox value="Friday">{t('friday')}</Checkbox>
</Col>
<Col span={8} style={{ paddingBottom: '8px' }}>
<Checkbox value="Saturday">{t('saturday')}</Checkbox>
</Col>
<Col span={8} style={{ paddingBottom: '8px' }}>
<Checkbox value="Sunday">{t('sunday')}</Checkbox>
</Col>
</Row>
</Checkbox.Group>
</Form.Item>
<Form.Item label={t('workingHours')}>
<Input
max={24}
defaultValue={workingHours}
type="number"
suffix={<span style={{ color: 'rgba(0, 0, 0, 0.46)' }}>{t('hours')}</span>}
onChange={onChangeWorkingHours}
/>
</Form.Item>
<Form.Item>
<Button type="primary" style={{ width: '100%' }} onClick={onSave}>
{t('saveButton')}
</Button>
</Form.Item>
</Form>
</Drawer>
</div>
);
};
export default ScheduleSettingsDrawer;

View File

@@ -0,0 +1,45 @@
import { createSlice } from '@reduxjs/toolkit';
interface scheduleState {
isSettingsDrawerOpen: boolean;
isModalOpen: boolean;
isScheduleDrawerOpen: boolean;
workingDays: string[];
workingHours: number;
}
const initialState: scheduleState = {
isSettingsDrawerOpen: false,
isModalOpen: false,
isScheduleDrawerOpen: false,
workingDays: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
workingHours: 8,
};
const scheduleSlice = createSlice({
name: 'scheduleReducer',
initialState,
reducers: {
toggleSettingsDrawer: state => {
state.isSettingsDrawerOpen
? (state.isSettingsDrawerOpen = false)
: (state.isSettingsDrawerOpen = true);
},
updateSettings(state, action) {
state.workingDays = action.payload.workingDays;
state.workingHours = action.payload.workingHours;
},
toggleModal(state) {
state.isModalOpen ? (state.isModalOpen = false) : (state.isModalOpen = true);
},
toggleScheduleDrawer: state => {
state.isScheduleDrawerOpen
? (state.isScheduleDrawerOpen = false)
: (state.isScheduleDrawerOpen = true);
},
},
});
export const { toggleSettingsDrawer, updateSettings, toggleModal, toggleScheduleDrawer } =
scheduleSlice.actions;
export default scheduleSlice.reducer;

View File

@@ -0,0 +1,137 @@
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { ScheduleData } from '@/types/schedule/schedule-v2.types';
import { Button, Col, DatePicker, Flex, Form, Input, Row } from 'antd';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { createSchedule, fetchTeamData } from './scheduleSlice';
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 [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 }}));
form.resetFields();
setIsModalOpen(false);
dispatch(fetchTeamData());
};
const calTotalHours = async () => {
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");
return;
}
let totalWorkingDays = 0;
for (let current = new Date(start); current <= end; current.setDate(current.getDate() + 1)) {
if (workingDays.includes(getDayName(current))) {
totalWorkingDays++;
}
}
const hoursPerDay = secondsPerDay;
const totalHours = totalWorkingDays * hoursPerDay;
form.setFieldsValue({ total_seconds: totalHours.toFixed(2) });
} else {
form.setFieldsValue({ total_seconds: 0 });
}
};
const disabledStartDate = (current: dayjs.Dayjs) => {
const endDate = form.getFieldValue('allocated_to');
return current && endDate ? current > dayjs(endDate) : false;
};
const disabledEndDate = (current: dayjs.Dayjs) => {
const startDate = form.getFieldValue('allocated_from');
return current && startDate ? current < dayjs(startDate) : false;
};
useEffect(() => {
form.setFieldsValue({ allocated_from: dayjs(defaultData?.allocated_from) });
form.setFieldsValue({ allocated_to: dayjs(defaultData?.allocated_to) });
}, [defaultData]);
return (
<Form form={form} onFinish={handleFormSubmit}>
<Flex vertical gap={10} style={{ width: '480px' }}>
<Row>
<Col
span={12}
style={{
display: 'flex',
flexDirection: 'column',
paddingRight: '20px',
}}
>
<span>{t('startDate')}</span>
<Form.Item name="allocated_from">
<DatePicker disabledDate={disabledStartDate} onChange={e => calTotalHours()} />
</Form.Item>
</Col>
<Col
span={12}
style={{
display: 'flex',
flexDirection: 'column',
paddingLeft: '20px',
}}
>
<span>{t('endDate')}</span>
<Form.Item name="allocated_to">
<DatePicker disabledDate={disabledEndDate} onChange={e => calTotalHours()}/>
</Form.Item>
</Col>
</Row>
<Row>
<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" />
</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" />
</Form.Item>
</Col>
</Row>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Button type="link">{t('deleteButton')}</Button>
<div style={{ display: 'flex', gap: '5px' }}>
<Button onClick={() => setIsModalOpen(false)}>{t('cancelButton')}</Button>
<Button htmlType='submit' type="primary">
{t('saveButton')}
</Button>
</div>
</div>
</Flex>
</Form>
);
};
export default React.memo(ProjectTimelineModal);

View File

@@ -0,0 +1,45 @@
import { Avatar, Drawer, Tabs, TabsProps } from 'antd';
import React from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleScheduleDrawer } from './scheduleSlice';
import { avatarNamesMap } from '../../shared/constants';
import WithStartAndEndDates from '../../components/schedule-old/tabs/withStartAndEndDates/WithStartAndEndDates';
import { useTranslation } from 'react-i18next';
const ScheduleDrawer = () => {
const isScheduleDrawerOpen = useAppSelector(state => state.scheduleReducer.isScheduleDrawerOpen);
const dispatch = useAppDispatch();
const { t } = useTranslation('schedule');
const items: TabsProps['items'] = [
{
key: '1',
label: '2024-11-04 - 2024-12-24',
children: <WithStartAndEndDates />,
},
{
key: '2',
label: t('tabTitle'),
children: 'Content of Tab Pane 2',
},
];
return (
<Drawer
width={1000}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<Avatar style={{ backgroundColor: avatarNamesMap['R'] }}>R</Avatar>
<span>Raveesha Dilanka</span>
</div>
}
onClose={() => dispatch(toggleScheduleDrawer())}
open={isScheduleDrawerOpen}
>
<Tabs defaultActiveKey="1" type="card" items={items} />
</Drawer>
);
};
export default ScheduleDrawer;

View File

@@ -0,0 +1,99 @@
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 { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { scheduleAPIService } from '@/api/schedule/schedule.api.service';
import Skeleton from 'antd/es/skeleton/Skeleton';
import { useAppDispatch } from '@/hooks/useAppDispatch';
const ScheduleSettingsDrawer: React.FC = () => {
const isDrawerOpen = useAppSelector(state => state.scheduleReducer.isSettingsDrawerOpen);
const dispatch = useAppDispatch();
const [form] = Form.useForm();
const { t } = useTranslation('schedule');
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());
dispatch(fetchDateList({ date, type }));
dispatch(fetchTeamData());
};
const fetchSettings = async () => {
dispatch(getWorking());
};
useEffect(() => {
form.setFieldsValue({ workingDays, workingHours });
}, [workingDays, workingHours]);
return (
<div>
<Drawer
title={t('settings')}
open={isDrawerOpen}
onClose={() => {
dispatch(toggleSettingsDrawer());
}}
destroyOnClose
afterOpenChange={() => {
fetchSettings();
}}
>
<Skeleton loading={loading} active paragraph={{ rows: 1 }}>
<Form layout="vertical" form={form} onFinish={handleFormSubmit}>
<Form.Item label={t('workingDays')} name="workingDays">
<Checkbox.Group defaultValue={workingDays}>
<Row>
<Col span={8} style={{ paddingBottom: '8px' }}>
<Checkbox value="Monday">{t('monday')}</Checkbox>
</Col>
<Col span={8} style={{ paddingBottom: '8px' }}>
<Checkbox value="Tuesday">{t('tuesday')}</Checkbox>
</Col>
<Col span={8} style={{ paddingBottom: '8px' }}>
<Checkbox value="Wednesday">{t('wednesday')}</Checkbox>
</Col>
<Col span={8} style={{ paddingBottom: '8px' }}>
<Checkbox value="Thursday">{t('thursday')}</Checkbox>
</Col>
<Col span={8} style={{ paddingBottom: '8px' }}>
<Checkbox value="Friday">{t('friday')}</Checkbox>
</Col>
<Col span={8} style={{ paddingBottom: '8px' }}>
<Checkbox value="Saturday">{t('saturday')}</Checkbox>
</Col>
<Col span={8} style={{ paddingBottom: '8px' }}>
<Checkbox value="Sunday">{t('sunday')}</Checkbox>
</Col>
</Row>
</Checkbox.Group>
</Form.Item>
<Form.Item label={t('workingHours')} name="workingHours">
<Input
max={24}
type="number"
suffix={<span style={{ color: 'rgba(0, 0, 0, 0.46)' }}>{t('hours')}</span>}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" style={{ width: '100%' }}>
{t('saveButton')}
</Button>
</Form.Item>
</Form>
</Skeleton>
</Drawer>
</div>
);
};
export default ScheduleSettingsDrawer;

View File

@@ -0,0 +1,223 @@
import { scheduleAPIService } from '@/api/schedule/schedule.api.service';
import { PickerType, ScheduleData } from '@/types/schedule/schedule-v2.types';
import logger from '@/utils/errorLogger';
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
interface scheduleState {
isSettingsDrawerOpen: boolean;
isScheduleDrawerOpen: boolean;
workingDays: string[];
workingHours: number;
teamData: any[];
dateList: any;
loading: boolean;
error: string | null;
type: PickerType;
date: Date;
dayCount: number;
}
const initialState: scheduleState = {
isSettingsDrawerOpen: false,
isScheduleDrawerOpen: false,
workingDays: [],
workingHours: 8,
teamData: [],
dateList: {},
loading: false,
error: null,
type: 'month',
date: new Date(),
dayCount: 0,
};
export const fetchTeamData = createAsyncThunk('schedule/fetchTeamData', async () => {
const response = await scheduleAPIService.fetchScheduleMembers();
if (!response.done) {
throw new Error('Failed to fetch team data');
}
const data = response.body;
return data;
});
export const fetchDateList = createAsyncThunk(
'schedule/fetchDateList',
async ({ date, type }: { date: Date; type: string }) => {
const response = await scheduleAPIService.fetchScheduleDates({ date: date.toISOString(), type });
if (!response.done) {
throw new Error('Failed to fetch date list');
}
const data = response.body;
return data;
}
);
export const updateWorking = createAsyncThunk(
'schedule/updateWorking',
async ({ workingDays, workingHours }: { workingDays: string[]; workingHours: number }) => {
const response = await scheduleAPIService.updateScheduleSettings({ workingDays, workingHours });
if (!response.done) {
throw new Error('Failed to fetch date list');
}
const data = response.body;
return data;
}
);
export const getWorking = createAsyncThunk(
'schedule/getWorking',
async (_, { rejectWithValue }) => {
try {
const response = await scheduleAPIService.fetchScheduleSettings();
if (!response.done) {
throw new Error('Failed to fetch date list');
}
return response;
} catch (error) {
logger.error('getWorking', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to getWorking');
}
}
);
export const fetchMemberProjects = createAsyncThunk(
'schedule/fetchMemberProjects',
async ({ id }: { id: string }) => {
const response = await scheduleAPIService.fetchMemberProjects({ id });
if (!response.done) {
throw new Error('Failed to fetch date list');
}
const data = response.body;
return data;
}
);
export const createSchedule = createAsyncThunk(
'schedule/createSchedule',
async ({ schedule }: { schedule: ScheduleData}) => {
const response = await scheduleAPIService.submitScheduleData({ schedule });
if (!response.done) {
throw new Error('Failed to fetch date list');
}
const data = response.body;
return data;
}
);
const scheduleSlice = createSlice({
name: 'scheduleReducer',
initialState,
reducers: {
toggleSettingsDrawer: state => {
state.isSettingsDrawerOpen = !state.isSettingsDrawerOpen;
},
updateSettings(state, action) {
state.workingDays = action.payload.workingDays;
state.workingHours = action.payload.workingHours;
},
toggleScheduleDrawer: state => {
state.isScheduleDrawerOpen = !state.isScheduleDrawerOpen;
},
getWorkingSettings(state, action) {
state.workingDays = action.payload.workingDays;
state.workingHours = action.payload.workingHours;
},
setDate(state, action) {
state.date = action.payload;
},
setType(state, action) {
state.type = action.payload;
},
setDayCount(state, action) {
state.dayCount = action.payload;
},
},
extraReducers: builder => {
builder
.addCase(fetchTeamData.pending, state => {
state.loading = true;
state.error = null;
})
.addCase(fetchTeamData.fulfilled, (state, action) => {
state.teamData = action.payload;
state.loading = false;
})
.addCase(fetchTeamData.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Failed to fetch team data';
})
.addCase(fetchDateList.pending, state => {
state.loading = true;
state.error = null;
})
.addCase(fetchDateList.fulfilled, (state, action) => {
state.dateList = action.payload;
state.dayCount = (action.payload as any)?.date_data[0]?.days?.length;
state.loading = false;
})
.addCase(fetchDateList.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Failed to fetch date list';
})
.addCase(updateWorking.pending, state => {
state.loading = true;
state.error = null;
})
.addCase(updateWorking.fulfilled, (state, action) => {
state.workingDays = action.payload.workingDays;
state.workingHours = action.payload.workingHours;
state.loading = false;
})
.addCase(updateWorking.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Failed to fetch date list';
})
.addCase(getWorking.pending, state => {
state.loading = true;
state.error = null;
})
.addCase(getWorking.fulfilled, (state, action) => {
state.workingDays = action.payload.body.workingDays;
state.workingHours = action.payload.body.workingHours;
state.loading = false;
})
.addCase(getWorking.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Failed to fetch list';
}).addCase(fetchMemberProjects.pending, state => {
state.loading = true;
state.error = null;
})
.addCase(fetchMemberProjects.fulfilled, (state, action) => {
const data = action.payload;
state.teamData.find((team: any) => {
if (team.id === data.id) {
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 => {
state.loading = true;
state.error = null;
})
.addCase(createSchedule.fulfilled, (state, action) => {
state.loading = false;
})
.addCase(createSchedule.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Failed to send schedule';
});
},
});
export const { toggleSettingsDrawer, updateSettings, toggleScheduleDrawer, getWorkingSettings, setDate, setType, setDayCount } =
scheduleSlice.actions;
export default scheduleSlice.reducer;

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { Tag, Typography } from 'antd';
import { colors } from '@/styles/colors';
import { IProjectCategory } from '@/types/project/projectCategory.types';
const CustomColorsCategoryTag = ({ category }: { category: IProjectCategory | null }) => {
return (
<Tag key={category?.id} color={category?.color_code}>
<Typography.Text style={{ fontSize: 12, color: colors.darkGray }}>
{category?.name}
</Typography.Text>
</Tag>
);
};
export default CustomColorsCategoryTag;

View File

@@ -0,0 +1,30 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { IProjectCategory } from '@/types/project/projectCategory.types';
type CategoriesState = {
categoriesList: IProjectCategory[];
};
const initialState: CategoriesState = {
categoriesList: [],
};
const categoriesSlice = createSlice({
name: 'categoriesReducer',
initialState,
reducers: {
// action for add category
addCategory: (state, action: PayloadAction<IProjectCategory>) => {
state.categoriesList.push(action.payload);
},
// action for delete category
deleteCategory: (state, action: PayloadAction<string>) => {
state.categoriesList = state.categoriesList.filter(
category => category.id !== action.payload
);
},
},
});
export const { addCategory, deleteCategory } = categoriesSlice.actions;
export default categoriesSlice.reducer;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Select, Tag, Tooltip } from 'antd';
import { CategoryType } from '../../../types/categories.types';
import { useTranslation } from 'react-i18next';
import { PhaseColorCodes } from '../../../shared/constants';
const ColorChangedCategory = ({ category }: { category: CategoryType | null }) => {
// localization
const { t } = useTranslation('categoriesSettings');
// color options for the categories
const colorsOptions = PhaseColorCodes.map(color => ({
key: color,
value: color,
label: (
<Tag
color={color}
style={{
display: 'flex',
alignItems: 'center',
justifyItems: 'center',
height: 22,
width: 'fit-content',
}}
>
{category?.categoryName}
</Tag>
),
}));
return (
<Tooltip title={t('colorChangeTooltip')}>
<Select
key={category?.categoryId}
options={colorsOptions}
variant="borderless"
style={{
display: 'flex',
alignItems: 'center',
justifyItems: 'center',
height: 22,
maxWidth: 160,
}}
defaultValue={category?.categoryColor}
suffixIcon={null}
/>
</Tooltip>
);
};
export default ColorChangedCategory;

View File

@@ -0,0 +1,70 @@
import { Button, Drawer, Form, Input, message, Typography } from 'antd';
import React from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { addClient, toggleCreateClientDrawer } from './clientSlice';
import { IClient } from '../../../types/client.types';
import { nanoid } from '@reduxjs/toolkit';
import { useTranslation } from 'react-i18next';
const CreateClientDrawer = () => {
// localization
const { t } = useTranslation('settings/clients');
// get drawer state from client reducer
const isDrawerOpen = useAppSelector(state => state.clientReducer.isCreateClientDrawerOpen);
const dispatch = useAppDispatch();
const [form] = Form.useForm();
// this function for handle form submit
const handleFormSubmit = async (values: any) => {
try {
const newClient: IClient = {
name: values.name,
};
dispatch(addClient(newClient));
dispatch(toggleCreateClientDrawer());
form.resetFields();
message.success(t('createClientSuccessMessage'));
} catch (error) {
message.error(t('createClientErrorMessage'));
}
};
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{t('createClientDrawerTitle')}
</Typography.Text>
}
open={isDrawerOpen}
onClose={() => dispatch(toggleCreateClientDrawer())}
>
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
<Form.Item
name="name"
label={t('nameLabel')}
rules={[
{
required: true,
message: t('nameRequiredError'),
},
]}
>
<Input placeholder={t('namePlaceholder')} />
</Form.Item>
<Form.Item>
<Button type="primary" style={{ width: '100%' }} htmlType="submit">
{t('createButton')}
</Button>
</Form.Item>
</Form>
</Drawer>
);
};
export default CreateClientDrawer;

Some files were not shown because too many files have changed in this diff Show More