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:
shancds
2025-07-23 10:49:00 +05:30
parent aaaac09212
commit 300d4763f5
7 changed files with 106 additions and 86 deletions

View File

@@ -71,7 +71,7 @@ export default class ProjectsController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`)); return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`));
} }
} }
const q = `SELECT create_project($1) AS project`; const q = `SELECT create_project($1) AS project`;
req.body.team_id = req.user?.team_id || null; req.body.team_id = req.user?.team_id || null;
@@ -317,65 +317,58 @@ export default class ProjectsController extends WorklenzControllerBase {
@HandleExceptions() @HandleExceptions()
public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> { public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name"); 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 = ` const q = `
SELECT ROW_TO_JSON(rec) AS members WITH filtered_members AS (
FROM (SELECT COUNT(*) AS total, SELECT project_members.id,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) team_member_id,
FROM (SELECT project_members.id, (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS name,
team_member_id, (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS email,
(SELECT name u.avatar_url,
FROM team_member_info_view (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,
WHERE team_member_info_view.team_member_id = tm.id), (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,
(SELECT email EXISTS(SELECT email FROM email_invitations WHERE team_member_id = project_members.team_member_id AND email_invitations.team_id = $2) AS pending_invitation,
FROM team_member_info_view (SELECT project_access_levels.name FROM project_access_levels WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
WHERE team_member_info_view.team_member_id = tm.id) AS email, (SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
u.avatar_url, FROM project_members
(SELECT COUNT(*) INNER JOIN team_members tm ON project_members.team_member_id = tm.id
FROM tasks LEFT JOIN users u ON tm.user_id = u.id
WHERE archived IS FALSE WHERE project_id = $1
AND project_id = project_members.project_id ${search ? searchFilter : ""}
AND id IN (SELECT task_id )
FROM tasks_assignees SELECT
WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count, (SELECT COUNT(*) FROM filtered_members) AS total,
(SELECT COUNT(*) (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
FROM tasks FROM (
WHERE archived IS FALSE SELECT * FROM filtered_members
AND project_id = project_members.project_id ORDER BY ${sortField} ${sortOrder}
AND id IN (SELECT task_id LIMIT $3 OFFSET $4
FROM tasks_assignees ) t
WHERE tasks_assignees.project_member_id = project_members.id) ) AS data
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
ORDER BY ${sortField} ${sortOrder}
LIMIT $3 OFFSET $4) t) AS data
FROM project_members
WHERE project_id = $1) rec;
`; `;
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; 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.progress = member.all_tasks_count > 0
? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 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() @HandleExceptions()
@@ -779,7 +772,7 @@ export default class ProjectsController extends WorklenzControllerBase {
let groupJoin = ""; let groupJoin = "";
let groupByFields = ""; let groupByFields = "";
let groupOrderBy = ""; let groupOrderBy = "";
switch (groupBy) { switch (groupBy) {
case "client": case "client":
groupField = "COALESCE(projects.client_id::text, 'no-client')"; groupField = "COALESCE(projects.client_id::text, 'no-client')";
@@ -888,13 +881,13 @@ export default class ProjectsController extends WorklenzControllerBase {
ELSE p2.updated_at END) AS updated_at ELSE p2.updated_at END) AS updated_at
FROM projects p2 FROM projects p2
${groupJoin.replace("projects.", "p2.")} ${groupJoin.replace("projects.", "p2.")}
WHERE p2.team_id = $1 WHERE p2.team_id = $1
AND ${groupField.replace("projects.", "p2.")} = ${groupField} AND ${groupField.replace("projects.", "p2.")} = ${groupField}
${categories.replace("projects.", "p2.")} ${categories.replace("projects.", "p2.")}
${statuses.replace("projects.", "p2.")} ${statuses.replace("projects.", "p2.")}
${isArchived.replace("projects.", "p2.")} ${isArchived.replace("projects.", "p2.")}
${isFavorites.replace("projects.", "p2.")} ${isFavorites.replace("projects.", "p2.")}
${filterByMember.replace("projects.", "p2.")} ${filterByMember.replace("projects.", "p2.")}
${searchQuery.replace("projects.", "p2.")} ${searchQuery.replace("projects.", "p2.")}
ORDER BY ${innerSortField} ${sortOrder} ORDER BY ${innerSortField} ${sortOrder}
) project_data ) project_data

View File

@@ -13,5 +13,6 @@
"deleteButtonTooltip": "Hiq nga projekti", "deleteButtonTooltip": "Hiq nga projekti",
"memberCount": "Anëtar", "memberCount": "Anëtar",
"membersCountPlural": "Anëtarë", "membersCountPlural": "Anëtarë",
"emptyText": "Nuk ka bashkëngjitje në projekt." "emptyText": "Nuk ka bashkëngjitje në projekt.",
"searchPlaceholder": "Kërko anëtarë"
} }

View File

@@ -13,5 +13,6 @@
"deleteButtonTooltip": "Aus Projekt entfernen", "deleteButtonTooltip": "Aus Projekt entfernen",
"memberCount": "Mitglied", "memberCount": "Mitglied",
"membersCountPlural": "Mitglieder", "membersCountPlural": "Mitglieder",
"emptyText": "Es gibt keine Anhänge in diesem Projekt." "emptyText": "Es gibt keine Anhänge in diesem Projekt.",
"searchPlaceholder": "Mitglieder suchen"
} }

View File

@@ -13,5 +13,6 @@
"deleteButtonTooltip": "Remove from project", "deleteButtonTooltip": "Remove from project",
"memberCount": "Member", "memberCount": "Member",
"membersCountPlural": "Members", "membersCountPlural": "Members",
"emptyText": "There are no attachments in the project." "emptyText": "There are no attachments in the project.",
"searchPlaceholder": "Search members"
} }

View File

@@ -13,5 +13,6 @@
"deleteButtonTooltip": "Eliminar del proyecto", "deleteButtonTooltip": "Eliminar del proyecto",
"memberCount": "Miembro", "memberCount": "Miembro",
"membersCountPlural": "Miembros", "membersCountPlural": "Miembros",
"emptyText": "No hay archivos adjuntos en el proyecto." "emptyText": "No hay archivos adjuntos en el proyecto.",
"searchPlaceholder": "Buscar miembros"
} }

View File

@@ -13,5 +13,6 @@
"deleteButtonTooltip": "Remover do projeto", "deleteButtonTooltip": "Remover do projeto",
"memberCount": "Membro", "memberCount": "Membro",
"membersCountPlural": "Membros", "membersCountPlural": "Membros",
"emptyText": "Não há anexos no projeto." "emptyText": "Não há anexos no projeto.",
"searchPlaceholder": "Pesquisar membros"
} }

View File

@@ -11,6 +11,7 @@ import {
TableProps, TableProps,
Tooltip, Tooltip,
Typography, Typography,
Input, // <-- Add this import
} from 'antd'; } from 'antd';
// Icons // Icons
@@ -70,26 +71,29 @@ const ProjectViewMembers = () => {
field: 'name', field: 'name',
order: 'ascend', order: 'ascend',
total: 0, total: 0,
pageSizeOptions: ['5', '10', '15', '20', '50', '100'], pageSizeOptions: ['10', '20', '50', '100'],
size: 'small', size: 'small',
}); });
const [searchQuery, setSearchQuery] = useState(''); // <-- Add search state
// API Functions // API Functions
const getProjectMembers = async () => { const getProjectMembers = async (search: string = searchQuery) => {
if (!projectId) return; if (!projectId) return;
setIsLoading(true); setIsLoading(true);
try { try {
const offset = (pagination.current - 1) * pagination.pageSize;
const res = await projectsApiService.getMembers( const res = await projectsApiService.getMembers(
projectId, projectId,
pagination.current, pagination.current, // index
pagination.pageSize, pagination.pageSize, // size // offset
pagination.field, pagination.field,
pagination.order, pagination.order,
null search
); );
if (res.done) { if (res.done) {
setMembers(res.body); setMembers(res.body);
setPagination(p => ({ ...p, total: res.body.total ?? 0 })); // update total from backend, default to 0
} }
} catch (error) { } catch (error) {
logger.error('Error fetching members:', error); logger.error('Error fetching members:', error);
@@ -123,16 +127,14 @@ const ProjectViewMembers = () => {
return Math.floor((completed / total) * 100); return Math.floor((completed / total) * 100);
}; };
const handleTableChange = (pagination: any, filters: any, sorter: any) => { const handleTableChange = (tablePagination: any, filters: any, sorter: any) => {
setPagination({ setPagination(prev => ({
current: pagination.current, ...prev,
pageSize: pagination.pageSize, current: tablePagination.current,
field: sorter.field || pagination.field, pageSize: tablePagination.pageSize,
order: sorter.order || pagination.order, field: sorter.field || prev.field,
total: pagination.total, order: sorter.order || prev.order,
pageSizeOptions: pagination.pageSizeOptions, }));
size: pagination.size,
});
}; };
// Effects // Effects
@@ -145,6 +147,7 @@ const ProjectViewMembers = () => {
pagination.pageSize, pagination.pageSize,
pagination.field, pagination.field,
pagination.order, pagination.order,
// searchQuery, // <-- Do NOT include here, search is triggered manually
]); ]);
useEffect(() => { useEffect(() => {
@@ -269,18 +272,33 @@ const ProjectViewMembers = () => {
<Card <Card
style={{ width: '100%' }} style={{ width: '100%' }}
title={ title={
<Flex justify="space-between"> <Flex justify="space-between" align="center">
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}> <Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{members?.total} {members?.total !== 1 ? t('membersCountPlural') : t('memberCount')} {members?.total} {members?.total !== 1 ? t('membersCountPlural') : t('memberCount')}
</Typography.Text> </Typography.Text>
<Tooltip title={t('refreshButtonTooltip')}> <Flex gap={8} align="center">
<Button <Input.Search
shape="circle" allowClear
icon={<SyncOutlined />} placeholder={t('searchPlaceholder')}
onClick={() => void getProjectMembers()} 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> <Tooltip title={t('refreshButtonTooltip')}>
<Button
shape="circle"
icon={<SyncOutlined />}
onClick={() => void getProjectMembers()}
/>
</Tooltip>
</Flex>
</Flex> </Flex>
} }
> >
@@ -299,8 +317,12 @@ const ProjectViewMembers = () => {
columns={columns} columns={columns}
rowKey={record => record.id} rowKey={record => record.id}
pagination={{ pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true, showSizeChanger: true,
defaultPageSize: 20, pageSizeOptions: pagination.pageSizeOptions,
size: pagination.size,
}} }}
onChange={handleTableChange} onChange={handleTableChange}
onRow={record => ({ onRow={record => ({