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:
@@ -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
|
||||||
|
|||||||
@@ -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ë"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user