init
This commit is contained in:
@@ -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;
|
||||
22
worklenz-frontend/src/features/actionSetup/buttonSlice.ts
Normal file
22
worklenz-frontend/src/features/actionSetup/buttonSlice.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
156
worklenz-frontend/src/features/auth/authSlice.ts
Normal file
156
worklenz-frontend/src/features/auth/authSlice.ts
Normal 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;
|
||||
872
worklenz-frontend/src/features/board/board-slice.ts
Normal file
872
worklenz-frontend/src/features/board/board-slice.ts
Normal 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;
|
||||
35
worklenz-frontend/src/features/board/create-card.slice.ts
Normal file
35
worklenz-frontend/src/features/board/create-card.slice.ts
Normal 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;
|
||||
24
worklenz-frontend/src/features/date/dateSlice.ts
Normal file
24
worklenz-frontend/src/features/date/dateSlice.ts
Normal 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;
|
||||
@@ -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;
|
||||
156
worklenz-frontend/src/features/home-page/home-page.slice.ts
Normal file
156
worklenz-frontend/src/features/home-page/home-page.slice.ts
Normal 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;
|
||||
56
worklenz-frontend/src/features/i18n/language-selector.tsx
Normal file
56
worklenz-frontend/src/features/i18n/language-selector.tsx
Normal 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;
|
||||
75
worklenz-frontend/src/features/i18n/localesSlice.ts
Normal file
75
worklenz-frontend/src/features/i18n/localesSlice.ts
Normal 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;
|
||||
@@ -0,0 +1,3 @@
|
||||
.notification-icon:hover .anticon {
|
||||
color: #1677ff;
|
||||
}
|
||||
23
worklenz-frontend/src/features/navbar/help/HelpButton.tsx
Normal file
23
worklenz-frontend/src/features/navbar/help/HelpButton.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
33
worklenz-frontend/src/features/navbar/navRoutes.ts
Normal file
33
worklenz-frontend/src/features/navbar/navRoutes.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
45
worklenz-frontend/src/features/navbar/navbar-logo.tsx
Normal file
45
worklenz-frontend/src/features/navbar/navbar-logo.tsx
Normal 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;
|
||||
186
worklenz-frontend/src/features/navbar/navbar.tsx
Normal file
186
worklenz-frontend/src/features/navbar/navbar.tsx
Normal 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;
|
||||
117
worklenz-frontend/src/features/navbar/notificationSlice.ts
Normal file
117
worklenz-frontend/src/features/navbar/notificationSlice.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
.profile-button:hover .anticon {
|
||||
color: #1677ff;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
221
worklenz-frontend/src/features/project/project.slice.ts
Normal file
221
worklenz-frontend/src/features/project/project.slice.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
252
worklenz-frontend/src/features/projects/projectsSlice.ts
Normal file
252
worklenz-frontend/src/features/projects/projectsSlice.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
66
worklenz-frontend/src/features/reporting/reporting.slice.ts
Normal file
66
worklenz-frontend/src/features/reporting/reporting.slice.ts
Normal 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;
|
||||
@@ -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;
|
||||
228
worklenz-frontend/src/features/roadmap/roadmap-slice.ts
Normal file
228
worklenz-frontend/src/features/roadmap/roadmap-slice.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
45
worklenz-frontend/src/features/schedule-old/scheduleSlice.ts
Normal file
45
worklenz-frontend/src/features/schedule-old/scheduleSlice.ts
Normal 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;
|
||||
137
worklenz-frontend/src/features/schedule/ProjectTimelineModal.tsx
Normal file
137
worklenz-frontend/src/features/schedule/ProjectTimelineModal.tsx
Normal 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);
|
||||
45
worklenz-frontend/src/features/schedule/ScheduleDrawer.tsx
Normal file
45
worklenz-frontend/src/features/schedule/ScheduleDrawer.tsx
Normal 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;
|
||||
@@ -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;
|
||||
223
worklenz-frontend/src/features/schedule/scheduleSlice.ts
Normal file
223
worklenz-frontend/src/features/schedule/scheduleSlice.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user