Enhance project member management with search functionality and localization updates
- Implemented search functionality for project members in the backend, allowing users to filter members by name or email. - Updated frontend components to include a search input for members, improving user experience. - Added localization strings for the search placeholder in multiple languages (Albanian, German, English, Spanish, Portuguese). - Refactored SQL queries for better performance and clarity in fetching project members.
This commit is contained in:
@@ -317,65 +317,58 @@ export default class ProjectsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
|
||||
const search = (req.query.search || "").toString().trim();
|
||||
|
||||
let searchFilter = "";
|
||||
const params = [req.params.id, req.user?.team_id ?? null, size, offset];
|
||||
if (search) {
|
||||
searchFilter = `
|
||||
AND (
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
|
||||
OR (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
|
||||
)
|
||||
`;
|
||||
params.push(search);
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS members
|
||||
FROM (SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT project_members.id,
|
||||
WITH filtered_members AS (
|
||||
SELECT project_members.id,
|
||||
team_member_id,
|
||||
(SELECT name
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id),
|
||||
(SELECT email
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id) AS email,
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS name,
|
||||
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS email,
|
||||
u.avatar_url,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = project_members.project_id
|
||||
AND id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = project_members.project_id
|
||||
AND id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE tasks_assignees.project_member_id = project_members.id)
|
||||
AND status_id IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE category_id = (SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE is_done IS TRUE))) AS completed_tasks_count,
|
||||
EXISTS(SELECT email
|
||||
FROM email_invitations
|
||||
WHERE team_member_id = project_members.team_member_id
|
||||
AND email_invitations.team_id = $2) AS pending_invitation,
|
||||
(SELECT project_access_levels.name
|
||||
FROM project_access_levels
|
||||
WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
|
||||
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
|
||||
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id) AND status_id IN (SELECT id FROM task_statuses WHERE category_id = (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count,
|
||||
EXISTS(SELECT email FROM email_invitations WHERE team_member_id = project_members.team_member_id AND email_invitations.team_id = $2) AS pending_invitation,
|
||||
(SELECT project_access_levels.name FROM project_access_levels WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
|
||||
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
|
||||
FROM project_members
|
||||
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
WHERE project_id = $1
|
||||
${search ? searchFilter : ""}
|
||||
)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM filtered_members) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (
|
||||
SELECT * FROM filtered_members
|
||||
ORDER BY ${sortField} ${sortOrder}
|
||||
LIMIT $3 OFFSET $4) t) AS data
|
||||
FROM project_members
|
||||
WHERE project_id = $1) rec;
|
||||
LIMIT $3 OFFSET $4
|
||||
) t
|
||||
) AS data
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, size, offset]);
|
||||
|
||||
const result = await db.query(q, params);
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const member of data?.members.data || []) {
|
||||
for (const member of data?.data || []) {
|
||||
member.progress = member.all_tasks_count > 0
|
||||
? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data?.members || this.paginatedDatasetDefaultStruct));
|
||||
return res.status(200).send(new ServerResponse(true, data || this.paginatedDatasetDefaultStruct));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
"deleteButtonTooltip": "Hiq nga projekti",
|
||||
"memberCount": "Anëtar",
|
||||
"membersCountPlural": "Anëtarë",
|
||||
"emptyText": "Nuk ka bashkëngjitje në projekt."
|
||||
"emptyText": "Nuk ka bashkëngjitje në projekt.",
|
||||
"searchPlaceholder": "Kërko anëtarë"
|
||||
}
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
"deleteButtonTooltip": "Aus Projekt entfernen",
|
||||
"memberCount": "Mitglied",
|
||||
"membersCountPlural": "Mitglieder",
|
||||
"emptyText": "Es gibt keine Anhänge in diesem Projekt."
|
||||
"emptyText": "Es gibt keine Anhänge in diesem Projekt.",
|
||||
"searchPlaceholder": "Mitglieder suchen"
|
||||
}
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
"deleteButtonTooltip": "Remove from project",
|
||||
"memberCount": "Member",
|
||||
"membersCountPlural": "Members",
|
||||
"emptyText": "There are no attachments in the project."
|
||||
"emptyText": "There are no attachments in the project.",
|
||||
"searchPlaceholder": "Search members"
|
||||
}
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
"deleteButtonTooltip": "Eliminar del proyecto",
|
||||
"memberCount": "Miembro",
|
||||
"membersCountPlural": "Miembros",
|
||||
"emptyText": "No hay archivos adjuntos en el proyecto."
|
||||
"emptyText": "No hay archivos adjuntos en el proyecto.",
|
||||
"searchPlaceholder": "Buscar miembros"
|
||||
}
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
"deleteButtonTooltip": "Remover do projeto",
|
||||
"memberCount": "Membro",
|
||||
"membersCountPlural": "Membros",
|
||||
"emptyText": "Não há anexos no projeto."
|
||||
"emptyText": "Não há anexos no projeto.",
|
||||
"searchPlaceholder": "Pesquisar membros"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Input, // <-- Add this import
|
||||
} from 'antd';
|
||||
|
||||
// Icons
|
||||
@@ -70,26 +71,29 @@ const ProjectViewMembers = () => {
|
||||
field: 'name',
|
||||
order: 'ascend',
|
||||
total: 0,
|
||||
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
size: 'small',
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState(''); // <-- Add search state
|
||||
|
||||
// API Functions
|
||||
const getProjectMembers = async () => {
|
||||
const getProjectMembers = async (search: string = searchQuery) => {
|
||||
if (!projectId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const offset = (pagination.current - 1) * pagination.pageSize;
|
||||
const res = await projectsApiService.getMembers(
|
||||
projectId,
|
||||
pagination.current,
|
||||
pagination.pageSize,
|
||||
pagination.current, // index
|
||||
pagination.pageSize, // size // offset
|
||||
pagination.field,
|
||||
pagination.order,
|
||||
null
|
||||
search
|
||||
);
|
||||
if (res.done) {
|
||||
setMembers(res.body);
|
||||
setPagination(p => ({ ...p, total: res.body.total ?? 0 })); // update total from backend, default to 0
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching members:', error);
|
||||
@@ -123,16 +127,14 @@ const ProjectViewMembers = () => {
|
||||
return Math.floor((completed / total) * 100);
|
||||
};
|
||||
|
||||
const handleTableChange = (pagination: any, filters: any, sorter: any) => {
|
||||
setPagination({
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
field: sorter.field || pagination.field,
|
||||
order: sorter.order || pagination.order,
|
||||
total: pagination.total,
|
||||
pageSizeOptions: pagination.pageSizeOptions,
|
||||
size: pagination.size,
|
||||
});
|
||||
const handleTableChange = (tablePagination: any, filters: any, sorter: any) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: tablePagination.current,
|
||||
pageSize: tablePagination.pageSize,
|
||||
field: sorter.field || prev.field,
|
||||
order: sorter.order || prev.order,
|
||||
}));
|
||||
};
|
||||
|
||||
// Effects
|
||||
@@ -145,6 +147,7 @@ const ProjectViewMembers = () => {
|
||||
pagination.pageSize,
|
||||
pagination.field,
|
||||
pagination.order,
|
||||
// searchQuery, // <-- Do NOT include here, search is triggered manually
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -269,11 +272,25 @@ const ProjectViewMembers = () => {
|
||||
<Card
|
||||
style={{ width: '100%' }}
|
||||
title={
|
||||
<Flex justify="space-between">
|
||||
<Flex justify="space-between" align="center">
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{members?.total} {members?.total !== 1 ? t('membersCountPlural') : t('memberCount')}
|
||||
</Typography.Text>
|
||||
|
||||
<Flex gap={8} align="center">
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onSearch={value => {
|
||||
setPagination(p => ({ ...p, current: 1 })); // Reset to first page
|
||||
void getProjectMembers(value);
|
||||
}}
|
||||
style={{ width: 220 }}
|
||||
enterButton
|
||||
size="middle"
|
||||
/>
|
||||
<Tooltip title={t('refreshButtonTooltip')}>
|
||||
<Button
|
||||
shape="circle"
|
||||
@@ -282,6 +299,7 @@ const ProjectViewMembers = () => {
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
{members?.total === 0 ? (
|
||||
@@ -299,8 +317,12 @@ const ProjectViewMembers = () => {
|
||||
columns={columns}
|
||||
rowKey={record => record.id}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 20,
|
||||
pageSizeOptions: pagination.pageSizeOptions,
|
||||
size: pagination.size,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
onRow={record => ({
|
||||
|
||||
Reference in New Issue
Block a user