feat(projects): implement grouped project retrieval and UI enhancements
- Added a new endpoint for retrieving projects grouped by category, client, or status. - Enhanced the ProjectsController with a method to handle grouped project queries. - Updated the projects API router to include the new grouped endpoint. - Improved the frontend to support displaying grouped projects with pagination and filtering options. - Updated localization files for English, Spanish, and Portuguese to include new grouping options. - Refactored project list components to accommodate the new grouped view and improved UI elements.
This commit is contained in:
@@ -756,4 +756,186 @@ export default class ProjectsController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getGrouped(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
// Use qualified field name for projects to avoid ambiguity
|
||||||
|
const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, ["projects.name"]);
|
||||||
|
const groupBy = req.query.groupBy as string || "category";
|
||||||
|
|
||||||
|
const filterByMember = !req.user?.owner && !req.user?.is_admin ?
|
||||||
|
` AND is_member_of_project(projects.id, '${req.user?.id}', $1) ` : "";
|
||||||
|
|
||||||
|
const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : "";
|
||||||
|
const isArchived = req.query.filter === "2"
|
||||||
|
? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`
|
||||||
|
: ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`;
|
||||||
|
const categories = this.getFilterByCategoryWhereClosure(req.query.categories as string);
|
||||||
|
const statuses = this.getFilterByStatusWhereClosure(req.query.statuses as string);
|
||||||
|
|
||||||
|
// Determine grouping field and join based on groupBy parameter
|
||||||
|
let groupField = "";
|
||||||
|
let groupName = "";
|
||||||
|
let groupColor = "";
|
||||||
|
let groupJoin = "";
|
||||||
|
let groupByFields = "";
|
||||||
|
let groupOrderBy = "";
|
||||||
|
|
||||||
|
switch (groupBy) {
|
||||||
|
case "client":
|
||||||
|
groupField = "COALESCE(projects.client_id::text, 'no-client')";
|
||||||
|
groupName = "COALESCE(clients.name, 'No Client')";
|
||||||
|
groupColor = "'#688'";
|
||||||
|
groupJoin = "LEFT JOIN clients ON projects.client_id = clients.id";
|
||||||
|
groupByFields = "projects.client_id, clients.name";
|
||||||
|
groupOrderBy = "COALESCE(clients.name, 'No Client')";
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
groupField = "COALESCE(projects.status_id::text, 'no-status')";
|
||||||
|
groupName = "COALESCE(sys_project_statuses.name, 'No Status')";
|
||||||
|
groupColor = "COALESCE(sys_project_statuses.color_code, '#888')";
|
||||||
|
groupJoin = "LEFT JOIN sys_project_statuses ON projects.status_id = sys_project_statuses.id";
|
||||||
|
groupByFields = "projects.status_id, sys_project_statuses.name, sys_project_statuses.color_code";
|
||||||
|
groupOrderBy = "COALESCE(sys_project_statuses.name, 'No Status')";
|
||||||
|
break;
|
||||||
|
case "category":
|
||||||
|
default:
|
||||||
|
groupField = "COALESCE(projects.category_id::text, 'uncategorized')";
|
||||||
|
groupName = "COALESCE(project_categories.name, 'Uncategorized')";
|
||||||
|
groupColor = "COALESCE(project_categories.color_code, '#888')";
|
||||||
|
groupJoin = "LEFT JOIN project_categories ON projects.category_id = project_categories.id";
|
||||||
|
groupByFields = "projects.category_id, project_categories.name, project_categories.color_code";
|
||||||
|
groupOrderBy = "COALESCE(project_categories.name, 'Uncategorized')";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure sortField is properly qualified for the inner project query
|
||||||
|
let qualifiedSortField = sortField;
|
||||||
|
if (Array.isArray(sortField)) {
|
||||||
|
qualifiedSortField = sortField[0]; // Take the first field if it's an array
|
||||||
|
}
|
||||||
|
// Replace "projects." with "p2." for the inner query
|
||||||
|
const innerSortField = qualifiedSortField.replace("projects.", "p2.");
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
SELECT ROW_TO_JSON(rec) AS groups
|
||||||
|
FROM (
|
||||||
|
SELECT COUNT(DISTINCT ${groupField}) AS total_groups,
|
||||||
|
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(group_data))), '[]'::JSON)
|
||||||
|
FROM (
|
||||||
|
SELECT ${groupField} AS group_key,
|
||||||
|
${groupName} AS group_name,
|
||||||
|
${groupColor} AS group_color,
|
||||||
|
COUNT(*) AS project_count,
|
||||||
|
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(project_data))), '[]'::JSON)
|
||||||
|
FROM (
|
||||||
|
SELECT p2.id,
|
||||||
|
p2.name,
|
||||||
|
(SELECT sys_project_statuses.name FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status,
|
||||||
|
(SELECT sys_project_statuses.color_code FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_color,
|
||||||
|
(SELECT sys_project_statuses.icon FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_icon,
|
||||||
|
EXISTS(SELECT user_id
|
||||||
|
FROM favorite_projects
|
||||||
|
WHERE user_id = '${req.user?.id}'
|
||||||
|
AND project_id = p2.id) AS favorite,
|
||||||
|
EXISTS(SELECT user_id
|
||||||
|
FROM archived_projects
|
||||||
|
WHERE user_id = '${req.user?.id}'
|
||||||
|
AND project_id = p2.id) AS archived,
|
||||||
|
p2.color_code,
|
||||||
|
p2.start_date,
|
||||||
|
p2.end_date,
|
||||||
|
p2.category_id,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM tasks
|
||||||
|
WHERE archived IS FALSE
|
||||||
|
AND project_id = p2.id) AS all_tasks_count,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM tasks
|
||||||
|
WHERE archived IS FALSE
|
||||||
|
AND project_id = p2.id
|
||||||
|
AND status_id IN (SELECT task_statuses.id
|
||||||
|
FROM task_statuses
|
||||||
|
WHERE task_statuses.project_id = p2.id
|
||||||
|
AND task_statuses.category_id IN
|
||||||
|
(SELECT sys_task_status_categories.id FROM sys_task_status_categories WHERE sys_task_status_categories.is_done IS TRUE))) AS completed_tasks_count,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM project_members
|
||||||
|
WHERE project_members.project_id = p2.id) AS members_count,
|
||||||
|
(SELECT get_project_members(p2.id)) AS names,
|
||||||
|
(SELECT clients.name FROM clients WHERE clients.id = p2.client_id) AS client_name,
|
||||||
|
(SELECT users.name FROM users WHERE users.id = p2.owner_id) AS project_owner,
|
||||||
|
(SELECT project_categories.name FROM project_categories WHERE project_categories.id = p2.category_id) AS category_name,
|
||||||
|
(SELECT project_categories.color_code
|
||||||
|
FROM project_categories
|
||||||
|
WHERE project_categories.id = p2.category_id) AS category_color,
|
||||||
|
((SELECT project_members.team_member_id as team_member_id
|
||||||
|
FROM project_members
|
||||||
|
WHERE project_members.project_id = p2.id
|
||||||
|
AND project_members.project_access_level_id = (SELECT project_access_levels.id FROM project_access_levels WHERE project_access_levels.key = 'PROJECT_MANAGER'))) AS project_manager_team_member_id,
|
||||||
|
(SELECT project_members.default_view
|
||||||
|
FROM project_members
|
||||||
|
WHERE project_members.project_id = p2.id
|
||||||
|
AND project_members.team_member_id = '${req.user?.team_member_id}') AS team_member_default_view,
|
||||||
|
(SELECT CASE
|
||||||
|
WHEN ((SELECT MAX(tasks.updated_at)
|
||||||
|
FROM tasks
|
||||||
|
WHERE tasks.archived IS FALSE
|
||||||
|
AND tasks.project_id = p2.id) >
|
||||||
|
p2.updated_at)
|
||||||
|
THEN (SELECT MAX(tasks.updated_at)
|
||||||
|
FROM tasks
|
||||||
|
WHERE tasks.archived IS FALSE
|
||||||
|
AND tasks.project_id = p2.id)
|
||||||
|
ELSE p2.updated_at END) AS updated_at
|
||||||
|
FROM projects p2
|
||||||
|
${groupJoin.replace("projects.", "p2.")}
|
||||||
|
WHERE p2.team_id = $1
|
||||||
|
AND ${groupField.replace("projects.", "p2.")} = ${groupField}
|
||||||
|
${categories.replace("projects.", "p2.")}
|
||||||
|
${statuses.replace("projects.", "p2.")}
|
||||||
|
${isArchived.replace("projects.", "p2.")}
|
||||||
|
${isFavorites.replace("projects.", "p2.")}
|
||||||
|
${filterByMember.replace("projects.", "p2.")}
|
||||||
|
${searchQuery.replace("projects.", "p2.")}
|
||||||
|
ORDER BY ${innerSortField} ${sortOrder}
|
||||||
|
) project_data
|
||||||
|
) AS projects
|
||||||
|
FROM projects
|
||||||
|
${groupJoin}
|
||||||
|
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
|
||||||
|
GROUP BY ${groupByFields}
|
||||||
|
ORDER BY ${groupOrderBy}
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
) group_data
|
||||||
|
) AS data
|
||||||
|
FROM projects
|
||||||
|
${groupJoin}
|
||||||
|
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
|
||||||
|
) rec;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
|
||||||
|
const [data] = result.rows;
|
||||||
|
|
||||||
|
// Process the grouped data
|
||||||
|
for (const group of data?.groups.data || []) {
|
||||||
|
for (const project of group.projects || []) {
|
||||||
|
project.progress = project.all_tasks_count > 0
|
||||||
|
? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0;
|
||||||
|
|
||||||
|
project.updated_at_string = moment(project.updated_at).fromNow();
|
||||||
|
|
||||||
|
project.names = this.createTagList(project?.names);
|
||||||
|
project.names.map((a: any) => a.color_code = getColor(a.name));
|
||||||
|
|
||||||
|
if (project.project_manager_team_member_id) {
|
||||||
|
project.project_manager = {
|
||||||
|
id: project.project_manager_team_member_id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, data?.groups || { total_groups: 0, data: [] }));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ projectsApiRouter.get("/update-exist-sort-order", safeControllerFunction(Project
|
|||||||
|
|
||||||
projectsApiRouter.post("/", teamOwnerOrAdminValidator, projectsBodyValidator, safeControllerFunction(ProjectsController.create));
|
projectsApiRouter.post("/", teamOwnerOrAdminValidator, projectsBodyValidator, safeControllerFunction(ProjectsController.create));
|
||||||
projectsApiRouter.get("/", safeControllerFunction(ProjectsController.get));
|
projectsApiRouter.get("/", safeControllerFunction(ProjectsController.get));
|
||||||
|
projectsApiRouter.get("/grouped", safeControllerFunction(ProjectsController.getGrouped));
|
||||||
projectsApiRouter.get("/my-task-projects", safeControllerFunction(ProjectsController.getMyProjectsToTasks));
|
projectsApiRouter.get("/my-task-projects", safeControllerFunction(ProjectsController.getMyProjectsToTasks));
|
||||||
projectsApiRouter.get("/my-projects", safeControllerFunction(ProjectsController.getMyProjects));
|
projectsApiRouter.get("/my-projects", safeControllerFunction(ProjectsController.getMyProjects));
|
||||||
projectsApiRouter.get("/all", safeControllerFunction(ProjectsController.getAllProjects));
|
projectsApiRouter.get("/all", safeControllerFunction(ProjectsController.getAllProjects));
|
||||||
|
|||||||
@@ -19,5 +19,13 @@
|
|||||||
"unarchiveConfirm": "Are you sure you want to unarchive this project?",
|
"unarchiveConfirm": "Are you sure you want to unarchive this project?",
|
||||||
"clickToFilter": "Click to filter by",
|
"clickToFilter": "Click to filter by",
|
||||||
"noProjects": "No projects found",
|
"noProjects": "No projects found",
|
||||||
"addToFavourites": "Add to favourites"
|
"addToFavourites": "Add to favourites",
|
||||||
|
"list": "List",
|
||||||
|
"group": "Group",
|
||||||
|
"listView": "List View",
|
||||||
|
"groupView": "Group View",
|
||||||
|
"groupBy": {
|
||||||
|
"category": "Category",
|
||||||
|
"client": "Client"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,13 @@
|
|||||||
"unarchiveConfirm": "¿Estás seguro de que deseas desarchivar este proyecto?",
|
"unarchiveConfirm": "¿Estás seguro de que deseas desarchivar este proyecto?",
|
||||||
"clickToFilter": "Clique para filtrar por",
|
"clickToFilter": "Clique para filtrar por",
|
||||||
"noProjects": "No se encontraron proyectos",
|
"noProjects": "No se encontraron proyectos",
|
||||||
"addToFavourites": "Añadir a favoritos"
|
"addToFavourites": "Añadir a favoritos",
|
||||||
|
"list": "Lista",
|
||||||
|
"group": "Grupo",
|
||||||
|
"listView": "Vista de Lista",
|
||||||
|
"groupView": "Vista de Grupo",
|
||||||
|
"groupBy": {
|
||||||
|
"category": "Categoría",
|
||||||
|
"client": "Cliente"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,13 @@
|
|||||||
"unarchiveConfirm": "Tem certeza de que deseja desarquivar este projeto?",
|
"unarchiveConfirm": "Tem certeza de que deseja desarquivar este projeto?",
|
||||||
"clickToFilter": "Clique para filtrar por",
|
"clickToFilter": "Clique para filtrar por",
|
||||||
"noProjects": "Nenhum projeto encontrado",
|
"noProjects": "Nenhum projeto encontrado",
|
||||||
"addToFavourites": "Adicionar aos favoritos"
|
"addToFavourites": "Adicionar aos favoritos",
|
||||||
|
"list": "Lista",
|
||||||
|
"group": "Grupo",
|
||||||
|
"listView": "Visualização em Lista",
|
||||||
|
"groupView": "Visualização em Grupo",
|
||||||
|
"groupBy": {
|
||||||
|
"category": "Categoria",
|
||||||
|
"client": "Cliente"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
|||||||
import { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types';
|
import { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types';
|
||||||
import { IProjectMembersViewModel } from '@/types/projectMember.types';
|
import { IProjectMembersViewModel } from '@/types/projectMember.types';
|
||||||
import { IProjectManager } from '@/types/project/projectManager.types';
|
import { IProjectManager } from '@/types/project/projectManager.types';
|
||||||
|
import { IGroupedProjectsViewModel } from '@/types/project/groupedProjectsViewModel.types';
|
||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/projects`;
|
const rootUrl = `${API_BASE_URL}/projects`;
|
||||||
|
|
||||||
@@ -32,6 +33,23 @@ export const projectsApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getGroupedProjects: async (
|
||||||
|
index: number,
|
||||||
|
size: number,
|
||||||
|
field: string | null,
|
||||||
|
order: string | null,
|
||||||
|
search: string | null,
|
||||||
|
groupBy: string,
|
||||||
|
filter: number | null = null,
|
||||||
|
statuses: string | null = null,
|
||||||
|
categories: string | null = null
|
||||||
|
): Promise<IServerResponse<IGroupedProjectsViewModel>> => {
|
||||||
|
const s = encodeURIComponent(search || '');
|
||||||
|
const url = `${rootUrl}/grouped${toQueryString({ index, size, field, order, search: s, groupBy, filter, statuses, categories })}`;
|
||||||
|
const response = await apiClient.get<IServerResponse<IGroupedProjectsViewModel>>(`${url}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
getProject: async (id: string): Promise<IServerResponse<IProjectViewModel>> => {
|
getProject: async (id: string): Promise<IServerResponse<IProjectViewModel>> => {
|
||||||
const url = `${rootUrl}/${id}`;
|
const url = `${rootUrl}/${id}`;
|
||||||
const response = await apiClient.get<IServerResponse<IProjectViewModel>>(`${url}`);
|
const response = await apiClient.get<IServerResponse<IProjectViewModel>>(`${url}`);
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
|
||||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
|
||||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
|
||||||
import { ColumnsType } from 'antd/es/table';
|
|
||||||
import { ColumnFilterItem } from 'antd/es/table/interface';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { NavigateFunction } from 'react-router-dom';
|
|
||||||
import Avatars from '../avatars/avatars';
|
|
||||||
import { ActionButtons } from './project-list-table/project-list-actions/project-list-actions';
|
|
||||||
import { CategoryCell } from './project-list-table/project-list-category/project-list-category';
|
|
||||||
import { ProgressListProgress } from './project-list-table/project-list-progress/progress-list-progress';
|
|
||||||
import { ProjectListUpdatedAt } from './project-list-table/project-list-updated-at/project-list-updated';
|
|
||||||
import { ProjectNameCell } from './project-list-table/project-name/project-name-cell';
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
|
||||||
import { ProjectRateCell } from './project-list-table/project-list-favorite/project-rate-cell';
|
|
||||||
|
|
||||||
const createFilters = (items: { id: string; name: string }[]) =>
|
|
||||||
items.map(item => ({ text: item.name, value: item.id })) as ColumnFilterItem[];
|
|
||||||
|
|
||||||
interface ITableColumnsProps {
|
|
||||||
navigate: NavigateFunction;
|
|
||||||
filteredInfo: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TableColumns = ({
|
|
||||||
navigate,
|
|
||||||
filteredInfo,
|
|
||||||
}: ITableColumnsProps): ColumnsType<IProjectViewModel> => {
|
|
||||||
const { t } = useTranslation('all-project-list');
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
|
||||||
|
|
||||||
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
|
|
||||||
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
|
|
||||||
const { filteredCategories, filteredStatuses } = useAppSelector(
|
|
||||||
state => state.projectsReducer
|
|
||||||
);
|
|
||||||
const columns = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
dataIndex: 'favorite',
|
|
||||||
key: 'favorite',
|
|
||||||
render: (text: string, record: IProjectViewModel) => (
|
|
||||||
<ProjectRateCell key={record.id} t={t} record={record} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('name'),
|
|
||||||
dataIndex: 'name',
|
|
||||||
key: 'name',
|
|
||||||
sorter: true,
|
|
||||||
showSorterTooltip: false,
|
|
||||||
defaultSortOrder: 'ascend',
|
|
||||||
render: (text: string, record: IProjectViewModel) => (
|
|
||||||
<ProjectNameCell navigate={navigate} key={record.id} t={t} record={record} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('client'),
|
|
||||||
dataIndex: 'client_name',
|
|
||||||
key: 'client_name',
|
|
||||||
sorter: true,
|
|
||||||
showSorterTooltip: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('category'),
|
|
||||||
dataIndex: 'category',
|
|
||||||
key: 'category_id',
|
|
||||||
filters: createFilters(
|
|
||||||
projectCategories.map(category => ({ id: category.id || '', name: category.name || '' }))
|
|
||||||
),
|
|
||||||
filteredValue: filteredInfo.category_id || filteredCategories || [],
|
|
||||||
filterMultiple: true,
|
|
||||||
render: (text: string, record: IProjectViewModel) => (
|
|
||||||
<CategoryCell key={record.id} t={t} record={record} />
|
|
||||||
),
|
|
||||||
sorter: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('status'),
|
|
||||||
dataIndex: 'status',
|
|
||||||
key: 'status_id',
|
|
||||||
filters: createFilters(
|
|
||||||
projectStatuses.map(status => ({ id: status.id || '', name: status.name || '' }))
|
|
||||||
),
|
|
||||||
filteredValue: filteredInfo.status_id || [],
|
|
||||||
filterMultiple: true,
|
|
||||||
sorter: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('tasksProgress'),
|
|
||||||
dataIndex: 'tasksProgress',
|
|
||||||
key: 'tasksProgress',
|
|
||||||
render: (_: string, record: IProjectViewModel) => <ProgressListProgress record={record} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('updated_at'),
|
|
||||||
dataIndex: 'updated_at',
|
|
||||||
key: 'updated_at',
|
|
||||||
sorter: true,
|
|
||||||
showSorterTooltip: false,
|
|
||||||
render: (_: string, record: IProjectViewModel) => <ProjectListUpdatedAt record={record} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('members'),
|
|
||||||
dataIndex: 'names',
|
|
||||||
key: 'members',
|
|
||||||
render: (members: InlineMember[]) => <Avatars members={members} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
key: 'button',
|
|
||||||
dataIndex: '',
|
|
||||||
render: (record: IProjectViewModel) => (
|
|
||||||
<ActionButtons
|
|
||||||
t={t}
|
|
||||||
record={record}
|
|
||||||
dispatch={dispatch}
|
|
||||||
isOwnerOrAdmin={isOwnerOrAdmin}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t, projectCategories, projectStatuses, filteredInfo, filteredCategories, filteredStatuses]
|
|
||||||
);
|
|
||||||
return columns as ColumnsType<IProjectViewModel>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TableColumns;
|
|
||||||
@@ -1,7 +1,50 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Card, Col, Empty, Row, Skeleton, Tag, Typography, Progress, Tooltip } from 'antd';
|
import {
|
||||||
import { ClockCircleOutlined, TeamOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
Card,
|
||||||
|
Col,
|
||||||
|
Empty,
|
||||||
|
Row,
|
||||||
|
Skeleton,
|
||||||
|
Typography,
|
||||||
|
Progress,
|
||||||
|
Tooltip,
|
||||||
|
Badge,
|
||||||
|
Space,
|
||||||
|
Avatar,
|
||||||
|
theme,
|
||||||
|
Divider
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
ClockCircleOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ProjectOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
MoreOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
import { ProjectGroupListProps } from '@/types/project/project.types';
|
import { ProjectGroupListProps } from '@/types/project/project.types';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||||
|
import {
|
||||||
|
fetchProjectData,
|
||||||
|
setProjectId,
|
||||||
|
toggleProjectDrawer
|
||||||
|
} from '@/features/project/project-drawer.slice';
|
||||||
|
import {
|
||||||
|
toggleArchiveProject,
|
||||||
|
toggleArchiveProjectForAll
|
||||||
|
} from '@/features/projects/projectsSlice';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
|
import {
|
||||||
|
evt_projects_settings_click,
|
||||||
|
evt_projects_archive,
|
||||||
|
evt_projects_archive_all
|
||||||
|
} from '@/shared/worklenz-analytics-events';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@@ -12,89 +55,505 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
loading,
|
loading,
|
||||||
t
|
t
|
||||||
}) => {
|
}) => {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||||
|
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||||
|
|
||||||
|
// Theme-aware color utilities
|
||||||
|
const getThemeAwareColor = (lightColor: string, darkColor: string) => {
|
||||||
|
return themeWiseColor(lightColor, darkColor, themeMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enhanced color processing for better contrast
|
||||||
|
const processColor = (color: string | undefined, fallback?: string) => {
|
||||||
|
if (!color) return fallback || token.colorPrimary;
|
||||||
|
|
||||||
|
if (color.startsWith('#')) {
|
||||||
|
if (themeMode === 'dark') {
|
||||||
|
const hex = color.replace('#', '');
|
||||||
|
const r = parseInt(hex.substr(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substr(2, 2), 16);
|
||||||
|
const b = parseInt(hex.substr(4, 2), 16);
|
||||||
|
|
||||||
|
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
|
|
||||||
|
if (brightness < 100) {
|
||||||
|
const factor = 1.5;
|
||||||
|
const newR = Math.min(255, Math.floor(r * factor));
|
||||||
|
const newG = Math.min(255, Math.floor(g * factor));
|
||||||
|
const newB = Math.min(255, Math.floor(b * factor));
|
||||||
|
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const hex = color.replace('#', '');
|
||||||
|
const r = parseInt(hex.substr(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substr(2, 2), 16);
|
||||||
|
const b = parseInt(hex.substr(4, 2), 16);
|
||||||
|
|
||||||
|
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
|
|
||||||
|
if (brightness > 200) {
|
||||||
|
const factor = 0.7;
|
||||||
|
const newR = Math.floor(r * factor);
|
||||||
|
const newG = Math.floor(g * factor);
|
||||||
|
const newB = Math.floor(b * factor);
|
||||||
|
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return color;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Action handlers
|
||||||
|
const handleSettingsClick = (e: React.MouseEvent, projectId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
trackMixpanelEvent(evt_projects_settings_click);
|
||||||
|
dispatch(setProjectId(projectId));
|
||||||
|
dispatch(fetchProjectData(projectId));
|
||||||
|
dispatch(toggleProjectDrawer());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchiveClick = async (e: React.MouseEvent, projectId: string, isArchived: boolean) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
if (isOwnerOrAdmin) {
|
||||||
|
trackMixpanelEvent(evt_projects_archive_all);
|
||||||
|
await dispatch(toggleArchiveProjectForAll(projectId));
|
||||||
|
} else {
|
||||||
|
trackMixpanelEvent(evt_projects_archive);
|
||||||
|
await dispatch(toggleArchiveProject(projectId));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to archive project:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoized styles for better performance
|
||||||
|
const styles = useMemo(() => ({
|
||||||
|
container: {
|
||||||
|
padding: '0',
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
groupSection: {
|
||||||
|
marginBottom: '24px',
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
groupHeader: {
|
||||||
|
background: getThemeAwareColor(
|
||||||
|
`linear-gradient(135deg, ${token.colorFillAlter} 0%, ${token.colorFillTertiary} 100%)`,
|
||||||
|
`linear-gradient(135deg, ${token.colorFillQuaternary} 0%, ${token.colorFillSecondary} 100%)`
|
||||||
|
),
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
padding: '12px 16px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||||
|
boxShadow: getThemeAwareColor(
|
||||||
|
'0 1px 4px rgba(0, 0, 0, 0.06)',
|
||||||
|
'0 1px 4px rgba(0, 0, 0, 0.15)'
|
||||||
|
),
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
},
|
||||||
|
groupTitle: {
|
||||||
|
margin: 0,
|
||||||
|
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
},
|
||||||
|
groupMeta: {
|
||||||
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||||
|
fontSize: '12px',
|
||||||
|
marginTop: '2px',
|
||||||
|
},
|
||||||
|
projectCard: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||||
|
boxShadow: getThemeAwareColor(
|
||||||
|
'0 1px 4px rgba(0, 0, 0, 0.04)',
|
||||||
|
'0 1px 4px rgba(0, 0, 0, 0.12)'
|
||||||
|
),
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: getThemeAwareColor(token.colorBgContainer, token.colorBgElevated),
|
||||||
|
},
|
||||||
|
projectCardHover: {
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: getThemeAwareColor(
|
||||||
|
'0 4px 12px rgba(0, 0, 0, 0.08)',
|
||||||
|
'0 4px 12px rgba(0, 0, 0, 0.20)'
|
||||||
|
),
|
||||||
|
borderColor: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||||
|
},
|
||||||
|
statusBar: {
|
||||||
|
height: '3px',
|
||||||
|
background: 'linear-gradient(90deg, transparent 0%, currentColor 100%)',
|
||||||
|
borderRadius: '0 0 2px 2px',
|
||||||
|
},
|
||||||
|
projectContent: {
|
||||||
|
padding: '12px',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
minHeight: '200px', // Ensure minimum height for consistent card sizes
|
||||||
|
},
|
||||||
|
projectTitle: {
|
||||||
|
margin: '0 0 6px 0',
|
||||||
|
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
},
|
||||||
|
clientName: {
|
||||||
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||||
|
fontSize: '12px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
},
|
||||||
|
progressSection: {
|
||||||
|
marginBottom: '10px',
|
||||||
|
// Remove flex: 1 to prevent it from taking all available space
|
||||||
|
},
|
||||||
|
progressLabel: {
|
||||||
|
fontSize: '10px',
|
||||||
|
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
|
||||||
|
marginBottom: '4px',
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'uppercase' as const,
|
||||||
|
letterSpacing: '0.3px',
|
||||||
|
},
|
||||||
|
metaGrid: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||||
|
gap: '8px',
|
||||||
|
marginTop: 'auto', // This pushes the meta section to the bottom
|
||||||
|
paddingTop: '10px',
|
||||||
|
borderTop: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||||
|
flexShrink: 0, // Prevent the meta section from shrinking
|
||||||
|
},
|
||||||
|
metaItem: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row' as const,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: token.borderRadiusSM,
|
||||||
|
background: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
},
|
||||||
|
metaContent: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
gap: '1px',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
metaIcon: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||||
|
},
|
||||||
|
metaValue: {
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
||||||
|
lineHeight: 1,
|
||||||
|
},
|
||||||
|
metaLabel: {
|
||||||
|
fontSize: '9px',
|
||||||
|
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
|
||||||
|
lineHeight: 1,
|
||||||
|
textTransform: 'uppercase' as const,
|
||||||
|
letterSpacing: '0.2px',
|
||||||
|
},
|
||||||
|
actionButtons: {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
top: '8px',
|
||||||
|
right: '8px',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '4px',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.2s ease',
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: 'none',
|
||||||
|
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
|
||||||
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '12px',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
'&:hover': {
|
||||||
|
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||||
|
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
||||||
|
transform: 'scale(1.1)',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
padding: '60px 20px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
background: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
|
||||||
|
borderRadius: token.borderRadiusLG,
|
||||||
|
border: `2px dashed ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
padding: '40px 20px',
|
||||||
|
}
|
||||||
|
}), [token, themeMode, getThemeAwareColor]);
|
||||||
|
|
||||||
|
// Early return for loading state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Skeleton active />;
|
return (
|
||||||
|
<div style={styles.loadingContainer}>
|
||||||
|
<Skeleton active paragraph={{ rows: 6 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Early return for empty state
|
||||||
if (groups.length === 0) {
|
if (groups.length === 0) {
|
||||||
return <Empty description={t('noProjects')} />;
|
return (
|
||||||
|
<div style={styles.emptyState}>
|
||||||
|
<Empty
|
||||||
|
image={<ProjectOutlined style={{ fontSize: '48px', color: token.colorTextTertiary }} />}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<Text style={{ fontSize: '16px', color: token.colorTextSecondary }}>
|
||||||
|
{t('noProjects')}
|
||||||
|
</Text>
|
||||||
|
<br />
|
||||||
|
<Text style={{ fontSize: '14px', color: token.colorTextTertiary }}>
|
||||||
|
Create your first project to get started
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderProjectCard = (project: any) => {
|
||||||
|
const projectColor = processColor(project.color_code, token.colorPrimary);
|
||||||
|
const statusColor = processColor(project.status_color, token.colorPrimary);
|
||||||
|
const progress = project.progress || 0;
|
||||||
|
const completedTasks = project.completed_tasks_count || 0;
|
||||||
|
const totalTasks = project.all_tasks_count || 0;
|
||||||
|
const membersCount = project.members_count || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col key={project.id} xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||||
|
<Card
|
||||||
|
style={{ ...styles.projectCard, position: 'relative' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
Object.assign(e.currentTarget.style, styles.projectCardHover);
|
||||||
|
const actionButtons = e.currentTarget.querySelector('.action-buttons') as HTMLElement;
|
||||||
|
if (actionButtons) {
|
||||||
|
actionButtons.style.opacity = '1';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
Object.assign(e.currentTarget.style, styles.projectCard);
|
||||||
|
const actionButtons = e.currentTarget.querySelector('.action-buttons') as HTMLElement;
|
||||||
|
if (actionButtons) {
|
||||||
|
actionButtons.style.opacity = '0';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => onProjectSelect(project.id || '')}
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="action-buttons" style={styles.actionButtons}>
|
||||||
|
<Tooltip title={t('setting')}>
|
||||||
|
<button
|
||||||
|
style={styles.actionButton}
|
||||||
|
onClick={(e) => handleSettingsClick(e, project.id)}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
Object.assign(e.currentTarget.style, {
|
||||||
|
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||||
|
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
||||||
|
transform: 'scale(1.1)',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
Object.assign(e.currentTarget.style, {
|
||||||
|
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
|
||||||
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||||
|
transform: 'scale(1)',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SettingOutlined />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={project.archived ? t('unarchive') : t('archive')}>
|
||||||
|
<button
|
||||||
|
style={styles.actionButton}
|
||||||
|
onClick={(e) => handleArchiveClick(e, project.id, project.archived)}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
Object.assign(e.currentTarget.style, {
|
||||||
|
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||||
|
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
||||||
|
transform: 'scale(1.1)',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
Object.assign(e.currentTarget.style, {
|
||||||
|
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
|
||||||
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||||
|
transform: 'scale(1)',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InboxOutlined />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{/* Project color indicator bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.statusBar,
|
||||||
|
color: projectColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={styles.projectContent}>
|
||||||
|
{/* Project title */}
|
||||||
|
<Title level={5} ellipsis={{ rows: 2, tooltip: project.name }} style={styles.projectTitle}>
|
||||||
|
{project.name}
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
{/* Client name */}
|
||||||
|
{project.client_name && (
|
||||||
|
<div style={styles.clientName}>
|
||||||
|
<UserOutlined />
|
||||||
|
<Text ellipsis style={{ color: 'inherit' }}>
|
||||||
|
{project.client_name}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress section */}
|
||||||
|
<div style={styles.progressSection}>
|
||||||
|
<div style={styles.progressLabel}>
|
||||||
|
Progress
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={progress}
|
||||||
|
size="small"
|
||||||
|
strokeColor={{
|
||||||
|
'0%': projectColor,
|
||||||
|
'100%': statusColor,
|
||||||
|
}}
|
||||||
|
trailColor={getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)}
|
||||||
|
strokeWidth={4}
|
||||||
|
showInfo={false}
|
||||||
|
/>
|
||||||
|
<Text style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||||
|
marginTop: '2px',
|
||||||
|
display: 'block'
|
||||||
|
}}>
|
||||||
|
{progress}%
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta information grid */}
|
||||||
|
<div style={styles.metaGrid}>
|
||||||
|
<Tooltip title="Tasks completed">
|
||||||
|
<div style={styles.metaItem}>
|
||||||
|
<CheckCircleOutlined style={styles.metaIcon} />
|
||||||
|
<div style={styles.metaContent}>
|
||||||
|
<span style={styles.metaValue}>{completedTasks}/{totalTasks}</span>
|
||||||
|
<span style={styles.metaLabel}>Tasks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Team members">
|
||||||
|
<div style={styles.metaItem}>
|
||||||
|
<TeamOutlined style={styles.metaIcon} />
|
||||||
|
<div style={styles.metaContent}>
|
||||||
|
<span style={styles.metaValue}>{membersCount}</span>
|
||||||
|
<span style={styles.metaLabel}>Members</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="project-group-container">
|
<div style={styles.container}>
|
||||||
{groups.map(group => (
|
{groups.map((group, groupIndex) => (
|
||||||
<div key={group.groupKey} className="project-group">
|
<div key={group.groupKey} style={styles.groupSection}>
|
||||||
<div className="project-group-header">
|
{/* Enhanced group header */}
|
||||||
{group.groupColor && (
|
<div style={styles.groupHeader}>
|
||||||
<span
|
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
className="group-color-indicator"
|
<Space align="center">
|
||||||
style={{ backgroundColor: group.groupColor }}
|
{group.groupColor && (
|
||||||
/>
|
<div style={{
|
||||||
)}
|
width: '16px',
|
||||||
<Title level={4} className="project-group-title">
|
height: '16px',
|
||||||
{group.groupName}
|
borderRadius: '50%',
|
||||||
</Title>
|
backgroundColor: processColor(group.groupColor),
|
||||||
</div>
|
flexShrink: 0,
|
||||||
<Row gutter={[16, 16]}>
|
border: `2px solid ${getThemeAwareColor('rgba(255,255,255,0.8)', 'rgba(0,0,0,0.3)')}`
|
||||||
{group.projects.map(project => (
|
}} />
|
||||||
<Col key={project.id} xs={24} sm={12} md={8} lg={6}>
|
)}
|
||||||
<Card
|
<div>
|
||||||
hoverable
|
<Title level={4} style={styles.groupTitle}>
|
||||||
onClick={() => onProjectSelect(project.id)}
|
{group.groupName}
|
||||||
className="project-card"
|
</Title>
|
||||||
cover={
|
<div style={styles.groupMeta}>
|
||||||
project.status_color && (
|
{group.projects.length} {group.projects.length === 1 ? 'project' : 'projects'}
|
||||||
<div
|
|
||||||
className="project-status-bar"
|
|
||||||
style={{ backgroundColor: project.status_color }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="project-card-content">
|
|
||||||
<Title level={5} ellipsis={{ rows: 2 }} className="project-title">
|
|
||||||
{project.name}
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
{project.client_name && (
|
|
||||||
<Text type="secondary" className="project-client">
|
|
||||||
{project.client_name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Progress
|
|
||||||
percent={project.progress}
|
|
||||||
size="small"
|
|
||||||
status="active"
|
|
||||||
className="project-progress"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="project-meta">
|
|
||||||
<Tooltip title="Tasks">
|
|
||||||
<span>
|
|
||||||
<CheckCircleOutlined /> {project.completed_tasks_count || 0}/{project.all_tasks_count || 0}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip title="Members">
|
|
||||||
<span>
|
|
||||||
<TeamOutlined /> {project.members_count || 0}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{project.updated_at_string && (
|
|
||||||
<Tooltip title="Last updated">
|
|
||||||
<span>
|
|
||||||
<ClockCircleOutlined /> {project.updated_at_string}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</Col>
|
</Space>
|
||||||
))}
|
|
||||||
|
<Badge
|
||||||
|
count={group.projects.length}
|
||||||
|
style={{
|
||||||
|
backgroundColor: processColor(group.groupColor, token.colorPrimary),
|
||||||
|
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px',
|
||||||
|
minWidth: '24px',
|
||||||
|
height: '24px',
|
||||||
|
lineHeight: '22px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: `2px solid ${getThemeAwareColor(token.colorBgContainer, token.colorBgElevated)}`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projects grid */}
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{group.projects.map(renderProjectCard)}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{/* Add spacing between groups except for the last one */}
|
||||||
|
{groupIndex < groups.length - 1 && (
|
||||||
|
<Divider style={{
|
||||||
|
margin: '32px 0 0 0',
|
||||||
|
borderColor: getThemeAwareColor(token.colorBorderSecondary, token.colorBorder),
|
||||||
|
opacity: 0.5
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
|||||||
import { IProjectCategory } from '@/types/project/projectCategory.types';
|
import { IProjectCategory } from '@/types/project/projectCategory.types';
|
||||||
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
|
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
|
||||||
import { IProjectManager } from '@/types/project/projectManager.types';
|
import { IProjectManager } from '@/types/project/projectManager.types';
|
||||||
|
import { IGroupedProjectsViewModel } from '@/types/project/groupedProjectsViewModel.types';
|
||||||
|
|
||||||
interface ProjectState {
|
interface ProjectState {
|
||||||
projects: {
|
projects: {
|
||||||
data: IProjectViewModel[];
|
data: IProjectViewModel[];
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
groupedProjects: {
|
||||||
|
data: IGroupedProjectsViewModel | null;
|
||||||
|
loading: boolean;
|
||||||
|
};
|
||||||
categories: IProjectCategory[];
|
categories: IProjectCategory[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
creatingProject: boolean;
|
creatingProject: boolean;
|
||||||
@@ -29,6 +34,17 @@ interface ProjectState {
|
|||||||
statuses: string | null;
|
statuses: string | null;
|
||||||
categories: string | null;
|
categories: string | null;
|
||||||
};
|
};
|
||||||
|
groupedRequestParams: {
|
||||||
|
index: number;
|
||||||
|
size: number;
|
||||||
|
field: string;
|
||||||
|
order: string;
|
||||||
|
search: string;
|
||||||
|
groupBy: string;
|
||||||
|
filter: number;
|
||||||
|
statuses: string | null;
|
||||||
|
categories: string | null;
|
||||||
|
};
|
||||||
projectManagers: IProjectManager[];
|
projectManagers: IProjectManager[];
|
||||||
projectManagersLoading: boolean;
|
projectManagersLoading: boolean;
|
||||||
}
|
}
|
||||||
@@ -38,6 +54,10 @@ const initialState: ProjectState = {
|
|||||||
data: [],
|
data: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
},
|
},
|
||||||
|
groupedProjects: {
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
categories: [],
|
categories: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
creatingProject: false,
|
creatingProject: false,
|
||||||
@@ -56,6 +76,17 @@ const initialState: ProjectState = {
|
|||||||
statuses: null,
|
statuses: null,
|
||||||
categories: null,
|
categories: null,
|
||||||
},
|
},
|
||||||
|
groupedRequestParams: {
|
||||||
|
index: 1,
|
||||||
|
size: DEFAULT_PAGE_SIZE,
|
||||||
|
field: 'name',
|
||||||
|
order: 'ascend',
|
||||||
|
search: '',
|
||||||
|
groupBy: '',
|
||||||
|
filter: 0,
|
||||||
|
statuses: null,
|
||||||
|
categories: null,
|
||||||
|
},
|
||||||
projectManagers: [],
|
projectManagers: [],
|
||||||
projectManagersLoading: false,
|
projectManagersLoading: false,
|
||||||
};
|
};
|
||||||
@@ -98,6 +129,46 @@ export const fetchProjects = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Create async thunk for fetching grouped projects
|
||||||
|
export const fetchGroupedProjects = createAsyncThunk(
|
||||||
|
'projects/fetchGroupedProjects',
|
||||||
|
async (
|
||||||
|
params: {
|
||||||
|
index: number;
|
||||||
|
size: number;
|
||||||
|
field: string;
|
||||||
|
order: string;
|
||||||
|
search: string;
|
||||||
|
groupBy: string;
|
||||||
|
filter: number;
|
||||||
|
statuses: string | null;
|
||||||
|
categories: string | null;
|
||||||
|
},
|
||||||
|
{ rejectWithValue }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const groupedProjectsResponse = await projectsApiService.getGroupedProjects(
|
||||||
|
params.index,
|
||||||
|
params.size,
|
||||||
|
params.field,
|
||||||
|
params.order,
|
||||||
|
params.search,
|
||||||
|
params.groupBy,
|
||||||
|
params.filter,
|
||||||
|
params.statuses,
|
||||||
|
params.categories
|
||||||
|
);
|
||||||
|
return groupedProjectsResponse.body;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fetch Grouped Projects', error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
return rejectWithValue('Failed to fetch grouped projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const toggleFavoriteProject = createAsyncThunk(
|
export const toggleFavoriteProject = createAsyncThunk(
|
||||||
'projects/toggleFavoriteProject',
|
'projects/toggleFavoriteProject',
|
||||||
async (id: string, { rejectWithValue }) => {
|
async (id: string, { rejectWithValue }) => {
|
||||||
@@ -131,7 +202,7 @@ export const createProject = createAsyncThunk(
|
|||||||
export const updateProject = createAsyncThunk(
|
export const updateProject = createAsyncThunk(
|
||||||
'projects/updateProject',
|
'projects/updateProject',
|
||||||
async ({ id, project }: { id: string; project: IProjectViewModel }, { rejectWithValue }) => {
|
async ({ id, project }: { id: string; project: IProjectViewModel }, { rejectWithValue }) => {
|
||||||
const response = await projectsApiService.updateProject(id, project);
|
const response = await projectsApiService.updateProject({ id, ...project });
|
||||||
return response.body;
|
return response.body;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -196,6 +267,12 @@ const projectSlice = createSlice({
|
|||||||
...action.payload,
|
...action.payload,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
setGroupedRequestParams: (state, action: PayloadAction<Partial<ProjectState['groupedRequestParams']>>) => {
|
||||||
|
state.groupedRequestParams = {
|
||||||
|
...state.groupedRequestParams,
|
||||||
|
...action.payload,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder
|
builder
|
||||||
@@ -213,6 +290,16 @@ const projectSlice = createSlice({
|
|||||||
.addCase(fetchProjects.rejected, state => {
|
.addCase(fetchProjects.rejected, state => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
})
|
})
|
||||||
|
.addCase(fetchGroupedProjects.pending, state => {
|
||||||
|
state.groupedProjects.loading = true;
|
||||||
|
})
|
||||||
|
.addCase(fetchGroupedProjects.fulfilled, (state, action) => {
|
||||||
|
state.groupedProjects.loading = false;
|
||||||
|
state.groupedProjects.data = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(fetchGroupedProjects.rejected, state => {
|
||||||
|
state.groupedProjects.loading = false;
|
||||||
|
})
|
||||||
.addCase(createProject.pending, state => {
|
.addCase(createProject.pending, state => {
|
||||||
state.creatingProject = true;
|
state.creatingProject = true;
|
||||||
})
|
})
|
||||||
@@ -248,5 +335,6 @@ export const {
|
|||||||
setFilteredCategories,
|
setFilteredCategories,
|
||||||
setFilteredStatuses,
|
setFilteredStatuses,
|
||||||
setRequestParams,
|
setRequestParams,
|
||||||
|
setGroupedRequestParams,
|
||||||
} = projectSlice.actions;
|
} = projectSlice.actions;
|
||||||
export default projectSlice.reducer;
|
export default projectSlice.reducer;
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ProjectViewType, ProjectGroupBy } from '@/types/project/project.types';
|
import { ProjectViewType, ProjectGroupBy } from '@/types/project/project.types';
|
||||||
import { setViewMode, setGroupBy } from '@features/project/project-view-slice';
|
import { setViewMode, setGroupBy } from '@features/project/project-view-slice';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Empty,
|
Empty,
|
||||||
Flex,
|
Flex,
|
||||||
Input,
|
Input,
|
||||||
|
Pagination,
|
||||||
Segmented,
|
Segmented,
|
||||||
Select,
|
Select,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
@@ -27,7 +29,16 @@ import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
|||||||
|
|
||||||
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
||||||
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
||||||
import TableColumns from '@/components/project-list/TableColumns';
|
import { ColumnsType } from 'antd/es/table';
|
||||||
|
import { ColumnFilterItem } from 'antd/es/table/interface';
|
||||||
|
import Avatars from '@/components/avatars/avatars';
|
||||||
|
import { ActionButtons } from '@/components/project-list/project-list-table/project-list-actions/project-list-actions';
|
||||||
|
import { CategoryCell } from '@/components/project-list/project-list-table/project-list-category/project-list-category';
|
||||||
|
import { ProgressListProgress } from '@/components/project-list/project-list-table/project-list-progress/progress-list-progress';
|
||||||
|
import { ProjectListUpdatedAt } from '@/components/project-list/project-list-table/project-list-updated-at/project-list-updated';
|
||||||
|
import { ProjectNameCell } from '@/components/project-list/project-list-table/project-name/project-name-cell';
|
||||||
|
import { ProjectRateCell } from '@/components/project-list/project-list-table/project-list-favorite/project-rate-cell';
|
||||||
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
|
|
||||||
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
|
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
|
||||||
|
|
||||||
@@ -50,6 +61,8 @@ import {
|
|||||||
setFilteredCategories,
|
setFilteredCategories,
|
||||||
setFilteredStatuses,
|
setFilteredStatuses,
|
||||||
setRequestParams,
|
setRequestParams,
|
||||||
|
setGroupedRequestParams,
|
||||||
|
fetchGroupedProjects,
|
||||||
} from '@/features/projects/projectsSlice';
|
} from '@/features/projects/projectsSlice';
|
||||||
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
|
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
|
||||||
import { fetchProjectCategories } from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
|
import { fetchProjectCategories } from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
|
||||||
@@ -66,6 +79,9 @@ import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
|||||||
import ProjectGroupList from '@/components/project-list/project-group/project-group-list';
|
import ProjectGroupList from '@/components/project-list/project-group/project-group-list';
|
||||||
import { groupProjects } from '@/utils/project-group';
|
import { groupProjects } from '@/utils/project-group';
|
||||||
|
|
||||||
|
const createFilters = (items: { id: string; name: string }[]) =>
|
||||||
|
items.map(item => ({ text: item.name, value: item.id })) as ColumnFilterItem[];
|
||||||
|
|
||||||
const ProjectList: React.FC = () => {
|
const ProjectList: React.FC = () => {
|
||||||
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
|
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -79,10 +95,13 @@ const ProjectList: React.FC = () => {
|
|||||||
|
|
||||||
// Get view state from Redux
|
// Get view state from Redux
|
||||||
const { mode: viewMode, groupBy } = useAppSelector((state) => state.projectViewReducer);
|
const { mode: viewMode, groupBy } = useAppSelector((state) => state.projectViewReducer);
|
||||||
const { requestParams } = useAppSelector(state => state.projectsReducer);
|
const { requestParams, groupedRequestParams, groupedProjects } = useAppSelector(state => state.projectsReducer);
|
||||||
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
|
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
|
||||||
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
|
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
|
||||||
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
|
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
|
||||||
|
const { filteredCategories, filteredStatuses } = useAppSelector(
|
||||||
|
state => state.projectsReducer
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: projectsData,
|
data: projectsData,
|
||||||
@@ -155,6 +174,22 @@ const ProjectList: React.FC = () => {
|
|||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Memoize category filters to prevent unnecessary recalculations
|
||||||
|
const categoryFilters = useMemo(() =>
|
||||||
|
createFilters(
|
||||||
|
projectCategories.map(category => ({ id: category.id || '', name: category.name || '' }))
|
||||||
|
),
|
||||||
|
[projectCategories]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize status filters to prevent unnecessary recalculations
|
||||||
|
const statusFilters = useMemo(() =>
|
||||||
|
createFilters(
|
||||||
|
projectStatuses.map(status => ({ id: status.id || '', name: status.name || '' }))
|
||||||
|
),
|
||||||
|
[projectStatuses]
|
||||||
|
);
|
||||||
|
|
||||||
const paginationConfig = useMemo(
|
const paginationConfig = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
current: requestParams.index,
|
current: requestParams.index,
|
||||||
@@ -168,6 +203,60 @@ const ProjectList: React.FC = () => {
|
|||||||
[requestParams.index, requestParams.size, projectsData?.body?.total]
|
[requestParams.index, requestParams.size, projectsData?.body?.total]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const groupedPaginationConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
current: groupedRequestParams.index,
|
||||||
|
pageSize: groupedRequestParams.size,
|
||||||
|
showSizeChanger: true,
|
||||||
|
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||||
|
size: 'small' as const,
|
||||||
|
total: groupedProjects.data?.total_groups || 0,
|
||||||
|
}),
|
||||||
|
[groupedRequestParams.index, groupedRequestParams.size, groupedProjects.data?.total_groups]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize the project count calculation for the header
|
||||||
|
const projectCount = useMemo(() => {
|
||||||
|
if (viewMode === ProjectViewType.LIST) {
|
||||||
|
return projectsData?.body?.total || 0;
|
||||||
|
} else {
|
||||||
|
return groupedProjects.data?.data?.reduce((total, group) => total + group.project_count, 0) || 0;
|
||||||
|
}
|
||||||
|
}, [viewMode, projectsData?.body?.total, groupedProjects.data?.data]);
|
||||||
|
|
||||||
|
// Memoize the grouped projects data transformation
|
||||||
|
const transformedGroupedProjects = useMemo(() => {
|
||||||
|
return groupedProjects.data?.data?.map(group => ({
|
||||||
|
groupKey: group.group_key,
|
||||||
|
groupName: group.group_name,
|
||||||
|
groupColor: group.group_color,
|
||||||
|
projects: group.projects,
|
||||||
|
count: group.project_count,
|
||||||
|
totalProgress: 0,
|
||||||
|
totalTasks: 0
|
||||||
|
})) || [];
|
||||||
|
}, [groupedProjects.data?.data]);
|
||||||
|
|
||||||
|
// Memoize the table data source
|
||||||
|
const tableDataSource = useMemo(() =>
|
||||||
|
projectsData?.body?.data || [],
|
||||||
|
[projectsData?.body?.data]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize the empty text component
|
||||||
|
const emptyText = useMemo(() =>
|
||||||
|
<Empty description={t('noProjects')} />,
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize the pagination show total function
|
||||||
|
const paginationShowTotal = useMemo(() =>
|
||||||
|
(total: number, range: [number, number]) =>
|
||||||
|
`${range[0]}-${range[1]} of ${total} groups`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTableChange = useCallback(
|
const handleTableChange = useCallback(
|
||||||
(
|
(
|
||||||
newPagination: TablePaginationConfig,
|
newPagination: TablePaginationConfig,
|
||||||
@@ -202,39 +291,138 @@ const ProjectList: React.FC = () => {
|
|||||||
newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
|
newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
dispatch(setRequestParams(newParams));
|
dispatch(setRequestParams(newParams));
|
||||||
|
|
||||||
|
// Also update grouped request params to keep them in sync
|
||||||
|
dispatch(setGroupedRequestParams({
|
||||||
|
...groupedRequestParams,
|
||||||
|
statuses: newParams.statuses,
|
||||||
|
categories: newParams.categories,
|
||||||
|
order: newParams.order,
|
||||||
|
field: newParams.field,
|
||||||
|
index: newParams.index,
|
||||||
|
size: newParams.size,
|
||||||
|
}));
|
||||||
|
|
||||||
setFilteredInfo(filters);
|
setFilteredInfo(filters);
|
||||||
},
|
},
|
||||||
[dispatch, setSortingValues]
|
[dispatch, setSortingValues, groupedRequestParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGroupedTableChange = useCallback(
|
||||||
|
(newPagination: TablePaginationConfig) => {
|
||||||
|
const newParams: Partial<typeof groupedRequestParams> = {
|
||||||
|
index: newPagination.current || 1,
|
||||||
|
size: newPagination.pageSize || DEFAULT_PAGE_SIZE,
|
||||||
|
};
|
||||||
|
dispatch(setGroupedRequestParams(newParams));
|
||||||
|
},
|
||||||
|
[dispatch, groupedRequestParams]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
trackMixpanelEvent(evt_projects_refresh_click);
|
trackMixpanelEvent(evt_projects_refresh_click);
|
||||||
refetchProjects();
|
if (viewMode === ProjectViewType.LIST) {
|
||||||
}, [trackMixpanelEvent, refetchProjects]);
|
refetchProjects();
|
||||||
|
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
|
||||||
|
dispatch(fetchGroupedProjects(groupedRequestParams));
|
||||||
|
}
|
||||||
|
}, [trackMixpanelEvent, refetchProjects, viewMode, groupBy, dispatch, groupedRequestParams]);
|
||||||
|
|
||||||
const handleSegmentChange = useCallback(
|
const handleSegmentChange = useCallback(
|
||||||
(value: IProjectFilter) => {
|
(value: IProjectFilter) => {
|
||||||
const newFilterIndex = filters.indexOf(value);
|
const newFilterIndex = filters.indexOf(value);
|
||||||
setFilterIndex(newFilterIndex);
|
setFilterIndex(newFilterIndex);
|
||||||
|
|
||||||
|
// Update both request params for consistency
|
||||||
dispatch(setRequestParams({ filter: newFilterIndex }));
|
dispatch(setRequestParams({ filter: newFilterIndex }));
|
||||||
refetchProjects();
|
dispatch(setGroupedRequestParams({
|
||||||
|
...groupedRequestParams,
|
||||||
|
filter: newFilterIndex,
|
||||||
|
index: 1 // Reset to first page when changing filter
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Refresh data based on current view mode
|
||||||
|
if (viewMode === ProjectViewType.LIST) {
|
||||||
|
refetchProjects();
|
||||||
|
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
|
||||||
|
dispatch(fetchGroupedProjects({
|
||||||
|
...groupedRequestParams,
|
||||||
|
filter: newFilterIndex,
|
||||||
|
index: 1
|
||||||
|
}));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[filters, setFilterIndex, dispatch, refetchProjects]
|
[filters, setFilterIndex, dispatch, refetchProjects, viewMode, groupBy, groupedRequestParams]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
// Debounced search for grouped projects
|
||||||
trackMixpanelEvent(evt_projects_search);
|
const debouncedGroupedSearch = useCallback(
|
||||||
const value = e.target.value;
|
debounce((params: typeof groupedRequestParams) => {
|
||||||
dispatch(setRequestParams({ search: value }));
|
if (groupBy) {
|
||||||
}, [trackMixpanelEvent, dispatch]);
|
dispatch(fetchGroupedProjects(params));
|
||||||
|
}
|
||||||
|
}, 300),
|
||||||
|
[dispatch, groupBy]
|
||||||
|
);
|
||||||
|
|
||||||
const handleViewToggle = useCallback((value: ProjectViewType) => {
|
const handleSearchChange = useCallback(
|
||||||
dispatch(setViewMode(value));
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
}, [dispatch]);
|
const searchValue = e.target.value;
|
||||||
|
trackMixpanelEvent(evt_projects_search);
|
||||||
|
|
||||||
const handleGroupByChange = useCallback((value: ProjectGroupBy) => {
|
// Update both request params for consistency
|
||||||
dispatch(setGroupBy(value));
|
dispatch(setRequestParams({ search: searchValue, index: 1 }));
|
||||||
}, [dispatch]);
|
|
||||||
|
if (viewMode === ProjectViewType.GROUP) {
|
||||||
|
const newGroupedParams = {
|
||||||
|
...groupedRequestParams,
|
||||||
|
search: searchValue,
|
||||||
|
index: 1
|
||||||
|
};
|
||||||
|
dispatch(setGroupedRequestParams(newGroupedParams));
|
||||||
|
|
||||||
|
// Trigger debounced search in group mode
|
||||||
|
debouncedGroupedSearch(newGroupedParams);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, trackMixpanelEvent, viewMode, groupedRequestParams, debouncedGroupedSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleViewToggle = useCallback(
|
||||||
|
(value: ProjectViewType) => {
|
||||||
|
dispatch(setViewMode(value));
|
||||||
|
if (value === ProjectViewType.GROUP) {
|
||||||
|
// Initialize grouped request params when switching to group view
|
||||||
|
const newGroupedParams = {
|
||||||
|
...groupedRequestParams,
|
||||||
|
groupBy: groupBy || ProjectGroupBy.CATEGORY,
|
||||||
|
search: requestParams.search,
|
||||||
|
filter: requestParams.filter,
|
||||||
|
statuses: requestParams.statuses,
|
||||||
|
categories: requestParams.categories,
|
||||||
|
};
|
||||||
|
dispatch(setGroupedRequestParams(newGroupedParams));
|
||||||
|
// Fetch grouped data immediately
|
||||||
|
dispatch(fetchGroupedProjects(newGroupedParams));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, groupBy, groupedRequestParams, requestParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGroupByChange = useCallback(
|
||||||
|
(value: ProjectGroupBy) => {
|
||||||
|
dispatch(setGroupBy(value));
|
||||||
|
const newGroupedParams = {
|
||||||
|
...groupedRequestParams,
|
||||||
|
groupBy: value,
|
||||||
|
index: 1, // Reset to first page when changing grouping
|
||||||
|
};
|
||||||
|
dispatch(setGroupedRequestParams(newGroupedParams));
|
||||||
|
// Fetch new grouped data
|
||||||
|
dispatch(fetchGroupedProjects(newGroupedParams));
|
||||||
|
},
|
||||||
|
[dispatch, groupedRequestParams]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDrawerClose = useCallback(() => {
|
const handleDrawerClose = useCallback(() => {
|
||||||
dispatch(setProject({} as IProjectViewModel));
|
dispatch(setProject({} as IProjectViewModel));
|
||||||
@@ -249,19 +437,131 @@ const ProjectList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
|
// Define table columns directly in the component to avoid hooks order issues
|
||||||
|
const tableColumns: ColumnsType<IProjectViewModel> = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'favorite',
|
||||||
|
key: 'favorite',
|
||||||
|
render: (text: string, record: IProjectViewModel) => (
|
||||||
|
<ProjectRateCell key={record.id} t={t} record={record} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('name'),
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
defaultSortOrder: 'ascend',
|
||||||
|
render: (text: string, record: IProjectViewModel) => (
|
||||||
|
<ProjectNameCell navigate={navigate} key={record.id} t={t} record={record} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('client'),
|
||||||
|
dataIndex: 'client_name',
|
||||||
|
key: 'client_name',
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('category'),
|
||||||
|
dataIndex: 'category',
|
||||||
|
key: 'category_id',
|
||||||
|
filters: categoryFilters,
|
||||||
|
filteredValue: filteredInfo.category_id || filteredCategories || [],
|
||||||
|
filterMultiple: true,
|
||||||
|
render: (text: string, record: IProjectViewModel) => (
|
||||||
|
<CategoryCell key={record.id} t={t} record={record} />
|
||||||
|
),
|
||||||
|
sorter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('status'),
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status_id',
|
||||||
|
filters: statusFilters,
|
||||||
|
filteredValue: filteredInfo.status_id || [],
|
||||||
|
filterMultiple: true,
|
||||||
|
sorter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('tasksProgress'),
|
||||||
|
dataIndex: 'tasksProgress',
|
||||||
|
key: 'tasksProgress',
|
||||||
|
render: (_: string, record: IProjectViewModel) => <ProgressListProgress record={record} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('updated_at'),
|
||||||
|
dataIndex: 'updated_at',
|
||||||
|
key: 'updated_at',
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
render: (_: string, record: IProjectViewModel) => <ProjectListUpdatedAt record={record} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('members'),
|
||||||
|
dataIndex: 'names',
|
||||||
|
key: 'members',
|
||||||
|
render: (members: InlineMember[]) => <Avatars members={members} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'button',
|
||||||
|
dataIndex: '',
|
||||||
|
render: (record: IProjectViewModel) => (
|
||||||
|
<ActionButtons
|
||||||
|
t={t}
|
||||||
|
record={record}
|
||||||
|
dispatch={dispatch}
|
||||||
|
isOwnerOrAdmin={isOwnerOrAdmin}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, categoryFilters, statusFilters, filteredInfo, filteredCategories, filteredStatuses, navigate, dispatch, isOwnerOrAdmin]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(loadingProjects || isFetchingProjects);
|
if (viewMode === ProjectViewType.LIST) {
|
||||||
}, [loadingProjects, isFetchingProjects]);
|
setIsLoading(loadingProjects || isFetchingProjects);
|
||||||
|
} else {
|
||||||
|
setIsLoading(groupedProjects.loading);
|
||||||
|
}
|
||||||
|
}, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filterIndex = getFilterIndex();
|
const filterIndex = getFilterIndex();
|
||||||
dispatch(setRequestParams({ filter: filterIndex }));
|
dispatch(setRequestParams({ filter: filterIndex }));
|
||||||
|
// Also sync with grouped request params on initial load
|
||||||
|
dispatch(setGroupedRequestParams({
|
||||||
|
filter: filterIndex,
|
||||||
|
index: 1,
|
||||||
|
size: DEFAULT_PAGE_SIZE,
|
||||||
|
field: 'name',
|
||||||
|
order: 'ascend',
|
||||||
|
search: '',
|
||||||
|
groupBy: '',
|
||||||
|
statuses: null,
|
||||||
|
categories: null,
|
||||||
|
}));
|
||||||
}, [dispatch, getFilterIndex]);
|
}, [dispatch, getFilterIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
trackMixpanelEvent(evt_projects_page_visit);
|
trackMixpanelEvent(evt_projects_page_visit);
|
||||||
refetchProjects();
|
if (viewMode === ProjectViewType.LIST) {
|
||||||
}, [requestParams, refetchProjects, trackMixpanelEvent]);
|
refetchProjects();
|
||||||
|
}
|
||||||
|
}, [requestParams, refetchProjects, trackMixpanelEvent, viewMode]);
|
||||||
|
|
||||||
|
// Separate useEffect for grouped projects
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewMode === ProjectViewType.GROUP && groupBy) {
|
||||||
|
dispatch(fetchGroupedProjects(groupedRequestParams));
|
||||||
|
}
|
||||||
|
}, [dispatch, viewMode, groupBy, groupedRequestParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
|
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
|
||||||
@@ -273,7 +573,7 @@ const ProjectList: React.FC = () => {
|
|||||||
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
className="site-page-header"
|
className="site-page-header"
|
||||||
title={`${projectsData?.body?.total || 0} ${t('projects')}`}
|
title={`${projectCount} ${t('projects')}`}
|
||||||
style={{ padding: '16px 0' }}
|
style={{ padding: '16px 0' }}
|
||||||
extra={
|
extra={
|
||||||
<Flex gap={8} align="center">
|
<Flex gap={8} align="center">
|
||||||
@@ -294,10 +594,6 @@ const ProjectList: React.FC = () => {
|
|||||||
options={viewToggleOptions}
|
options={viewToggleOptions}
|
||||||
value={viewMode}
|
value={viewMode}
|
||||||
onChange={handleViewToggle}
|
onChange={handleViewToggle}
|
||||||
style={{
|
|
||||||
backgroundColor: '#1f2937',
|
|
||||||
border: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{viewMode === ProjectViewType.GROUP && (
|
{viewMode === ProjectViewType.GROUP && (
|
||||||
<Select
|
<Select
|
||||||
@@ -323,31 +619,39 @@ const ProjectList: React.FC = () => {
|
|||||||
<Skeleton active loading={isLoading} className="mt-4 p-4">
|
<Skeleton active loading={isLoading} className="mt-4 p-4">
|
||||||
{viewMode === ProjectViewType.LIST ? (
|
{viewMode === ProjectViewType.LIST ? (
|
||||||
<Table<IProjectViewModel>
|
<Table<IProjectViewModel>
|
||||||
columns={TableColumns({
|
columns={tableColumns}
|
||||||
navigate,
|
dataSource={tableDataSource}
|
||||||
filteredInfo,
|
|
||||||
})}
|
|
||||||
dataSource={projectsData?.body?.data || []}
|
|
||||||
rowKey={record => record.id || ''}
|
rowKey={record => record.id || ''}
|
||||||
loading={loadingProjects}
|
loading={loadingProjects}
|
||||||
size="small"
|
size="small"
|
||||||
onChange={handleTableChange}
|
onChange={handleTableChange}
|
||||||
pagination={paginationConfig}
|
pagination={paginationConfig}
|
||||||
locale={{ emptyText: <Empty description={t('noProjects')} />}}
|
locale={{ emptyText }}
|
||||||
onRow={record => ({
|
onRow={record => ({
|
||||||
onClick: () => navigateToProject(record.id, record.team_member_default_view),
|
onClick: () => navigateToProject(record.id, record.team_member_default_view),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ProjectGroupList
|
<div>
|
||||||
groups={groupProjects(projectsData?.body?.data || [], groupBy)}
|
<ProjectGroupList
|
||||||
navigate={navigate}
|
groups={transformedGroupedProjects}
|
||||||
onProjectSelect={id => navigateToProject(id, undefined)}
|
navigate={navigate}
|
||||||
onArchive={() => {}}
|
onProjectSelect={id => navigateToProject(id, undefined)}
|
||||||
isOwnerOrAdmin={isOwnerOrAdmin}
|
onArchive={() => {}}
|
||||||
loading={loadingProjects}
|
isOwnerOrAdmin={isOwnerOrAdmin}
|
||||||
t={t}
|
loading={groupedProjects.loading}
|
||||||
/>
|
t={t}
|
||||||
|
/>
|
||||||
|
{!groupedProjects.loading && groupedProjects.data?.data && groupedProjects.data.data.length > 0 && (
|
||||||
|
<div style={{ marginTop: '24px', textAlign: 'center' }}>
|
||||||
|
<Pagination
|
||||||
|
{...groupedPaginationConfig}
|
||||||
|
onChange={(page, pageSize) => handleGroupedTableChange({ current: page, pageSize })}
|
||||||
|
showTotal={paginationShowTotal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -168,6 +168,61 @@ const Projects: React.FC = () => {
|
|||||||
[filteredProjects, allSelected]
|
[filteredProjects, allSelected]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Memoize group by options
|
||||||
|
const groupByOptions = useMemo(() => [
|
||||||
|
{ value: 'none', label: t('groupByNone') },
|
||||||
|
{ value: 'category', label: t('groupByCategory') },
|
||||||
|
{ value: 'team', label: t('groupByTeam') },
|
||||||
|
{ value: 'status', label: t('groupByStatus') },
|
||||||
|
], [t]);
|
||||||
|
|
||||||
|
// Memoize dropdown styles to prevent recalculation on every render
|
||||||
|
const dropdownStyles = useMemo(() => ({
|
||||||
|
dropdown: {
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
boxShadow: token.boxShadowSecondary,
|
||||||
|
border: `1px solid ${token.colorBorder}`,
|
||||||
|
},
|
||||||
|
groupHeader: {
|
||||||
|
backgroundColor: getThemeAwareColor(token.colorFillTertiary, token.colorFillQuaternary),
|
||||||
|
borderRadius: token.borderRadiusSM,
|
||||||
|
padding: '8px 12px',
|
||||||
|
marginBottom: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||||
|
},
|
||||||
|
projectItem: {
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: token.borderRadiusSM,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: `1px solid transparent`,
|
||||||
|
},
|
||||||
|
toggleIcon: {
|
||||||
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||||
|
fontSize: '12px',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
},
|
||||||
|
expandedToggleIcon: {
|
||||||
|
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||||
|
fontSize: '12px',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}
|
||||||
|
}), [token, getThemeAwareColor]);
|
||||||
|
|
||||||
|
// Memoize search placeholder and clear tooltip
|
||||||
|
const searchPlaceholder = useMemo(() => t('searchByProject'), [t]);
|
||||||
|
const clearTooltip = useMemo(() => t('clearSearch'), [t]);
|
||||||
|
const showSelectedTooltip = useMemo(() => t('showSelected'), [t]);
|
||||||
|
const selectAllText = useMemo(() => t('selectAll'), [t]);
|
||||||
|
const projectsSelectedText = useMemo(() => t('projectsSelected'), [t]);
|
||||||
|
const noProjectsText = useMemo(() => t('noProjects'), [t]);
|
||||||
|
const noDataText = useMemo(() => t('noData'), [t]);
|
||||||
|
const expandAllText = useMemo(() => t('expandAll'), [t]);
|
||||||
|
const collapseAllText = useMemo(() => t('collapseAll'), [t]);
|
||||||
|
|
||||||
// Handle checkbox change for individual items
|
// Handle checkbox change for individual items
|
||||||
const handleCheckboxChange = useCallback((key: string, checked: boolean) => {
|
const handleCheckboxChange = useCallback((key: string, checked: boolean) => {
|
||||||
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
|
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
|
||||||
@@ -202,54 +257,7 @@ const Projects: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [groupedProjects]);
|
}, [groupedProjects]);
|
||||||
|
|
||||||
// Get theme-aware styles
|
|
||||||
const getThemeStyles = useCallback(() => {
|
|
||||||
const isDark = themeMode === 'dark';
|
|
||||||
return {
|
|
||||||
dropdown: {
|
|
||||||
background: token.colorBgContainer,
|
|
||||||
borderRadius: token.borderRadius,
|
|
||||||
boxShadow: token.boxShadowSecondary,
|
|
||||||
border: `1px solid ${token.colorBorder}`,
|
|
||||||
},
|
|
||||||
groupHeader: {
|
|
||||||
backgroundColor: getThemeAwareColor(token.colorFillTertiary, token.colorFillQuaternary),
|
|
||||||
borderRadius: token.borderRadiusSM,
|
|
||||||
padding: '8px 12px',
|
|
||||||
marginBottom: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary),
|
|
||||||
borderColor: getThemeAwareColor(token.colorBorder, token.colorBorderSecondary),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
projectItem: {
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: token.borderRadiusSM,
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: `1px solid transparent`,
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
|
|
||||||
borderColor: getThemeAwareColor(token.colorBorderSecondary, token.colorBorder),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toggleIcon: {
|
|
||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
|
||||||
fontSize: '12px',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
},
|
|
||||||
expandedToggleIcon: {
|
|
||||||
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
|
||||||
fontSize: '12px',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [token, themeMode, getThemeAwareColor]);
|
|
||||||
|
|
||||||
const styles = getThemeStyles();
|
|
||||||
|
|
||||||
// Render project group
|
// Render project group
|
||||||
const renderProjectGroup = (group: ProjectGroup) => {
|
const renderProjectGroup = (group: ProjectGroup) => {
|
||||||
@@ -261,10 +269,10 @@ const Projects: React.FC = () => {
|
|||||||
{groupBy !== 'none' && (
|
{groupBy !== 'none' && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
...styles.groupHeader,
|
...dropdownStyles.groupHeader,
|
||||||
backgroundColor: isExpanded
|
backgroundColor: isExpanded
|
||||||
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
||||||
: styles.groupHeader.backgroundColor
|
: dropdownStyles.groupHeader.backgroundColor
|
||||||
}}
|
}}
|
||||||
onClick={() => toggleGroupExpansion(group.key)}
|
onClick={() => toggleGroupExpansion(group.key)}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
@@ -274,15 +282,15 @@ const Projects: React.FC = () => {
|
|||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.backgroundColor = isExpanded
|
e.currentTarget.style.backgroundColor = isExpanded
|
||||||
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
||||||
: styles.groupHeader.backgroundColor;
|
: dropdownStyles.groupHeader.backgroundColor;
|
||||||
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorderSecondary, token.colorBorder);
|
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorderSecondary, token.colorBorder);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Space>
|
<Space>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<DownOutlined style={styles.expandedToggleIcon} />
|
<DownOutlined style={dropdownStyles.expandedToggleIcon} />
|
||||||
) : (
|
) : (
|
||||||
<RightOutlined style={styles.toggleIcon} />
|
<RightOutlined style={dropdownStyles.toggleIcon} />
|
||||||
)}
|
)}
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '12px',
|
width: '12px',
|
||||||
@@ -314,7 +322,7 @@ const Projects: React.FC = () => {
|
|||||||
{group.projects.map(project => (
|
{group.projects.map(project => (
|
||||||
<div
|
<div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
style={styles.projectItem}
|
style={dropdownStyles.projectItem}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.backgroundColor = getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary);
|
e.currentTarget.style.backgroundColor = getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary);
|
||||||
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorderSecondary, token.colorBorder);
|
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorderSecondary, token.colorBorder);
|
||||||
@@ -362,7 +370,7 @@ const Projects: React.FC = () => {
|
|||||||
open={dropdownVisible}
|
open={dropdownVisible}
|
||||||
dropdownRender={() => (
|
dropdownRender={() => (
|
||||||
<div style={{
|
<div style={{
|
||||||
...styles.dropdown,
|
...dropdownStyles.dropdown,
|
||||||
padding: '8px 0',
|
padding: '8px 0',
|
||||||
maxHeight: '500px',
|
maxHeight: '500px',
|
||||||
width: '400px',
|
width: '400px',
|
||||||
@@ -374,12 +382,12 @@ const Projects: React.FC = () => {
|
|||||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('searchByProject')}
|
placeholder={searchPlaceholder}
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={e => setSearchText(e.target.value)}
|
onChange={e => setSearchText(e.target.value)}
|
||||||
prefix={<SearchOutlined style={{ color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary) }} />}
|
prefix={<SearchOutlined style={{ color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary) }} />}
|
||||||
suffix={searchText && (
|
suffix={searchText && (
|
||||||
<Tooltip title={t('clearSearch')}>
|
<Tooltip title={clearTooltip}>
|
||||||
<ClearOutlined
|
<ClearOutlined
|
||||||
onClick={clearSearch}
|
onClick={clearSearch}
|
||||||
style={{
|
style={{
|
||||||
@@ -407,12 +415,7 @@ const Projects: React.FC = () => {
|
|||||||
onChange={setGroupBy}
|
onChange={setGroupBy}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ width: '120px' }}
|
style={{ width: '120px' }}
|
||||||
options={[
|
options={groupByOptions}
|
||||||
{ value: 'none', label: t('groupByNone') },
|
|
||||||
{ value: 'category', label: t('groupByCategory') },
|
|
||||||
{ value: 'team', label: t('groupByTeam') },
|
|
||||||
{ value: 'status', label: t('groupByStatus') },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{groupBy !== 'none' && (
|
{groupBy !== 'none' && (
|
||||||
@@ -425,7 +428,7 @@ const Projects: React.FC = () => {
|
|||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('expandAll')}
|
{expandAllText}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
@@ -435,13 +438,13 @@ const Projects: React.FC = () => {
|
|||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('collapseAll')}
|
{collapseAllText}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Tooltip title={t('showSelected')}>
|
<Tooltip title={showSelectedTooltip}>
|
||||||
<Button
|
<Button
|
||||||
type={showSelectedOnly ? 'primary' : 'text'}
|
type={showSelectedOnly ? 'primary' : 'text'}
|
||||||
size="small"
|
size="small"
|
||||||
@@ -468,7 +471,7 @@ const Projects: React.FC = () => {
|
|||||||
<Text style={{
|
<Text style={{
|
||||||
color: getThemeAwareColor(token.colorText, token.colorTextBase)
|
color: getThemeAwareColor(token.colorText, token.colorTextBase)
|
||||||
}}>
|
}}>
|
||||||
{t('selectAll')}
|
{selectAllText}
|
||||||
</Text>
|
</Text>
|
||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -499,7 +502,7 @@ const Projects: React.FC = () => {
|
|||||||
<Text style={{
|
<Text style={{
|
||||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary)
|
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary)
|
||||||
}}>
|
}}>
|
||||||
{searchText ? t('noProjects') : t('noData')}
|
{searchText ? noProjectsText : noDataText}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
style={{ margin: '20px 0' }}
|
style={{ margin: '20px 0' }}
|
||||||
@@ -524,7 +527,7 @@ const Projects: React.FC = () => {
|
|||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary)
|
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary)
|
||||||
}}>
|
}}>
|
||||||
{selectedCount} {t('projectsSelected')}
|
{selectedCount} {projectsSelectedText}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IProjectViewModel } from './projectViewModel.types';
|
||||||
|
|
||||||
|
export interface IProjectGroup {
|
||||||
|
group_key: string;
|
||||||
|
group_name: string;
|
||||||
|
group_color?: string;
|
||||||
|
project_count: number;
|
||||||
|
projects: IProjectViewModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IGroupedProjectsViewModel {
|
||||||
|
total_groups: number;
|
||||||
|
data: IProjectGroup[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user