feat(reporting): implement member selection and filtering in time reports
This commit is contained in:
@@ -94,7 +94,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
|||||||
SELECT name,
|
SELECT name,
|
||||||
(SELECT COALESCE(SUM(time_spent), 0)
|
(SELECT COALESCE(SUM(time_spent), 0)
|
||||||
FROM task_work_log
|
FROM task_work_log
|
||||||
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
|
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
|
||||||
WHERE user_id = users.id ${billableQuery}
|
WHERE user_id = users.id ${billableQuery}
|
||||||
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
|
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
|
||||||
AND tasks.project_id = projects.id
|
AND tasks.project_id = projects.id
|
||||||
@@ -473,17 +473,24 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
|||||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
|
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
|
||||||
|
|
||||||
const billableQuery = this.buildBillableQuery(billable);
|
const billableQuery = this.buildBillableQuery(billable);
|
||||||
|
const members = (req.body.members || []) as string[];
|
||||||
|
// Prepare members filter
|
||||||
|
let membersFilter = "";
|
||||||
|
if (members.length > 0) {
|
||||||
|
const memberIds = members.map(id => `'${id}'`).join(",");
|
||||||
|
membersFilter = `AND tmiv.team_member_id IN (${memberIds})`;
|
||||||
|
}
|
||||||
const q = `
|
const q = `
|
||||||
SELECT tmiv.email, tmiv.name, SUM(time_spent) AS logged_time
|
SELECT tmiv.team_member_id, tmiv.email, tmiv.name, SUM(time_spent) AS logged_time
|
||||||
FROM team_member_info_view tmiv
|
FROM team_member_info_view tmiv
|
||||||
LEFT JOIN task_work_log ON task_work_log.user_id = tmiv.user_id
|
LEFT JOIN task_work_log ON task_work_log.user_id = tmiv.user_id
|
||||||
LEFT JOIN tasks ON tasks.id = task_work_log.task_id ${billableQuery}
|
LEFT JOIN tasks ON tasks.id = task_work_log.task_id ${billableQuery}
|
||||||
LEFT JOIN projects p ON p.id = tasks.project_id AND p.team_id = tmiv.team_id
|
LEFT JOIN projects p ON p.id = tasks.project_id AND p.team_id = tmiv.team_id
|
||||||
WHERE p.id IN (${projectIds})
|
WHERE p.id IN (${projectIds})
|
||||||
${durationClause} ${archivedClause}
|
${durationClause} ${archivedClause}
|
||||||
GROUP BY tmiv.email, tmiv.name
|
${membersFilter}
|
||||||
ORDER BY logged_time DESC;`;
|
GROUP BY tmiv.email, tmiv.name, tmiv.team_member_id
|
||||||
|
ORDER BY logged_time DESC;`;
|
||||||
const result = await db.query(q, []);
|
const result = await db.query(q, []);
|
||||||
|
|
||||||
for (const member of result.rows) {
|
for (const member of result.rows) {
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export const reportingTimesheetApiService = {
|
|||||||
|
|
||||||
getMemberTimeSheets: async (body = {}, archived = false): Promise<IServerResponse<IRPTTimeMember[]>> => {
|
getMemberTimeSheets: async (body = {}, archived = false): Promise<IServerResponse<IRPTTimeMember[]>> => {
|
||||||
const q = toQueryString({ archived });
|
const q = toQueryString({ archived });
|
||||||
|
console.log('getMemberTimeSheets body:', body);
|
||||||
const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, body);
|
const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, body);
|
||||||
|
console.log('getMemberTimeSheets response:', response);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ interface ITimeReportsOverviewState {
|
|||||||
billable: boolean;
|
billable: boolean;
|
||||||
nonBillable: boolean;
|
nonBillable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
members: any[];
|
||||||
|
loadingMembers: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ITimeReportsOverviewState = {
|
const initialState: ITimeReportsOverviewState = {
|
||||||
@@ -42,6 +45,12 @@ const initialState: ITimeReportsOverviewState = {
|
|||||||
billable: true,
|
billable: true,
|
||||||
nonBillable: true,
|
nonBillable: true,
|
||||||
},
|
},
|
||||||
|
members: [],
|
||||||
|
loadingMembers: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedMembers = (state: ITimeReportsOverviewState) => {
|
||||||
|
return state.members.filter(member => member.selected).map(member => member.id) as string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedTeams = (state: ITimeReportsOverviewState) => {
|
const selectedTeams = (state: ITimeReportsOverviewState) => {
|
||||||
@@ -54,6 +63,26 @@ const selectedCategories = (state: ITimeReportsOverviewState) => {
|
|||||||
.map(category => category.id) as string[];
|
.map(category => category.id) as string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchReportingMembers = createAsyncThunk(
|
||||||
|
'timeReportsOverview/fetchReportingMembers',
|
||||||
|
async (_, { rejectWithValue, getState }) => {
|
||||||
|
const state = getState() as { timeReportsOverviewReducer: ITimeReportsOverviewState };
|
||||||
|
const { timeReportsOverviewReducer } = state;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await reportingApiService.getMembers(selectedMembers(timeReportsOverviewReducer));
|
||||||
|
if (res.done) {
|
||||||
|
// Extract members from the response
|
||||||
|
return res.body.members; // Use `body.members` instead of `body`
|
||||||
|
} else {
|
||||||
|
return rejectWithValue(res.message || 'Failed to fetch members');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(error.message || 'An error occurred while fetching members');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const fetchReportingTeams = createAsyncThunk(
|
export const fetchReportingTeams = createAsyncThunk(
|
||||||
'timeReportsOverview/fetchReportingTeams',
|
'timeReportsOverview/fetchReportingTeams',
|
||||||
async () => {
|
async () => {
|
||||||
@@ -123,6 +152,7 @@ const timeReportsOverviewSlice = createSlice({
|
|||||||
setSelectOrDeselectProject: (state, action) => {
|
setSelectOrDeselectProject: (state, action) => {
|
||||||
const project = state.projects.find(project => project.id === action.payload.id);
|
const project = state.projects.find(project => project.id === action.payload.id);
|
||||||
if (project) {
|
if (project) {
|
||||||
|
console.log('setSelectOrDeselectProject', project, action.payload);
|
||||||
project.selected = action.payload.selected;
|
project.selected = action.payload.selected;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -141,6 +171,17 @@ const timeReportsOverviewSlice = createSlice({
|
|||||||
setArchived: (state, action: PayloadAction<boolean>) => {
|
setArchived: (state, action: PayloadAction<boolean>) => {
|
||||||
state.archived = action.payload;
|
state.archived = action.payload;
|
||||||
},
|
},
|
||||||
|
setSelectOrDeselectMember: (state, action: PayloadAction<{ id: string; selected: boolean }>) => {
|
||||||
|
const member = state.members.find(member => member.id === action.payload.id);
|
||||||
|
if (member) {
|
||||||
|
member.selected = action.payload.selected;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setSelectOrDeselectAllMembers: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.members.forEach(member => {
|
||||||
|
member.selected = action.payload;
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder.addCase(fetchReportingTeams.fulfilled, (state, action) => {
|
builder.addCase(fetchReportingTeams.fulfilled, (state, action) => {
|
||||||
@@ -185,6 +226,29 @@ const timeReportsOverviewSlice = createSlice({
|
|||||||
builder.addCase(fetchReportingProjects.rejected, state => {
|
builder.addCase(fetchReportingProjects.rejected, state => {
|
||||||
state.loadingProjects = false;
|
state.loadingProjects = false;
|
||||||
});
|
});
|
||||||
|
builder.addCase(fetchReportingMembers.fulfilled, (state, action) => {
|
||||||
|
console.log('fetchReportingMembers fulfilled', action.payload);
|
||||||
|
const members = action.payload.map((member: any) => ({
|
||||||
|
id: member.id,
|
||||||
|
name: member.name,
|
||||||
|
selected: true, // Default to selected
|
||||||
|
avatar_url: member.avatar_url, // Include avatar URL if needed
|
||||||
|
email: member.email, // Include email if needed
|
||||||
|
}));
|
||||||
|
state.members = members;
|
||||||
|
state.loadingMembers = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addCase(fetchReportingMembers.pending, state => {
|
||||||
|
console.log('fetchReportingMembers pending');
|
||||||
|
state.loadingMembers = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addCase(fetchReportingMembers.rejected, (state, action) => {
|
||||||
|
console.log('fetchReportingMembers rejected', action.payload);
|
||||||
|
state.loadingMembers = false;
|
||||||
|
console.error('Error fetching members:', action.payload);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,6 +261,8 @@ export const {
|
|||||||
setSelectOrDeselectProject,
|
setSelectOrDeselectProject,
|
||||||
setSelectOrDeselectAllProjects,
|
setSelectOrDeselectAllProjects,
|
||||||
setSelectOrDeselectBillable,
|
setSelectOrDeselectBillable,
|
||||||
|
setSelectOrDeselectMember,
|
||||||
|
setSelectOrDeselectAllMembers,
|
||||||
setNoCategory,
|
setNoCategory,
|
||||||
setArchived,
|
setArchived,
|
||||||
} = timeReportsOverviewSlice.actions;
|
} = timeReportsOverviewSlice.actions;
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
|||||||
loadingCategories,
|
loadingCategories,
|
||||||
projects: filterProjects,
|
projects: filterProjects,
|
||||||
loadingProjects,
|
loadingProjects,
|
||||||
|
members,
|
||||||
|
loadingMembers,
|
||||||
billable,
|
billable,
|
||||||
archived,
|
archived,
|
||||||
} = useAppSelector(state => state.timeReportsOverviewReducer);
|
} = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||||
@@ -170,11 +172,13 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
|||||||
const selectedTeams = teams.filter(team => team.selected);
|
const selectedTeams = teams.filter(team => team.selected);
|
||||||
const selectedProjects = filterProjects.filter(project => project.selected);
|
const selectedProjects = filterProjects.filter(project => project.selected);
|
||||||
const selectedCategories = categories.filter(category => category.selected);
|
const selectedCategories = categories.filter(category => category.selected);
|
||||||
|
const selectedMembers = members.filter(member => member.selected); // Use selected members
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
teams: selectedTeams.map(t => t.id),
|
teams: selectedTeams.map(t => t.id),
|
||||||
projects: selectedProjects.map(project => project.id),
|
projects: selectedProjects.map(project => project.id),
|
||||||
categories: selectedCategories.map(category => category.id),
|
categories: selectedCategories.map(category => category.id),
|
||||||
|
members: selectedMembers.map(member => member.id), // Include members in the request
|
||||||
duration,
|
duration,
|
||||||
date_range: dateRange,
|
date_range: dateRange,
|
||||||
billable,
|
billable,
|
||||||
@@ -185,6 +189,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
|||||||
setJsonData(res.body || []);
|
setJsonData(res.body || []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error fetching chart data:', error);
|
||||||
logger.error('Error fetching chart data:', error);
|
logger.error('Error fetching chart data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -193,7 +198,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchChartData();
|
fetchChartData();
|
||||||
}, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories]);
|
}, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories, members]);
|
||||||
|
|
||||||
const exportChart = () => {
|
const exportChart = () => {
|
||||||
if (chartRef.current) {
|
if (chartRef.current) {
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { setSelectOrDeselectAllMembers, setSelectOrDeselectMember } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||||
|
import { Button, Checkbox, Divider, Dropdown, Input, Avatar, theme } from 'antd';
|
||||||
|
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||||
|
import { CaretDownFilled } from '@ant-design/icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const Members: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { t } = useTranslation('time-report');
|
||||||
|
const { members, loadingMembers } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [selectAll, setSelectAll] = useState(true);
|
||||||
|
|
||||||
|
// Filter members based on search text
|
||||||
|
const filteredMembers = members.filter(member =>
|
||||||
|
member.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle checkbox change for individual members
|
||||||
|
const handleCheckboxChange = (id: string, checked: boolean) => {
|
||||||
|
console.log('Select Change:', id);
|
||||||
|
dispatch(setSelectOrDeselectMember({ id, selected: checked }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle "Select All" checkbox change
|
||||||
|
const handleSelectAllChange = (e: CheckboxChangeEvent) => {
|
||||||
|
console.log('Select All Change:', e);
|
||||||
|
const isChecked = e.target.checked;
|
||||||
|
setSelectAll(isChecked);
|
||||||
|
dispatch(setSelectOrDeselectAllMembers(isChecked));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
menu={undefined}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger={['click']}
|
||||||
|
dropdownRender={() => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
boxShadow: token.boxShadow,
|
||||||
|
padding: '4px 0',
|
||||||
|
maxHeight: '330px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '8px', flexShrink: 0 }}>
|
||||||
|
<Input
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
placeholder={t('searchByMember')}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '0 12px', flexShrink: 0 }}>
|
||||||
|
<Checkbox
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onChange={handleSelectAllChange}
|
||||||
|
checked={selectAll}
|
||||||
|
>
|
||||||
|
{t('selectAll')}
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
overflowY: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredMembers.map(member => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: token.colorBgTextHover,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar src={member.avatar_url} alt={member.name} />
|
||||||
|
<Checkbox
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
checked={member.selected}
|
||||||
|
onChange={e => handleCheckboxChange(member.id, e.target.checked)}
|
||||||
|
>
|
||||||
|
{member.name}
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button loading={loadingMembers}>
|
||||||
|
{t('members')} <CaretDownFilled />
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Members;
|
||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
fetchReportingTeams,
|
fetchReportingTeams,
|
||||||
fetchReportingProjects,
|
fetchReportingProjects,
|
||||||
fetchReportingCategories,
|
fetchReportingCategories,
|
||||||
|
fetchReportingMembers,
|
||||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||||
|
import Members from './members';
|
||||||
|
|
||||||
const TimeReportPageHeader: React.FC = () => {
|
const TimeReportPageHeader: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -18,6 +20,7 @@ const TimeReportPageHeader: React.FC = () => {
|
|||||||
await dispatch(fetchReportingTeams());
|
await dispatch(fetchReportingTeams());
|
||||||
await dispatch(fetchReportingCategories());
|
await dispatch(fetchReportingCategories());
|
||||||
await dispatch(fetchReportingProjects());
|
await dispatch(fetchReportingProjects());
|
||||||
|
await dispatch(fetchReportingMembers());
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -29,6 +32,7 @@ const TimeReportPageHeader: React.FC = () => {
|
|||||||
<Categories />
|
<Categories />
|
||||||
<Projects />
|
<Projects />
|
||||||
<Billable />
|
<Billable />
|
||||||
|
<Members/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user