feat(finance): implement project finance and rate card management features
- Added new controllers for managing project finance and rate cards, including CRUD operations for rate card roles and project finance tasks. - Introduced API routes for project finance and rate card functionalities, enhancing the backend structure. - Developed frontend components for displaying and managing project finance data, including a finance drawer and rate card settings. - Enhanced localization files to support new UI elements and ensure consistency across multiple languages. - Implemented utility functions for handling man-days and financial calculations, improving overall functionality.
This commit is contained in:
252
worklenz-frontend/src/features/finance/finance-slice.ts
Normal file
252
worklenz-frontend/src/features/finance/finance-slice.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service';
|
||||
import { RatecardType } from '@/types/project/ratecard.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
type financeState = {
|
||||
isRatecardDrawerOpen: boolean;
|
||||
isFinanceDrawerOpen: boolean;
|
||||
isImportRatecardsDrawerOpen: boolean;
|
||||
currency: string;
|
||||
isRatecardsLoading?: boolean;
|
||||
isFinanceDrawerloading?: boolean;
|
||||
drawerRatecard?: RatecardType | null;
|
||||
ratecardsList?: RatecardType[] | null;
|
||||
selectedTask?: any | null;
|
||||
};
|
||||
|
||||
const initialState: financeState = {
|
||||
isRatecardDrawerOpen: false,
|
||||
isFinanceDrawerOpen: false,
|
||||
isImportRatecardsDrawerOpen: false,
|
||||
currency: 'USD',
|
||||
isRatecardsLoading: false,
|
||||
isFinanceDrawerloading: false,
|
||||
drawerRatecard: null,
|
||||
ratecardsList: null,
|
||||
selectedTask: null,
|
||||
};
|
||||
interface FetchRateCardsParams {
|
||||
index: number;
|
||||
size: number;
|
||||
field: string | null;
|
||||
order: string | null;
|
||||
search: string | null;
|
||||
}
|
||||
// Async thunks
|
||||
export const fetchRateCards = createAsyncThunk(
|
||||
'ratecards/fetchAll',
|
||||
async (params: FetchRateCardsParams, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.getRateCards(
|
||||
params.index,
|
||||
params.size,
|
||||
params.field,
|
||||
params.order,
|
||||
params.search
|
||||
);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch RateCards', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to fetch rate cards');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchRateCardById = createAsyncThunk(
|
||||
'ratecard/fetchById',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.getRateCardById(id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch RateCardById', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to fetch rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const createRateCard = createAsyncThunk(
|
||||
'ratecards/create',
|
||||
async (body: RatecardType, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.createRateCard(body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Create RateCard', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to create rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateRateCard = createAsyncThunk(
|
||||
'ratecards/update',
|
||||
async ({ id, body }: { id: string; body: RatecardType }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.updateRateCard(id, body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Update RateCard', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to update rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteRateCard = createAsyncThunk(
|
||||
'ratecards/delete',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
await rateCardApiService.deleteRateCard(id);
|
||||
return id;
|
||||
} catch (error) {
|
||||
logger.error('Delete RateCard', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to delete rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const financeSlice = createSlice({
|
||||
name: 'financeReducer',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleRatecardDrawer: (state) => {
|
||||
state.isRatecardDrawerOpen = !state.isRatecardDrawerOpen;
|
||||
},
|
||||
toggleFinanceDrawer: (state) => {
|
||||
state.isFinanceDrawerOpen = !state.isFinanceDrawerOpen;
|
||||
},
|
||||
openFinanceDrawer: (state, action: PayloadAction<any>) => {
|
||||
state.isFinanceDrawerOpen = true;
|
||||
state.selectedTask = action.payload;
|
||||
},
|
||||
closeFinanceDrawer: (state) => {
|
||||
state.isFinanceDrawerOpen = false;
|
||||
state.selectedTask = null;
|
||||
},
|
||||
setSelectedTask: (state, action: PayloadAction<any>) => {
|
||||
state.selectedTask = action.payload;
|
||||
},
|
||||
toggleImportRatecardsDrawer: (state) => {
|
||||
state.isImportRatecardsDrawerOpen = !state.isImportRatecardsDrawerOpen;
|
||||
},
|
||||
changeCurrency: (state, action: PayloadAction<string>) => {
|
||||
state.currency = action.payload;
|
||||
},
|
||||
ratecardDrawerLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.isFinanceDrawerloading = action.payload;
|
||||
},
|
||||
clearDrawerRatecard: (state) => {
|
||||
state.drawerRatecard = null;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchRateCards.pending, (state) => {
|
||||
state.isRatecardsLoading = true;
|
||||
})
|
||||
.addCase(fetchRateCards.fulfilled, (state, action) => {
|
||||
state.isRatecardsLoading = false;
|
||||
state.ratecardsList = Array.isArray(action.payload.data)
|
||||
? action.payload.data
|
||||
: Array.isArray(action.payload)
|
||||
? action.payload
|
||||
: [];
|
||||
})
|
||||
.addCase(fetchRateCards.rejected, (state) => {
|
||||
state.isRatecardsLoading = false;
|
||||
state.ratecardsList = [];
|
||||
})
|
||||
.addCase(fetchRateCardById.pending, (state) => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
state.drawerRatecard = null;
|
||||
})
|
||||
.addCase(fetchRateCardById.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
state.drawerRatecard = action.payload;
|
||||
})
|
||||
.addCase(fetchRateCardById.rejected, (state) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
state.drawerRatecard = null;
|
||||
})
|
||||
// Create rate card
|
||||
.addCase(createRateCard.pending, (state) => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(createRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
if (state.ratecardsList) {
|
||||
state.ratecardsList.push(action.payload);
|
||||
} else {
|
||||
state.ratecardsList = [action.payload];
|
||||
}
|
||||
})
|
||||
.addCase(createRateCard.rejected, (state) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
})
|
||||
// Update rate card
|
||||
.addCase(updateRateCard.pending, (state) => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(updateRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
// Update the drawerRatecard with the new data
|
||||
state.drawerRatecard = action.payload;
|
||||
// Update the rate card in the list if it exists
|
||||
if (state.ratecardsList && action.payload?.id) {
|
||||
const index = state.ratecardsList.findIndex(rc => rc.id === action.payload.id);
|
||||
if (index !== -1) {
|
||||
state.ratecardsList[index] = action.payload;
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateRateCard.rejected, (state) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
})
|
||||
// Delete rate card
|
||||
.addCase(deleteRateCard.pending, (state) => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(deleteRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
// Remove the deleted rate card from the list
|
||||
if (state.ratecardsList) {
|
||||
state.ratecardsList = state.ratecardsList.filter(rc => rc.id !== action.payload);
|
||||
}
|
||||
// Clear drawer rate card if it was the deleted one
|
||||
if (state.drawerRatecard?.id === action.payload) {
|
||||
state.drawerRatecard = null;
|
||||
}
|
||||
})
|
||||
.addCase(deleteRateCard.rejected, (state) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
toggleRatecardDrawer,
|
||||
toggleFinanceDrawer,
|
||||
openFinanceDrawer,
|
||||
closeFinanceDrawer,
|
||||
setSelectedTask,
|
||||
toggleImportRatecardsDrawer,
|
||||
changeCurrency,
|
||||
ratecardDrawerLoading,
|
||||
clearDrawerRatecard,
|
||||
} = financeSlice.actions;
|
||||
export default financeSlice.reducer;
|
||||
310
worklenz-frontend/src/features/finance/project-finance-slice.ts
Normal file
310
worklenz-frontend/src/features/finance/project-finance-slice.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import {
|
||||
projectRateCardApiService,
|
||||
IProjectRateCardRole,
|
||||
} from '@/api/project-finance-ratecard/project-finance-rate-cards.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { JobRoleType } from '@/types/project/ratecard.types';
|
||||
|
||||
type ProjectFinanceRateCardState = {
|
||||
isDrawerOpen: boolean;
|
||||
isLoading: boolean;
|
||||
rateCardRoles: JobRoleType[] | null;
|
||||
drawerRole: IProjectRateCardRole | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
const initialState: ProjectFinanceRateCardState = {
|
||||
isDrawerOpen: false,
|
||||
isLoading: false,
|
||||
rateCardRoles: null,
|
||||
drawerRole: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Async thunks
|
||||
export const fetchProjectRateCardRoles = createAsyncThunk(
|
||||
'projectFinance/fetchAll',
|
||||
async (project_id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.getFromProjectId(project_id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Project RateCard Roles', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to fetch project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchProjectRateCardRoleById = createAsyncThunk(
|
||||
'projectFinance/fetchById',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.getFromId(id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Project RateCard Role By Id', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to fetch project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const insertProjectRateCardRoles = createAsyncThunk(
|
||||
'projectFinance/insertMany',
|
||||
async (
|
||||
{
|
||||
project_id,
|
||||
roles,
|
||||
}: { project_id: string; roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[] },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.insertMany(project_id, roles);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Insert Project RateCard Roles', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to insert project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const insertProjectRateCardRole = createAsyncThunk(
|
||||
'projectFinance/insertOne',
|
||||
async (
|
||||
{
|
||||
project_id,
|
||||
job_title_id,
|
||||
rate,
|
||||
man_day_rate,
|
||||
}: { project_id: string; job_title_id: string; rate: number; man_day_rate?: number },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.insertOne({
|
||||
project_id,
|
||||
job_title_id,
|
||||
rate,
|
||||
man_day_rate,
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Insert Project RateCard Role', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to insert project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateProjectRateCardRoleById = createAsyncThunk(
|
||||
'projectFinance/updateById',
|
||||
async (
|
||||
{
|
||||
id,
|
||||
body,
|
||||
}: { id: string; body: { job_title_id: string; rate?: string; man_day_rate?: string } },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.updateFromId(id, body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Update Project RateCard Role By Id', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to update project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateProjectRateCardRolesByProjectId = createAsyncThunk(
|
||||
'projectFinance/updateByProjectId',
|
||||
async (
|
||||
{
|
||||
project_id,
|
||||
roles,
|
||||
}: { project_id: string; roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[] },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.updateFromProjectId(project_id, roles);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Update Project RateCard Roles By ProjectId', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to update project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteProjectRateCardRoleById = createAsyncThunk(
|
||||
'projectFinance/deleteById',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.deleteFromId(id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Delete Project RateCard Role By Id', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to delete project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const assignMemberToRateCardRole = createAsyncThunk(
|
||||
'projectFinance/assignMemberToRateCardRole',
|
||||
async ({
|
||||
project_id,
|
||||
member_id,
|
||||
project_rate_card_role_id,
|
||||
}: {
|
||||
project_id: string;
|
||||
member_id: string;
|
||||
project_rate_card_role_id: string;
|
||||
}) => {
|
||||
const response = await projectRateCardApiService.updateMemberRateCardRole(
|
||||
project_id,
|
||||
member_id,
|
||||
project_rate_card_role_id
|
||||
);
|
||||
return response.body;
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteProjectRateCardRolesByProjectId = createAsyncThunk(
|
||||
'projectFinance/deleteByProjectId',
|
||||
async (project_id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.deleteFromProjectId(project_id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Delete Project RateCard Roles By ProjectId', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to delete project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const projectFinanceSlice = createSlice({
|
||||
name: 'projectFinanceRateCard',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleDrawer: state => {
|
||||
state.isDrawerOpen = !state.isDrawerOpen;
|
||||
},
|
||||
clearDrawerRole: state => {
|
||||
state.drawerRole = null;
|
||||
},
|
||||
clearError: state => {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
// Fetch all
|
||||
.addCase(fetchProjectRateCardRoles.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoles.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = action.payload || [];
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoles.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
state.rateCardRoles = [];
|
||||
})
|
||||
// Fetch by id
|
||||
.addCase(fetchProjectRateCardRoleById.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.drawerRole = null;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.drawerRole = action.payload || null;
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoleById.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
state.drawerRole = null;
|
||||
})
|
||||
// Insert many
|
||||
.addCase(insertProjectRateCardRoles.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(insertProjectRateCardRoles.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = action.payload || [];
|
||||
})
|
||||
.addCase(insertProjectRateCardRoles.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Update by id
|
||||
.addCase(updateProjectRateCardRoleById.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(updateProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
if (state.rateCardRoles && action.payload) {
|
||||
state.rateCardRoles = state.rateCardRoles.map(role =>
|
||||
role.id === action.payload.id ? action.payload : role
|
||||
);
|
||||
}
|
||||
})
|
||||
.addCase(updateProjectRateCardRoleById.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Update by project id
|
||||
.addCase(updateProjectRateCardRolesByProjectId.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(updateProjectRateCardRolesByProjectId.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = action.payload || [];
|
||||
})
|
||||
.addCase(updateProjectRateCardRolesByProjectId.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Delete by id
|
||||
.addCase(deleteProjectRateCardRoleById.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(deleteProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
if (state.rateCardRoles && action.payload) {
|
||||
state.rateCardRoles = state.rateCardRoles.filter(role => role.id !== action.payload.id);
|
||||
}
|
||||
})
|
||||
.addCase(deleteProjectRateCardRoleById.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Delete by project id
|
||||
.addCase(deleteProjectRateCardRolesByProjectId.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(deleteProjectRateCardRolesByProjectId.fulfilled, state => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = [];
|
||||
})
|
||||
.addCase(deleteProjectRateCardRolesByProjectId.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { toggleDrawer, clearDrawerRole, clearError } = projectFinanceSlice.actions;
|
||||
|
||||
export default projectFinanceSlice.reducer;
|
||||
@@ -116,6 +116,11 @@ const projectSlice = createSlice({
|
||||
state.project.phase_label = action.payload;
|
||||
}
|
||||
},
|
||||
updateProjectCurrency: (state, action: PayloadAction<string>) => {
|
||||
if (state.project) {
|
||||
state.project.currency = action.payload;
|
||||
}
|
||||
},
|
||||
addTask: (
|
||||
state,
|
||||
action: PayloadAction<{ task: IProjectTask; groupId: string; insert?: boolean }>
|
||||
@@ -143,7 +148,6 @@ const projectSlice = createSlice({
|
||||
} 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;
|
||||
@@ -215,6 +219,7 @@ export const {
|
||||
setProjectView,
|
||||
updatePhaseLabel,
|
||||
setRefreshTimestamp,
|
||||
updateProjectCurrency
|
||||
} = projectSlice.actions;
|
||||
|
||||
export default projectSlice.reducer;
|
||||
|
||||
252
worklenz-frontend/src/features/projects/finance/finance-slice.ts
Normal file
252
worklenz-frontend/src/features/projects/finance/finance-slice.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service';
|
||||
import { RatecardType } from '@/types/project/ratecard.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
type financeState = {
|
||||
isRatecardDrawerOpen: boolean;
|
||||
isFinanceDrawerOpen: boolean;
|
||||
isImportRatecardsDrawerOpen: boolean;
|
||||
currency: string;
|
||||
isRatecardsLoading?: boolean;
|
||||
isFinanceDrawerloading?: boolean;
|
||||
drawerRatecard?: RatecardType | null;
|
||||
ratecardsList?: RatecardType[] | null;
|
||||
selectedTask?: any | null;
|
||||
};
|
||||
|
||||
const initialState: financeState = {
|
||||
isRatecardDrawerOpen: false,
|
||||
isFinanceDrawerOpen: false,
|
||||
isImportRatecardsDrawerOpen: false,
|
||||
currency: 'USD',
|
||||
isRatecardsLoading: false,
|
||||
isFinanceDrawerloading: false,
|
||||
drawerRatecard: null,
|
||||
ratecardsList: null,
|
||||
selectedTask: null,
|
||||
};
|
||||
interface FetchRateCardsParams {
|
||||
index: number;
|
||||
size: number;
|
||||
field: string | null;
|
||||
order: string | null;
|
||||
search: string | null;
|
||||
}
|
||||
// Async thunks
|
||||
export const fetchRateCards = createAsyncThunk(
|
||||
'ratecards/fetchAll',
|
||||
async (params: FetchRateCardsParams, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.getRateCards(
|
||||
params.index,
|
||||
params.size,
|
||||
params.field,
|
||||
params.order,
|
||||
params.search
|
||||
);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch RateCards', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to fetch rate cards');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchRateCardById = createAsyncThunk(
|
||||
'ratecard/fetchById',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.getRateCardById(id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch RateCardById', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to fetch rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const createRateCard = createAsyncThunk(
|
||||
'ratecards/create',
|
||||
async (body: RatecardType, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.createRateCard(body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Create RateCard', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to create rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateRateCard = createAsyncThunk(
|
||||
'ratecards/update',
|
||||
async ({ id, body }: { id: string; body: RatecardType }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.updateRateCard(id, body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Update RateCard', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to update rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteRateCard = createAsyncThunk(
|
||||
'ratecards/delete',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
await rateCardApiService.deleteRateCard(id);
|
||||
return id;
|
||||
} catch (error) {
|
||||
logger.error('Delete RateCard', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to delete rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const financeSlice = createSlice({
|
||||
name: 'financeReducer',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleRatecardDrawer: state => {
|
||||
state.isRatecardDrawerOpen = !state.isRatecardDrawerOpen;
|
||||
},
|
||||
toggleFinanceDrawer: state => {
|
||||
state.isFinanceDrawerOpen = !state.isFinanceDrawerOpen;
|
||||
},
|
||||
openFinanceDrawer: (state, action: PayloadAction<any>) => {
|
||||
state.isFinanceDrawerOpen = true;
|
||||
state.selectedTask = action.payload;
|
||||
},
|
||||
closeFinanceDrawer: state => {
|
||||
state.isFinanceDrawerOpen = false;
|
||||
state.selectedTask = null;
|
||||
},
|
||||
setSelectedTask: (state, action: PayloadAction<any>) => {
|
||||
state.selectedTask = action.payload;
|
||||
},
|
||||
toggleImportRatecardsDrawer: state => {
|
||||
state.isImportRatecardsDrawerOpen = !state.isImportRatecardsDrawerOpen;
|
||||
},
|
||||
changeCurrency: (state, action: PayloadAction<string>) => {
|
||||
state.currency = action.payload;
|
||||
},
|
||||
ratecardDrawerLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.isFinanceDrawerloading = action.payload;
|
||||
},
|
||||
clearDrawerRatecard: state => {
|
||||
state.drawerRatecard = null;
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
.addCase(fetchRateCards.pending, state => {
|
||||
state.isRatecardsLoading = true;
|
||||
})
|
||||
.addCase(fetchRateCards.fulfilled, (state, action) => {
|
||||
state.isRatecardsLoading = false;
|
||||
state.ratecardsList = Array.isArray(action.payload.data)
|
||||
? action.payload.data
|
||||
: Array.isArray(action.payload)
|
||||
? action.payload
|
||||
: [];
|
||||
})
|
||||
.addCase(fetchRateCards.rejected, state => {
|
||||
state.isRatecardsLoading = false;
|
||||
state.ratecardsList = [];
|
||||
})
|
||||
.addCase(fetchRateCardById.pending, state => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
state.drawerRatecard = null;
|
||||
})
|
||||
.addCase(fetchRateCardById.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
state.drawerRatecard = action.payload;
|
||||
})
|
||||
.addCase(fetchRateCardById.rejected, state => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
state.drawerRatecard = null;
|
||||
})
|
||||
// Create rate card
|
||||
.addCase(createRateCard.pending, state => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(createRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
if (state.ratecardsList) {
|
||||
state.ratecardsList.push(action.payload);
|
||||
} else {
|
||||
state.ratecardsList = [action.payload];
|
||||
}
|
||||
})
|
||||
.addCase(createRateCard.rejected, state => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
})
|
||||
// Update rate card
|
||||
.addCase(updateRateCard.pending, state => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(updateRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
// Update the drawerRatecard with the new data
|
||||
state.drawerRatecard = action.payload;
|
||||
// Update the rate card in the list if it exists
|
||||
if (state.ratecardsList && action.payload?.id) {
|
||||
const index = state.ratecardsList.findIndex(rc => rc.id === action.payload.id);
|
||||
if (index !== -1) {
|
||||
state.ratecardsList[index] = action.payload;
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateRateCard.rejected, state => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
})
|
||||
// Delete rate card
|
||||
.addCase(deleteRateCard.pending, state => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(deleteRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
// Remove the deleted rate card from the list
|
||||
if (state.ratecardsList) {
|
||||
state.ratecardsList = state.ratecardsList.filter(rc => rc.id !== action.payload);
|
||||
}
|
||||
// Clear drawer rate card if it was the deleted one
|
||||
if (state.drawerRatecard?.id === action.payload) {
|
||||
state.drawerRatecard = null;
|
||||
}
|
||||
})
|
||||
.addCase(deleteRateCard.rejected, state => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
toggleRatecardDrawer,
|
||||
toggleFinanceDrawer,
|
||||
openFinanceDrawer,
|
||||
closeFinanceDrawer,
|
||||
setSelectedTask,
|
||||
toggleImportRatecardsDrawer,
|
||||
changeCurrency,
|
||||
ratecardDrawerLoading,
|
||||
clearDrawerRatecard,
|
||||
} = financeSlice.actions;
|
||||
export default financeSlice.reducer;
|
||||
@@ -0,0 +1,691 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
import {
|
||||
IProjectFinanceGroup,
|
||||
IProjectFinanceTask,
|
||||
IProjectRateCard,
|
||||
IProjectFinanceProject,
|
||||
} from '@/types/project/project-finance.types';
|
||||
|
||||
type FinanceTabType = 'finance' | 'ratecard';
|
||||
type GroupTypes = 'status' | 'priority' | 'phases';
|
||||
type BillableFilterType = 'all' | 'billable' | 'non-billable';
|
||||
|
||||
interface ProjectFinanceState {
|
||||
activeTab: FinanceTabType;
|
||||
activeGroup: GroupTypes;
|
||||
billableFilter: BillableFilterType;
|
||||
loading: boolean;
|
||||
taskGroups: IProjectFinanceGroup[];
|
||||
projectRateCards: IProjectRateCard[];
|
||||
project: IProjectFinanceProject | null;
|
||||
}
|
||||
|
||||
// Enhanced utility functions for efficient frontend calculations
|
||||
const secondsToHours = (seconds: number) => seconds / 3600;
|
||||
|
||||
const calculateTaskCosts = (task: IProjectFinanceTask) => {
|
||||
const hours = secondsToHours(task.estimated_seconds || 0);
|
||||
const timeLoggedHours = secondsToHours(task.total_time_logged_seconds || 0);
|
||||
|
||||
const totalBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0);
|
||||
// task.total_actual already includes actual_cost_from_logs + fixed_cost from backend
|
||||
const totalActual = task.total_actual || 0;
|
||||
const variance = totalActual - totalBudget;
|
||||
|
||||
return {
|
||||
hours,
|
||||
timeLoggedHours,
|
||||
totalBudget,
|
||||
totalActual,
|
||||
variance,
|
||||
};
|
||||
};
|
||||
|
||||
// Memoization cache for task calculations to improve performance
|
||||
const taskCalculationCache = new Map<
|
||||
string,
|
||||
{
|
||||
task: IProjectFinanceTask;
|
||||
result: IProjectFinanceTask;
|
||||
timestamp: number;
|
||||
}
|
||||
>();
|
||||
|
||||
// Cache cleanup interval (5 minutes)
|
||||
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000;
|
||||
const CACHE_MAX_AGE = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
// Periodic cache cleanup
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
Array.from(taskCalculationCache.entries()).forEach(([key, value]) => {
|
||||
if (now - value.timestamp > CACHE_MAX_AGE) {
|
||||
taskCalculationCache.delete(key);
|
||||
}
|
||||
});
|
||||
}, CACHE_CLEANUP_INTERVAL);
|
||||
|
||||
// Generate cache key for task
|
||||
const generateTaskCacheKey = (task: IProjectFinanceTask): string => {
|
||||
return `${task.id}-${task.estimated_cost}-${task.fixed_cost}-${task.total_actual}-${task.estimated_seconds}-${task.total_time_logged_seconds}`;
|
||||
};
|
||||
|
||||
// Check if task has changed significantly to warrant recalculation
|
||||
const hasTaskChanged = (oldTask: IProjectFinanceTask, newTask: IProjectFinanceTask): boolean => {
|
||||
return (
|
||||
oldTask.estimated_cost !== newTask.estimated_cost ||
|
||||
oldTask.fixed_cost !== newTask.fixed_cost ||
|
||||
oldTask.total_actual !== newTask.total_actual ||
|
||||
oldTask.estimated_seconds !== newTask.estimated_seconds ||
|
||||
oldTask.total_time_logged_seconds !== newTask.total_time_logged_seconds
|
||||
);
|
||||
};
|
||||
|
||||
// Optimized recursive calculation for task hierarchy with memoization
|
||||
const recalculateTaskHierarchy = (tasks: IProjectFinanceTask[]): IProjectFinanceTask[] => {
|
||||
return tasks.map(task => {
|
||||
// If task has loaded subtasks, recalculate from subtasks
|
||||
if (task.sub_tasks && task.sub_tasks.length > 0) {
|
||||
const updatedSubTasks = recalculateTaskHierarchy(task.sub_tasks);
|
||||
|
||||
// Calculate totals from subtasks only (for time and costs from logs)
|
||||
const subtaskTotals = updatedSubTasks.reduce(
|
||||
(acc, subtask) => ({
|
||||
estimated_cost: acc.estimated_cost + (subtask.estimated_cost || 0),
|
||||
fixed_cost: acc.fixed_cost + (subtask.fixed_cost || 0),
|
||||
actual_cost_from_logs: acc.actual_cost_from_logs + (subtask.actual_cost_from_logs || 0),
|
||||
estimated_seconds: acc.estimated_seconds + (subtask.estimated_seconds || 0),
|
||||
total_time_logged_seconds:
|
||||
acc.total_time_logged_seconds + (subtask.total_time_logged_seconds || 0),
|
||||
}),
|
||||
{
|
||||
estimated_cost: 0,
|
||||
fixed_cost: 0,
|
||||
actual_cost_from_logs: 0,
|
||||
estimated_seconds: 0,
|
||||
total_time_logged_seconds: 0,
|
||||
}
|
||||
);
|
||||
|
||||
// For parent tasks with loaded subtasks: use ONLY the subtask totals
|
||||
// The parent's original values were backend-aggregated, now we use frontend subtask aggregation
|
||||
const totalFixedCost = subtaskTotals.fixed_cost; // Only subtask fixed costs
|
||||
const totalEstimatedCost = subtaskTotals.estimated_cost; // Only subtask estimated costs
|
||||
const totalActualCostFromLogs = subtaskTotals.actual_cost_from_logs; // Only subtask logged costs
|
||||
const totalActual = totalActualCostFromLogs + totalFixedCost;
|
||||
|
||||
// Update parent task with aggregated values
|
||||
const updatedTask = {
|
||||
...task,
|
||||
sub_tasks: updatedSubTasks,
|
||||
estimated_cost: totalEstimatedCost,
|
||||
fixed_cost: totalFixedCost,
|
||||
actual_cost_from_logs: totalActualCostFromLogs,
|
||||
total_actual: totalActual,
|
||||
estimated_seconds: subtaskTotals.estimated_seconds,
|
||||
total_time_logged_seconds: subtaskTotals.total_time_logged_seconds,
|
||||
total_budget: totalEstimatedCost + totalFixedCost,
|
||||
variance: totalActual - (totalEstimatedCost + totalFixedCost),
|
||||
};
|
||||
|
||||
return updatedTask;
|
||||
}
|
||||
|
||||
// For parent tasks without loaded subtasks, trust backend-calculated values
|
||||
if (task.sub_tasks_count > 0 && (!task.sub_tasks || task.sub_tasks.length === 0)) {
|
||||
// Parent task with unloaded subtasks - backend has already calculated aggregated values
|
||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||
return {
|
||||
...task,
|
||||
total_budget: totalBudget,
|
||||
total_actual: totalActual,
|
||||
variance: variance,
|
||||
};
|
||||
}
|
||||
|
||||
// For leaf tasks, check cache first
|
||||
const cacheKey = generateTaskCacheKey(task);
|
||||
const cached = taskCalculationCache.get(cacheKey);
|
||||
|
||||
if (cached && !hasTaskChanged(cached.task, task)) {
|
||||
return { ...cached.result, ...task }; // Merge with current task to preserve other properties
|
||||
}
|
||||
|
||||
// For leaf tasks, just recalculate their own values
|
||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||
const updatedTask = {
|
||||
...task,
|
||||
total_budget: totalBudget,
|
||||
total_actual: totalActual,
|
||||
variance: variance,
|
||||
};
|
||||
|
||||
// Cache the result only for leaf tasks
|
||||
taskCalculationCache.set(cacheKey, {
|
||||
task: { ...task },
|
||||
result: updatedTask,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return updatedTask;
|
||||
});
|
||||
};
|
||||
|
||||
// Optimized function to find and update a specific task, then recalculate hierarchy
|
||||
const updateTaskAndRecalculateHierarchy = (
|
||||
tasks: IProjectFinanceTask[],
|
||||
targetId: string,
|
||||
updateFn: (task: IProjectFinanceTask) => IProjectFinanceTask
|
||||
): { updated: boolean; tasks: IProjectFinanceTask[] } => {
|
||||
let updated = false;
|
||||
|
||||
const updatedTasks = tasks.map(task => {
|
||||
if (task.id === targetId) {
|
||||
updated = true;
|
||||
return updateFn(task);
|
||||
}
|
||||
|
||||
// Search in subtasks recursively
|
||||
if (task.sub_tasks && task.sub_tasks.length > 0) {
|
||||
const result = updateTaskAndRecalculateHierarchy(task.sub_tasks, targetId, updateFn);
|
||||
if (result.updated) {
|
||||
updated = true;
|
||||
return {
|
||||
...task,
|
||||
sub_tasks: result.tasks,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return task;
|
||||
});
|
||||
|
||||
// If a task was updated, recalculate the entire hierarchy to ensure parent totals are correct
|
||||
return {
|
||||
updated,
|
||||
tasks: updated ? recalculateTaskHierarchy(updatedTasks) : updatedTasks,
|
||||
};
|
||||
};
|
||||
|
||||
const initialState: ProjectFinanceState = {
|
||||
activeTab: 'finance',
|
||||
activeGroup: 'status',
|
||||
billableFilter: 'billable',
|
||||
loading: false,
|
||||
taskGroups: [],
|
||||
projectRateCards: [],
|
||||
project: null,
|
||||
};
|
||||
|
||||
export const fetchProjectFinances = createAsyncThunk(
|
||||
'projectFinances/fetchProjectFinances',
|
||||
async ({
|
||||
projectId,
|
||||
groupBy,
|
||||
billableFilter,
|
||||
}: {
|
||||
projectId: string;
|
||||
groupBy: GroupTypes;
|
||||
billableFilter?: BillableFilterType;
|
||||
}) => {
|
||||
const response = await projectFinanceApiService.getProjectTasks(
|
||||
projectId,
|
||||
groupBy,
|
||||
billableFilter
|
||||
);
|
||||
return response.body;
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchProjectFinancesSilent = createAsyncThunk(
|
||||
'projectFinances/fetchProjectFinancesSilent',
|
||||
async ({
|
||||
projectId,
|
||||
groupBy,
|
||||
billableFilter,
|
||||
resetExpansions = false,
|
||||
}: {
|
||||
projectId: string;
|
||||
groupBy: GroupTypes;
|
||||
billableFilter?: BillableFilterType;
|
||||
resetExpansions?: boolean;
|
||||
}) => {
|
||||
const response = await projectFinanceApiService.getProjectTasks(
|
||||
projectId,
|
||||
groupBy,
|
||||
billableFilter
|
||||
);
|
||||
return { ...response.body, resetExpansions };
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchSubTasks = createAsyncThunk(
|
||||
'projectFinances/fetchSubTasks',
|
||||
async ({
|
||||
projectId,
|
||||
parentTaskId,
|
||||
billableFilter,
|
||||
}: {
|
||||
projectId: string;
|
||||
parentTaskId: string;
|
||||
billableFilter?: BillableFilterType;
|
||||
}) => {
|
||||
const response = await projectFinanceApiService.getSubTasks(
|
||||
projectId,
|
||||
parentTaskId,
|
||||
billableFilter
|
||||
);
|
||||
return { parentTaskId, subTasks: response.body };
|
||||
}
|
||||
);
|
||||
|
||||
export const updateTaskFixedCostAsync = createAsyncThunk(
|
||||
'projectFinances/updateTaskFixedCostAsync',
|
||||
async ({
|
||||
taskId,
|
||||
groupId,
|
||||
fixedCost,
|
||||
}: {
|
||||
taskId: string;
|
||||
groupId: string;
|
||||
fixedCost: number;
|
||||
}) => {
|
||||
await projectFinanceApiService.updateTaskFixedCost(taskId, fixedCost);
|
||||
return { taskId, groupId, fixedCost };
|
||||
}
|
||||
);
|
||||
|
||||
export const updateProjectCalculationMethodAsync = createAsyncThunk(
|
||||
'projectFinances/updateProjectCalculationMethodAsync',
|
||||
async ({
|
||||
projectId,
|
||||
calculationMethod,
|
||||
hoursPerDay,
|
||||
}: {
|
||||
projectId: string;
|
||||
calculationMethod: 'hourly' | 'man_days';
|
||||
hoursPerDay?: number;
|
||||
}) => {
|
||||
await projectFinanceApiService.updateProjectCalculationMethod(
|
||||
projectId,
|
||||
calculationMethod,
|
||||
hoursPerDay
|
||||
);
|
||||
return { calculationMethod, hoursPerDay };
|
||||
}
|
||||
);
|
||||
|
||||
export const updateTaskEstimatedManDaysAsync = createAsyncThunk(
|
||||
'projectFinances/updateTaskEstimatedManDaysAsync',
|
||||
async ({
|
||||
taskId,
|
||||
groupId,
|
||||
estimatedManDays,
|
||||
}: {
|
||||
taskId: string;
|
||||
groupId: string;
|
||||
estimatedManDays: number;
|
||||
}) => {
|
||||
await projectFinanceApiService.updateTaskEstimatedManDays(taskId, estimatedManDays);
|
||||
return { taskId, groupId, estimatedManDays };
|
||||
}
|
||||
);
|
||||
|
||||
export const updateRateCardManDayRateAsync = createAsyncThunk(
|
||||
'projectFinances/updateRateCardManDayRateAsync',
|
||||
async ({ rateCardRoleId, manDayRate }: { rateCardRoleId: string; manDayRate: number }) => {
|
||||
await projectFinanceApiService.updateRateCardManDayRate(rateCardRoleId, manDayRate);
|
||||
return { rateCardRoleId, manDayRate };
|
||||
}
|
||||
);
|
||||
|
||||
// Function to clear calculation cache (useful for testing or when data is refreshed)
|
||||
const clearCalculationCache = () => {
|
||||
taskCalculationCache.clear();
|
||||
};
|
||||
|
||||
export const projectFinancesSlice = createSlice({
|
||||
name: 'projectFinances',
|
||||
initialState,
|
||||
reducers: {
|
||||
setActiveTab: (state, action: PayloadAction<FinanceTabType>) => {
|
||||
state.activeTab = action.payload;
|
||||
},
|
||||
setActiveGroup: (state, action: PayloadAction<GroupTypes>) => {
|
||||
state.activeGroup = action.payload;
|
||||
},
|
||||
setBillableFilter: (state, action: PayloadAction<BillableFilterType>) => {
|
||||
state.billableFilter = action.payload;
|
||||
},
|
||||
resetAllTaskExpansions: state => {
|
||||
// Recursive function to reset all expansion states
|
||||
const resetExpansionStates = (tasks: IProjectFinanceTask[]): IProjectFinanceTask[] => {
|
||||
return tasks.map(task => ({
|
||||
...task,
|
||||
show_sub_tasks: false,
|
||||
sub_tasks: task.sub_tasks ? resetExpansionStates(task.sub_tasks) : task.sub_tasks,
|
||||
}));
|
||||
};
|
||||
|
||||
// Reset expansion states for all groups
|
||||
state.taskGroups = state.taskGroups.map(group => ({
|
||||
...group,
|
||||
tasks: resetExpansionStates(group.tasks),
|
||||
}));
|
||||
},
|
||||
updateTaskFixedCost: (
|
||||
state,
|
||||
action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>
|
||||
) => {
|
||||
const { taskId, groupId, fixedCost } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
fixed_cost: fixedCost,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateTaskEstimatedCost: (
|
||||
state,
|
||||
action: PayloadAction<{ taskId: string; groupId: string; estimatedCost: number }>
|
||||
) => {
|
||||
const { taskId, groupId, estimatedCost } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
estimated_cost: estimatedCost,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateTaskTimeLogged: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
taskId: string;
|
||||
groupId: string;
|
||||
timeLoggedSeconds: number;
|
||||
timeLoggedString: string;
|
||||
totalActual: number;
|
||||
}>
|
||||
) => {
|
||||
const { taskId, groupId, timeLoggedSeconds, timeLoggedString, totalActual } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
total_time_logged_seconds: timeLoggedSeconds,
|
||||
total_time_logged: timeLoggedString,
|
||||
total_actual: totalActual,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleTaskExpansion: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => {
|
||||
const { taskId, groupId } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
// Recursive function to find and toggle a task in the hierarchy
|
||||
const findAndToggleTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => {
|
||||
for (const task of tasks) {
|
||||
if (task.id === targetId) {
|
||||
task.show_sub_tasks = !task.show_sub_tasks;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in subtasks recursively
|
||||
if (task.sub_tasks && findAndToggleTask(task.sub_tasks, targetId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
findAndToggleTask(group.tasks, taskId);
|
||||
}
|
||||
},
|
||||
updateProjectFinanceCurrency: (state, action: PayloadAction<string>) => {
|
||||
if (state.project) {
|
||||
state.project.currency = action.payload;
|
||||
}
|
||||
},
|
||||
updateProjectCalculationMethod: (
|
||||
state,
|
||||
action: PayloadAction<{ calculationMethod: 'hourly' | 'man_days'; hoursPerDay?: number }>
|
||||
) => {
|
||||
if (state.project) {
|
||||
state.project.calculation_method = action.payload.calculationMethod;
|
||||
if (action.payload.hoursPerDay !== undefined) {
|
||||
state.project.hours_per_day = action.payload.hoursPerDay;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateTaskEstimatedManDays: (
|
||||
state,
|
||||
action: PayloadAction<{ taskId: string; groupId: string; estimatedManDays: number }>
|
||||
) => {
|
||||
const { taskId, groupId, estimatedManDays } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
estimated_man_days: estimatedManDays,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateRateCardManDayRate: (
|
||||
state,
|
||||
action: PayloadAction<{ rateCardRoleId: string; manDayRate: number }>
|
||||
) => {
|
||||
const { rateCardRoleId, manDayRate } = action.payload;
|
||||
const rateCard = state.projectRateCards.find(rc => rc.id === rateCardRoleId);
|
||||
|
||||
if (rateCard) {
|
||||
rateCard.man_day_rate = manDayRate.toString();
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
.addCase(fetchProjectFinances.pending, state => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(fetchProjectFinances.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
// Apply hierarchy recalculation to ensure parent tasks show correct aggregated values
|
||||
const recalculatedGroups = action.payload.groups.map((group: IProjectFinanceGroup) => ({
|
||||
...group,
|
||||
tasks: recalculateTaskHierarchy(group.tasks),
|
||||
}));
|
||||
state.taskGroups = recalculatedGroups;
|
||||
state.projectRateCards = action.payload.project_rate_cards;
|
||||
state.project = action.payload.project;
|
||||
// Clear cache when fresh data is loaded
|
||||
clearCalculationCache();
|
||||
})
|
||||
.addCase(fetchProjectFinances.rejected, state => {
|
||||
state.loading = false;
|
||||
})
|
||||
.addCase(fetchProjectFinancesSilent.fulfilled, (state, action) => {
|
||||
const { resetExpansions, ...payload } = action.payload;
|
||||
|
||||
if (resetExpansions) {
|
||||
// Reset all expansions and load fresh data
|
||||
const recalculatedGroups = payload.groups.map((group: IProjectFinanceGroup) => ({
|
||||
...group,
|
||||
tasks: recalculateTaskHierarchy(group.tasks),
|
||||
}));
|
||||
state.taskGroups = recalculatedGroups;
|
||||
} else {
|
||||
// Helper function to preserve expansion state and sub_tasks during updates
|
||||
const preserveExpansionState = (
|
||||
existingTasks: IProjectFinanceTask[],
|
||||
newTasks: IProjectFinanceTask[]
|
||||
): IProjectFinanceTask[] => {
|
||||
return newTasks.map(newTask => {
|
||||
const existingTask = existingTasks.find(t => t.id === newTask.id);
|
||||
if (existingTask) {
|
||||
// Preserve expansion state and subtasks
|
||||
const updatedTask = {
|
||||
...newTask,
|
||||
show_sub_tasks: existingTask.show_sub_tasks,
|
||||
sub_tasks: existingTask.sub_tasks
|
||||
? preserveExpansionState(existingTask.sub_tasks, newTask.sub_tasks || [])
|
||||
: newTask.sub_tasks,
|
||||
};
|
||||
return updatedTask;
|
||||
}
|
||||
return newTask;
|
||||
});
|
||||
};
|
||||
|
||||
// Update groups while preserving expansion state and applying hierarchy recalculation
|
||||
const updatedTaskGroups = payload.groups.map((newGroup: IProjectFinanceGroup) => {
|
||||
const existingGroup = state.taskGroups.find(g => g.group_id === newGroup.group_id);
|
||||
if (existingGroup) {
|
||||
const tasksWithExpansion = preserveExpansionState(
|
||||
existingGroup.tasks,
|
||||
newGroup.tasks
|
||||
);
|
||||
return {
|
||||
...newGroup,
|
||||
tasks: recalculateTaskHierarchy(tasksWithExpansion),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...newGroup,
|
||||
tasks: recalculateTaskHierarchy(newGroup.tasks),
|
||||
};
|
||||
});
|
||||
state.taskGroups = updatedTaskGroups;
|
||||
}
|
||||
|
||||
// Update data without changing loading state for silent refresh
|
||||
state.projectRateCards = payload.project_rate_cards;
|
||||
state.project = payload.project;
|
||||
// Clear cache when data is refreshed from backend
|
||||
clearCalculationCache();
|
||||
})
|
||||
.addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => {
|
||||
const { taskId, groupId, fixedCost } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
// Update the specific task's fixed cost and recalculate the entire hierarchy
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
fixed_cost: fixedCost,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
clearCalculationCache();
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(fetchSubTasks.fulfilled, (state, action) => {
|
||||
const { parentTaskId, subTasks } = action.payload;
|
||||
|
||||
// Recursive function to find and update a task in the hierarchy
|
||||
const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => {
|
||||
for (const task of tasks) {
|
||||
if (task.id === targetId) {
|
||||
// Found the parent task, add subtasks
|
||||
task.sub_tasks = subTasks.map(subTask => ({
|
||||
...subTask,
|
||||
is_sub_task: true,
|
||||
parent_task_id: targetId,
|
||||
}));
|
||||
task.show_sub_tasks = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in subtasks recursively
|
||||
if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Find the parent task in any group and add the subtasks
|
||||
for (const group of state.taskGroups) {
|
||||
if (findAndUpdateTask(group.tasks, parentTaskId)) {
|
||||
// Recalculate the hierarchy after adding subtasks to ensure parent values are correct
|
||||
group.tasks = recalculateTaskHierarchy(group.tasks);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateProjectCalculationMethodAsync.fulfilled, (state, action) => {
|
||||
if (state.project) {
|
||||
state.project.calculation_method = action.payload.calculationMethod;
|
||||
if (action.payload.hoursPerDay !== undefined) {
|
||||
state.project.hours_per_day = action.payload.hoursPerDay;
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateTaskEstimatedManDaysAsync.fulfilled, (state, action) => {
|
||||
const { taskId, groupId, estimatedManDays } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
estimated_man_days: estimatedManDays,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
clearCalculationCache();
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateRateCardManDayRateAsync.fulfilled, (state, action) => {
|
||||
const { rateCardRoleId, manDayRate } = action.payload;
|
||||
const rateCard = state.projectRateCards.find(rc => rc.id === rateCardRoleId);
|
||||
|
||||
if (rateCard) {
|
||||
rateCard.man_day_rate = manDayRate.toString();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setActiveTab,
|
||||
setActiveGroup,
|
||||
setBillableFilter,
|
||||
resetAllTaskExpansions,
|
||||
updateTaskFixedCost,
|
||||
updateTaskEstimatedCost,
|
||||
updateTaskTimeLogged,
|
||||
toggleTaskExpansion,
|
||||
updateProjectFinanceCurrency,
|
||||
updateProjectCalculationMethod,
|
||||
updateTaskEstimatedManDays,
|
||||
updateRateCardManDayRate,
|
||||
} = projectFinancesSlice.actions;
|
||||
|
||||
export default projectFinancesSlice.reducer;
|
||||
Reference in New Issue
Block a user