Merge pull request #157 from Worklenz/feature/project-list-grouping

Feature/project list grouping
This commit is contained in:
Chamika J
2025-06-16 10:05:54 +05:30
committed by GitHub
31 changed files with 7433 additions and 5785 deletions

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "worklenz",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -68,6 +68,7 @@
"express-rate-limit": "^6.8.0",
"express-session": "^1.17.3",
"express-validator": "^6.15.0",
"grunt-cli": "^1.5.0",
"helmet": "^6.2.0",
"hpp": "^0.2.3",
"http-errors": "^2.0.0",
@@ -93,8 +94,10 @@
"sharp": "^0.32.6",
"slugify": "^1.6.6",
"socket.io": "^4.7.1",
"tinymce": "^7.8.0",
"uglify-js": "^3.17.4",
"winston": "^3.10.0",
"worklenz-backend": "file:",
"xss-filters": "^1.2.7"
},
"devDependencies": {
@@ -102,15 +105,17 @@
"@babel/preset-typescript": "^7.22.5",
"@types/bcrypt": "^5.0.0",
"@types/bluebird": "^3.5.38",
"@types/body-parser": "^1.19.2",
"@types/compression": "^1.7.2",
"@types/connect-flash": "^0.0.37",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.1",
"@types/crypto-js": "^4.2.2",
"@types/csurf": "^1.11.2",
"@types/express": "^4.17.17",
"@types/express": "^4.17.21",
"@types/express-brute": "^1.0.2",
"@types/express-brute-redis": "^0.0.4",
"@types/express-serve-static-core": "^4.17.34",
"@types/express-session": "^1.17.7",
"@types/fs-extra": "^9.0.13",
"@types/hpp": "^0.2.2",

View File

@@ -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: [] }));
}
}

View File

@@ -0,0 +1,20 @@
{
"name": "tinymce",
"version": "6.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tinymce",
"version": "6.8.4",
"license": "MIT",
"dependencies": {
"tinymce": "file:"
}
},
"node_modules/tinymce": {
"resolved": "",
"link": true
}
}
}

View File

@@ -28,5 +28,8 @@
"homepage": "https://www.tiny.cloud/",
"bugs": {
"url": "https://github.com/tinymce/tinymce/issues"
},
"dependencies": {
"tinymce": "file:"
}
}

View File

@@ -18,6 +18,7 @@ projectsApiRouter.get("/update-exist-sort-order", safeControllerFunction(Project
projectsApiRouter.post("/", teamOwnerOrAdminValidator, projectsBodyValidator, safeControllerFunction(ProjectsController.create));
projectsApiRouter.get("/", safeControllerFunction(ProjectsController.get));
projectsApiRouter.get("/grouped", safeControllerFunction(ProjectsController.getGrouped));
projectsApiRouter.get("/my-task-projects", safeControllerFunction(ProjectsController.getMyProjectsToTasks));
projectsApiRouter.get("/my-projects", safeControllerFunction(ProjectsController.getMyProjects));
projectsApiRouter.get("/all", safeControllerFunction(ProjectsController.getAllProjects));

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,8 @@
"version": "1.0.0",
"private": true,
"scripts": {
"start": "vite",
"start": "vite dev",
"dev": "vite dev",
"prebuild": "node scripts/copy-tinymce.js",
"build": "vite build",
"dev-build": "vite build",
@@ -13,7 +14,7 @@
"dependencies": {
"@ant-design/colors": "^7.1.0",
"@ant-design/compatible": "^5.1.4",
"@ant-design/icons": "^5.4.0",
"@ant-design/icons": "^4.7.0",
"@ant-design/pro-components": "^2.7.19",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
@@ -29,6 +30,7 @@
"axios": "^1.9.0",
"chart.js": "^4.4.7",
"chartjs-plugin-datalabels": "^2.2.0",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"dompurify": "^3.2.5",
"gantt-task-react": "^0.3.9",
@@ -38,6 +40,7 @@
"i18next-http-backend": "^2.7.3",
"jspdf": "^3.0.0",
"mixpanel-browser": "^2.56.0",
"nanoid": "^5.1.5",
"primereact": "^10.8.4",
"re-resizable": "^6.10.3",
"react": "^18.3.1",
@@ -52,7 +55,8 @@
"react-window": "^1.8.11",
"socket.io-client": "^4.8.1",
"tinymce": "^7.7.2",
"web-vitals": "^4.2.4"
"web-vitals": "^4.2.4",
"worklenz": "file:"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
@@ -70,6 +74,7 @@
"autoprefixer": "^10.4.20",
"postcss": "^8.5.2",
"prettier-plugin-tailwindcss": "^0.6.8",
"rollup": "^4.40.2",
"tailwindcss": "^3.4.17",
"terser": "^5.39.0",
"typescript": "^5.7.3",

View File

@@ -19,5 +19,13 @@
"unarchiveConfirm": "Are you sure you want to unarchive this project?",
"clickToFilter": "Click to filter by",
"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"
}
}

View File

@@ -40,5 +40,18 @@
"noCategory": "No Category",
"noProjects": "No projects found",
"noTeams": "No teams found",
"noData": "No data found"
"noData": "No data found",
"groupBy": "Group by",
"groupByCategory": "Category",
"groupByTeam": "Team",
"groupByStatus": "Status",
"groupByNone": "None",
"clearSearch": "Clear search",
"selectedProjects": "Selected Projects",
"projectsSelected": "projects selected",
"showSelected": "Show Selected Only",
"expandAll": "Expand All",
"collapseAll": "Collapse All",
"ungrouped": "Ungrouped"
}

View File

@@ -19,5 +19,13 @@
"unarchiveConfirm": "¿Estás seguro de que deseas desarchivar este proyecto?",
"clickToFilter": "Clique para filtrar por",
"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"
}
}

View File

@@ -7,7 +7,7 @@
"selectAll": "Seleccionar Todo",
"teams": "Equipos",
"searchByProject": "Buscar por nombre de proyecto",
"searchByProject": "Buscar por nombre del proyecto",
"projects": "Proyectos",
"searchByCategory": "Buscar por nombre de categoría",
@@ -37,8 +37,21 @@
"actualDays": "Días Reales",
"noCategories": "No se encontraron categorías",
"noCategory": "No Categoría",
"noCategory": "Sin Categoría",
"noProjects": "No se encontraron proyectos",
"noTeams": "No se encontraron equipos",
"noData": "No se encontraron datos"
"noData": "No se encontraron datos",
"groupBy": "Agrupar por",
"groupByCategory": "Categoría",
"groupByTeam": "Equipo",
"groupByStatus": "Estado",
"groupByNone": "Ninguno",
"clearSearch": "Limpiar búsqueda",
"selectedProjects": "Proyectos Seleccionados",
"projectsSelected": "proyectos seleccionados",
"showSelected": "Mostrar Solo Seleccionados",
"expandAll": "Expandir Todo",
"collapseAll": "Contraer Todo",
"ungrouped": "Sin Agrupar"
}

View File

@@ -19,5 +19,13 @@
"unarchiveConfirm": "Tem certeza de que deseja desarquivar este projeto?",
"clickToFilter": "Clique para filtrar por",
"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"
}
}

View File

@@ -4,7 +4,7 @@
"timeSheet": "Folha de Tempo",
"searchByName": "Pesquisar por nome",
"selectAll": "Selecionar Todos",
"selectAll": "Selecionar Tudo",
"teams": "Equipes",
"searchByProject": "Pesquisar por nome do projeto",
@@ -13,32 +13,45 @@
"searchByCategory": "Pesquisar por nome da categoria",
"categories": "Categorias",
"billable": "Cobrável",
"nonBillable": "Não Cobrável",
"billable": "Faturável",
"nonBillable": "Não Faturável",
"total": "Total",
"projectsTimeSheet": "Folha de Tempo dos Projetos",
"projectsTimeSheet": "Folha de Tempo de Projetos",
"loggedTime": "Tempo Registrado (horas)",
"loggedTime": "Tempo Registrado(horas)",
"exportToExcel": "Exportar para Excel",
"logged": "registrado",
"for": "para",
"membersTimeSheet": "Folha de Tempo dos Membros",
"membersTimeSheet": "Folha de Tempo de Membros",
"member": "Membro",
"estimatedVsActual": "Estimado vs Real",
"workingDays": "Dias de Trabalho",
"manDays": "Dias-Homem",
"workingDays": "Dias Úteis",
"manDays": "Dias Homem",
"days": "Dias",
"estimatedDays": "Dias Estimados",
"actualDays": "Dias Reais",
"noCategories": "Nenhuma categoria encontrada",
"noCategory": "Nenhuma Categoria",
"noCategory": "Sem Categoria",
"noProjects": "Nenhum projeto encontrado",
"noTeams": "Nenhum time encontrado",
"noData": "Nenhum dado encontrado"
"noTeams": "Nenhuma equipe encontrada",
"noData": "Nenhum dado encontrado",
"groupBy": "Agrupar por",
"groupByCategory": "Categoria",
"groupByTeam": "Equipe",
"groupByStatus": "Status",
"groupByNone": "Nenhum",
"clearSearch": "Limpar pesquisa",
"selectedProjects": "Projetos Selecionados",
"projectsSelected": "projetos selecionados",
"showSelected": "Mostrar Apenas Selecionados",
"expandAll": "Expandir Tudo",
"collapseAll": "Recolher Tudo",
"ungrouped": "Não Agrupado"
}

View File

@@ -7,6 +7,7 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types';
import { IProjectMembersViewModel } from '@/types/projectMember.types';
import { IProjectManager } from '@/types/project/projectManager.types';
import { IGroupedProjectsViewModel } from '@/types/project/groupedProjectsViewModel.types';
const rootUrl = `${API_BASE_URL}/projects`;
@@ -32,6 +33,23 @@ export const projectsApiService = {
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>> => {
const url = `${rootUrl}/${id}`;
const response = await apiClient.get<IServerResponse<IProjectViewModel>>(`${url}`);

View File

@@ -76,6 +76,8 @@ import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/g
import homePageApiService from '@/api/home-page/home-page.api.service';
import { projectsApi } from '@/api/projects/projects.v1.api.service';
import projectViewReducer from '@features/project/project-view-slice';
export const store = configureStore({
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
@@ -114,6 +116,8 @@ export const store = configureStore({
boardReducer: boardReducer,
projectDrawerReducer: projectDrawerReducer,
projectViewReducer: projectViewReducer,
// Project Lookups
projectCategoriesReducer: projectCategoriesReducer,
projectStatusesReducer: projectStatusesReducer,

View File

@@ -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;

View File

@@ -0,0 +1,563 @@
import React, { useMemo } from 'react';
import {
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 { 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 ProjectGroupList: React.FC<ProjectGroupListProps> = ({
groups,
navigate,
onProjectSelect,
loading,
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) {
return (
<div style={styles.loadingContainer}>
<Skeleton active paragraph={{ rows: 6 }} />
</div>
);
}
// Early return for empty state
if (groups.length === 0) {
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 (
<div style={styles.container}>
{groups.map((group, groupIndex) => (
<div key={group.groupKey} style={styles.groupSection}>
{/* Enhanced group header */}
<div style={styles.groupHeader}>
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
<Space align="center">
{group.groupColor && (
<div style={{
width: '16px',
height: '16px',
borderRadius: '50%',
backgroundColor: processColor(group.groupColor),
flexShrink: 0,
border: `2px solid ${getThemeAwareColor('rgba(255,255,255,0.8)', 'rgba(0,0,0,0.3)')}`
}} />
)}
<div>
<Title level={4} style={styles.groupTitle}>
{group.groupName}
</Title>
<div style={styles.groupMeta}>
{group.projects.length} {group.projects.length === 1 ? 'project' : 'projects'}
</div>
</div>
</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>
{/* 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>
);
};
export default ProjectGroupList;

View File

@@ -0,0 +1,47 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { ProjectGroupBy, ProjectViewType } from '@/types/project/project.types';
interface ProjectViewState {
mode: ProjectViewType;
groupBy: ProjectGroupBy;
lastUpdated?: string;
}
const LOCAL_STORAGE_KEY = 'project_view_preferences';
const loadInitialState = (): ProjectViewState => {
const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
return saved
? JSON.parse(saved)
: {
mode: ProjectViewType.LIST,
groupBy: ProjectGroupBy.CATEGORY,
lastUpdated: new Date().toISOString()
};
};
const initialState: ProjectViewState = loadInitialState();
export const projectViewSlice = createSlice({
name: 'projectView',
initialState,
reducers: {
setViewMode: (state, action: PayloadAction<ProjectViewType>) => {
state.mode = action.payload;
state.lastUpdated = new Date().toISOString();
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state));
},
setGroupBy: (state, action: PayloadAction<ProjectGroupBy>) => {
state.groupBy = action.payload;
state.lastUpdated = new Date().toISOString();
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state));
},
resetViewState: () => {
localStorage.removeItem(LOCAL_STORAGE_KEY);
return loadInitialState();
}
}
});
export const { setViewMode, setGroupBy, resetViewState } = projectViewSlice.actions;
export default projectViewSlice.reducer;

View File

@@ -5,12 +5,17 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { IProjectCategory } from '@/types/project/projectCategory.types';
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
import { IProjectManager } from '@/types/project/projectManager.types';
import { IGroupedProjectsViewModel } from '@/types/project/groupedProjectsViewModel.types';
interface ProjectState {
projects: {
data: IProjectViewModel[];
total: number;
};
groupedProjects: {
data: IGroupedProjectsViewModel | null;
loading: boolean;
};
categories: IProjectCategory[];
loading: boolean;
creatingProject: boolean;
@@ -29,6 +34,17 @@ interface ProjectState {
statuses: 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[];
projectManagersLoading: boolean;
}
@@ -38,6 +54,10 @@ const initialState: ProjectState = {
data: [],
total: 0,
},
groupedProjects: {
data: null,
loading: false,
},
categories: [],
loading: false,
creatingProject: false,
@@ -56,6 +76,17 @@ const initialState: ProjectState = {
statuses: null,
categories: null,
},
groupedRequestParams: {
index: 1,
size: DEFAULT_PAGE_SIZE,
field: 'name',
order: 'ascend',
search: '',
groupBy: '',
filter: 0,
statuses: null,
categories: null,
},
projectManagers: [],
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(
'projects/toggleFavoriteProject',
async (id: string, { rejectWithValue }) => {
@@ -131,7 +202,7 @@ export const createProject = createAsyncThunk(
export const updateProject = createAsyncThunk(
'projects/updateProject',
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;
}
);
@@ -196,6 +267,12 @@ const projectSlice = createSlice({
...action.payload,
};
},
setGroupedRequestParams: (state, action: PayloadAction<Partial<ProjectState['groupedRequestParams']>>) => {
state.groupedRequestParams = {
...state.groupedRequestParams,
...action.payload,
};
},
},
extraReducers: builder => {
builder
@@ -213,6 +290,16 @@ const projectSlice = createSlice({
.addCase(fetchProjects.rejected, state => {
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 => {
state.creatingProject = true;
})
@@ -248,5 +335,6 @@ export const {
setFilteredCategories,
setFilteredStatuses,
setRequestParams,
setGroupedRequestParams,
} = projectSlice.actions;
export default projectSlice.reducer;

View File

@@ -25,3 +25,84 @@
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-bottom > div > .ant-tabs-nav::before {
border: none;
}
.project-group-container {
margin-top: 16px;
}
.project-group {
margin-bottom: 32px;
}
.project-group-header {
display: flex;
align-items: center;
margin-bottom: 16px;
gap: 8px;
}
.group-color-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
.group-stats {
margin-left: 8px;
font-size: 14px;
font-weight: normal;
}
.project-card {
height: 100%;
overflow: hidden;
}
.project-card .ant-card-cover {
height: 4px;
}
.project-status-bar {
width: 100%;
height: 100%;
}
.project-card-content {
padding: 8px;
}
.project-title {
margin-bottom: 8px !important;
min-height: 44px;
}
.project-client {
display: block;
margin-bottom: 12px;
font-size: 12px;
}
.project-progress {
margin-bottom: 12px;
}
.project-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.project-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.project-status-tag {
margin-top: 8px;
width: 100%;
text-align: center;
}

View File

@@ -1,26 +1,44 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ProjectViewType, ProjectGroupBy } from '@/types/project/project.types';
import { setViewMode, setGroupBy } from '@features/project/project-view-slice';
import debounce from 'lodash/debounce';
import {
Button,
Card,
Empty,
Flex,
Input,
Pagination,
Segmented,
Select,
Skeleton,
Table,
TablePaginationConfig,
Tooltip,
} from 'antd';
import { PageHeader } from '@ant-design/pro-components';
import { SearchOutlined, SyncOutlined } from '@ant-design/icons';
import {
SearchOutlined,
SyncOutlined,
UnorderedListOutlined,
AppstoreOutlined,
} from '@ant-design/icons';
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
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';
@@ -43,6 +61,8 @@ import {
setFilteredCategories,
setFilteredStatuses,
setRequestParams,
setGroupedRequestParams,
fetchGroupedProjects,
} from '@/features/projects/projectsSlice';
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
import { fetchProjectCategories } from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
@@ -50,12 +70,22 @@ import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/pr
import { setProjectId, setStatuses } from '@/features/project/project.slice';
import { setProject } from '@/features/project/project.slice';
import { createPortal } from 'react-dom';
import { evt_projects_page_visit, evt_projects_refresh_click, evt_projects_search } from '@/shared/worklenz-analytics-events';
import {
evt_projects_page_visit,
evt_projects_refresh_click,
evt_projects_search,
} from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import ProjectGroupList from '@/components/project-list/project-group/project-group-list';
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 [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation('all-project-list');
const dispatch = useAppDispatch();
const navigate = useNavigate();
@@ -63,6 +93,23 @@ const ProjectList: React.FC = () => {
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const { trackMixpanelEvent } = useMixpanelTracking();
// Get view state from Redux
const { mode: viewMode, groupBy } = useAppSelector((state) => state.projectViewReducer);
const { requestParams, groupedRequestParams, groupedProjects } = useAppSelector(state => state.projectsReducer);
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
const { filteredCategories, filteredStatuses } = useAppSelector(
state => state.projectsReducer
);
const {
data: projectsData,
isLoading: loadingProjects,
isFetching: isFetchingProjects,
refetch: refetchProjects,
} = useGetProjectsQuery(requestParams);
const getFilterIndex = useCallback(() => {
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
}, []);
@@ -76,42 +123,139 @@ const ProjectList: React.FC = () => {
localStorage.setItem(PROJECT_SORT_ORDER, order);
}, []);
const { requestParams } = useAppSelector(state => state.projectsReducer);
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
const {
data: projectsData,
isLoading: loadingProjects,
isFetching: isFetchingProjects,
refetch: refetchProjects,
} = useGetProjectsQuery(requestParams);
const filters = useMemo(() => Object.values(IProjectFilter), []);
// Create translated segment options for the filters
const segmentOptions = useMemo(() => {
return filters.map(filter => ({
value: filter,
label: t(filter.toLowerCase())
label: t(filter.toLowerCase()),
}));
}, [filters, t]);
useEffect(() => {
setIsLoading(loadingProjects || isFetchingProjects);
}, [loadingProjects, isFetchingProjects]);
const viewToggleOptions = useMemo(
() => [
{
value: ProjectViewType.LIST,
label: (
<Tooltip title={t('listView')}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<UnorderedListOutlined />
<span>{t('list')}</span>
</div>
</Tooltip>
),
},
{
value: ProjectViewType.GROUP,
label: (
<Tooltip title={t('groupView')}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<AppstoreOutlined />
<span>{t('group')}</span>
</div>
</Tooltip>
),
},
],
[t]
);
useEffect(() => {
const filterIndex = getFilterIndex();
dispatch(setRequestParams({ filter: filterIndex }));
}, [dispatch, getFilterIndex]);
const groupByOptions = useMemo(
() => [
{
value: ProjectGroupBy.CATEGORY,
label: t('groupBy.category'),
},
{
value: ProjectGroupBy.CLIENT,
label: t('groupBy.client'),
},
],
[t]
);
useEffect(() => {
trackMixpanelEvent(evt_projects_page_visit);
refetchProjects();
}, [requestParams, refetchProjects]);
// 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(
() => ({
current: requestParams.index,
pageSize: requestParams.size,
showSizeChanger: true,
defaultPageSize: DEFAULT_PAGE_SIZE,
pageSizeOptions: PAGE_SIZE_OPTIONS,
size: 'small' as const,
total: 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(
(
@@ -124,7 +268,6 @@ const ProjectList: React.FC = () => {
newParams.statuses = null;
dispatch(setFilteredStatuses([]));
} else {
// dispatch(setFilteredStatuses(filters.status_id as Array<string>));
newParams.statuses = filters.status_id.join(' ');
}
@@ -132,7 +275,6 @@ const ProjectList: React.FC = () => {
newParams.categories = null;
dispatch(setFilteredCategories([]));
} else {
// dispatch(setFilteredCategories(filters.category_id as Array<string>));
newParams.categories = filters.category_id.join(' ');
}
@@ -149,66 +291,289 @@ const ProjectList: React.FC = () => {
newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
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);
},
[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(() => {
trackMixpanelEvent(evt_projects_refresh_click);
refetchProjects();
}, [refetchProjects, requestParams]);
if (viewMode === ProjectViewType.LIST) {
refetchProjects();
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
dispatch(fetchGroupedProjects(groupedRequestParams));
}
}, [trackMixpanelEvent, refetchProjects, viewMode, groupBy, dispatch, groupedRequestParams]);
const handleSegmentChange = useCallback(
(value: IProjectFilter) => {
const newFilterIndex = filters.indexOf(value);
setFilterIndex(newFilterIndex);
// Update both request params for consistency
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, refetchProjects]
[filters, setFilterIndex, dispatch, refetchProjects, viewMode, groupBy, groupedRequestParams]
);
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
trackMixpanelEvent(evt_projects_search);
const value = e.target.value;
dispatch(setRequestParams({ search: value }));
}, []);
const paginationConfig = useMemo(
() => ({
current: requestParams.index,
pageSize: requestParams.size,
showSizeChanger: true,
defaultPageSize: DEFAULT_PAGE_SIZE,
pageSizeOptions: PAGE_SIZE_OPTIONS,
size: 'small' as const,
total: projectsData?.body?.total,
}),
[requestParams.index, requestParams.size, projectsData?.body?.total]
// Debounced search for grouped projects
const debouncedGroupedSearch = useCallback(
debounce((params: typeof groupedRequestParams) => {
if (groupBy) {
dispatch(fetchGroupedProjects(params));
}
}, 300),
[dispatch, groupBy]
);
const handleDrawerClose = () => {
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const searchValue = e.target.value;
trackMixpanelEvent(evt_projects_search);
// Update both request params for consistency
dispatch(setRequestParams({ search: searchValue, index: 1 }));
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(() => {
dispatch(setProject({} as IProjectViewModel));
dispatch(setProjectId(null));
};
const navigateToProject = (project_id: string | undefined, default_view: string | undefined) => {
}, [dispatch]);
const navigateToProject = useCallback((project_id: string | undefined, default_view: string | undefined) => {
if (project_id) {
navigate(`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`); // Update the route as per your project structure
navigate(
`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`
);
}
};
}, [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(() => {
if (viewMode === ProjectViewType.LIST) {
setIsLoading(loadingProjects || isFetchingProjects);
} else {
setIsLoading(groupedProjects.loading);
}
}, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading]);
useEffect(() => {
const filterIndex = getFilterIndex();
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]);
useEffect(() => {
trackMixpanelEvent(evt_projects_page_visit);
if (viewMode === ProjectViewType.LIST) {
refetchProjects();
}
}, [requestParams, refetchProjects, trackMixpanelEvent, viewMode]);
// Separate useEffect for grouped projects
useEffect(() => {
if (viewMode === ProjectViewType.GROUP && groupBy) {
dispatch(fetchGroupedProjects(groupedRequestParams));
}
}, [dispatch, viewMode, groupBy, groupedRequestParams]);
useEffect(() => {
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
if (projectCategories.length === 0) dispatch(fetchProjectCategories());
if (projectHealths.length === 0) dispatch(fetchProjectHealth());
}, [requestParams]);
}, [dispatch, projectStatuses.length, projectCategories.length, projectHealths.length]);
return (
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
<PageHeader
className="site-page-header"
title={`${projectsData?.body?.total || 0} ${t('projects')}`}
title={`${projectCount} ${t('projects')}`}
style={{ padding: '16px 0' }}
extra={
<Flex gap={8} align="center">
@@ -225,6 +590,19 @@ const ProjectList: React.FC = () => {
defaultValue={filters[getFilterIndex()] ?? filters[0]}
onChange={handleSegmentChange}
/>
<Segmented
options={viewToggleOptions}
value={viewMode}
onChange={handleViewToggle}
/>
{viewMode === ProjectViewType.GROUP && (
<Select
value={groupBy}
onChange={handleGroupByChange}
options={groupByOptions}
style={{ width: 150 }}
/>
)}
<Input
placeholder={t('placeholder')}
suffix={<SearchOutlined />}
@@ -238,25 +616,44 @@ const ProjectList: React.FC = () => {
}
/>
<Card className="project-card">
<Skeleton active loading={isLoading} className='mt-4 p-4'>
<Table<IProjectViewModel>
columns={TableColumns({
navigate,
filteredInfo,
})}
dataSource={projectsData?.body?.data || []}
rowKey={record => record.id || ''}
loading={loadingProjects}
size="small"
onChange={handleTableChange}
pagination={paginationConfig}
locale={{ emptyText: <Empty description={t('noProjects')} /> }}
onRow={record => ({
onClick: () => navigateToProject(record.id, record.team_member_default_view), // Navigate to project on row click
})}
/>
<Skeleton active loading={isLoading} className="mt-4 p-4">
{viewMode === ProjectViewType.LIST ? (
<Table<IProjectViewModel>
columns={tableColumns}
dataSource={tableDataSource}
rowKey={record => record.id || ''}
loading={loadingProjects}
size="small"
onChange={handleTableChange}
pagination={paginationConfig}
locale={{ emptyText }}
onRow={record => ({
onClick: () => navigateToProject(record.id, record.team_member_default_view),
})}
/>
) : (
<div>
<ProjectGroupList
groups={transformedGroupedProjects}
navigate={navigate}
onProjectSelect={id => navigateToProject(id, undefined)}
onArchive={() => {}}
isOwnerOrAdmin={isOwnerOrAdmin}
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>
</Card>
{createPortal(<ProjectDrawer onClose={handleDrawerClose} />, document.body, 'project-drawer')}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { nanoid } from 'nanoid';
import { nanoid } from "nanoid";
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { nanoid } from 'nanoid';
import { nanoid } from "nanoid";
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';

View File

@@ -1,37 +1,364 @@
import { setSelectOrDeselectAllProjects, setSelectOrDeselectProject } from '@/features/reporting/time-reports/time-reports-overview.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { CaretDownFilled } from '@ant-design/icons';
import { Button, Checkbox, Divider, Dropdown, Input, theme } from 'antd';
import { CaretDownFilled, SearchOutlined, ClearOutlined, DownOutlined, RightOutlined, FilterOutlined } from '@ant-design/icons';
import { Button, Checkbox, Divider, Dropdown, Input, theme, Typography, Badge, Collapse, Select, Space, Tooltip, Empty } from 'antd';
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import React, { useState } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { ISelectableProject } from '@/types/reporting/reporting-filters.types';
import { themeWiseColor } from '@/utils/themeWiseColor';
const { Panel } = Collapse;
const { Text } = Typography;
type GroupByOption = 'none' | 'category' | 'team' | 'status';
interface ProjectGroup {
key: string;
name: string;
color?: string;
projects: ISelectableProject[];
}
const Projects: React.FC = () => {
const dispatch = useAppDispatch();
const [checkedList, setCheckedList] = useState<string[]>([]);
const [searchText, setSearchText] = useState('');
const [selectAll, setSelectAll] = useState(true);
const [groupBy, setGroupBy] = useState<GroupByOption>('none');
const [showSelectedOnly, setShowSelectedOnly] = useState(false);
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
const { t } = useTranslation('time-report');
const [dropdownVisible, setDropdownVisible] = useState(false);
const { projects, loadingProjects } = useAppSelector(state => state.timeReportsOverviewReducer);
const { token } = theme.useToken();
const themeMode = useAppSelector(state => state.themeReducer.mode);
// Filter items based on search text
const filteredItems = projects.filter(item =>
item.name?.toLowerCase().includes(searchText.toLowerCase())
// Theme-aware color utilities
const getThemeAwareColor = useCallback((lightColor: string, darkColor: string) => {
return themeWiseColor(lightColor, darkColor, themeMode);
}, [themeMode]);
// Enhanced color processing for project/group colors
const processColor = useCallback((color: string | undefined, fallback?: string) => {
if (!color) return fallback || token.colorPrimary;
// If it's a hex color, ensure it has good contrast in both themes
if (color.startsWith('#')) {
// For dark mode, lighten dark colors and darken light colors for better visibility
if (themeMode === 'dark') {
// Simple brightness adjustment for dark mode
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);
// Calculate brightness (0-255)
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
// If color is too dark in dark mode, lighten it
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 {
// For light mode, ensure colors aren't too light
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 color is too light in light mode, darken it
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;
}, [themeMode, token.colorPrimary]);
// Memoized filtered projects
const filteredProjects = useMemo(() => {
let filtered = projects.filter(item =>
item.name?.toLowerCase().includes(searchText.toLowerCase())
);
if (showSelectedOnly) {
filtered = filtered.filter(item => item.selected);
}
return filtered;
}, [projects, searchText, showSelectedOnly]);
// Memoized grouped projects
const groupedProjects = useMemo(() => {
if (groupBy === 'none') {
return [{
key: 'all',
name: t('projects'),
projects: filteredProjects
}];
}
const groups: { [key: string]: ProjectGroup } = {};
filteredProjects.forEach(project => {
let groupKey: string;
let groupName: string;
let groupColor: string | undefined;
switch (groupBy) {
case 'category':
groupKey = (project as any).category_id || 'uncategorized';
groupName = (project as any).category_name || t('noCategory');
groupColor = (project as any).category_color;
break;
case 'team':
groupKey = (project as any).team_id || 'no-team';
groupName = (project as any).team_name || t('ungrouped');
groupColor = (project as any).team_color;
break;
case 'status':
groupKey = (project as any).status_id || 'no-status';
groupName = (project as any).status_name || t('ungrouped');
groupColor = (project as any).status_color;
break;
default:
groupKey = 'all';
groupName = t('projects');
}
if (!groups[groupKey]) {
groups[groupKey] = {
key: groupKey,
name: groupName,
color: processColor(groupColor),
projects: []
};
}
groups[groupKey].projects.push(project);
});
return Object.values(groups).sort((a, b) => a.name.localeCompare(b.name));
}, [filteredProjects, groupBy, t, processColor]);
// Selected projects count
const selectedCount = useMemo(() =>
projects.filter(p => p.selected).length,
[projects]
);
const allSelected = useMemo(() =>
filteredProjects.length > 0 && filteredProjects.every(p => p.selected),
[filteredProjects]
);
const indeterminate = useMemo(() =>
filteredProjects.some(p => p.selected) && !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
const handleCheckboxChange = (key: string, checked: boolean) => {
const handleCheckboxChange = useCallback((key: string, checked: boolean) => {
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
};
}, [dispatch]);
// Handle "Select All" checkbox change
const handleSelectAllChange = (e: CheckboxChangeEvent) => {
const handleSelectAllChange = useCallback((e: CheckboxChangeEvent) => {
const isChecked = e.target.checked;
setSelectAll(isChecked);
dispatch(setSelectOrDeselectAllProjects(isChecked));
}, [dispatch]);
// Clear search
const clearSearch = useCallback(() => {
setSearchText('');
}, []);
// Toggle group expansion
const toggleGroupExpansion = useCallback((groupKey: string) => {
setExpandedGroups(prev =>
prev.includes(groupKey)
? prev.filter(key => key !== groupKey)
: [...prev, groupKey]
);
}, []);
// Expand/Collapse all groups
const toggleAllGroups = useCallback((expand: boolean) => {
if (expand) {
setExpandedGroups(groupedProjects.map(g => g.key));
} else {
setExpandedGroups([]);
}
}, [groupedProjects]);
// Render project group
const renderProjectGroup = (group: ProjectGroup) => {
const isExpanded = expandedGroups.includes(group.key) || groupBy === 'none';
const groupSelectedCount = group.projects.filter(p => p.selected).length;
return (
<div key={group.key} style={{ marginBottom: '8px' }}>
{groupBy !== 'none' && (
<div
style={{
...dropdownStyles.groupHeader,
backgroundColor: isExpanded
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
: dropdownStyles.groupHeader.backgroundColor
}}
onClick={() => toggleGroupExpansion(group.key)}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary);
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorder, token.colorBorderSecondary);
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = isExpanded
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
: dropdownStyles.groupHeader.backgroundColor;
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorderSecondary, token.colorBorder);
}}
>
<Space>
{isExpanded ? (
<DownOutlined style={dropdownStyles.expandedToggleIcon} />
) : (
<RightOutlined style={dropdownStyles.toggleIcon} />
)}
<div style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: group.color || processColor(undefined, token.colorPrimary),
flexShrink: 0,
border: `1px solid ${getThemeAwareColor('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.2)')}`
}} />
<Text strong style={{
color: getThemeAwareColor(token.colorText, token.colorTextBase)
}}>
{group.name}
</Text>
<Badge
count={groupSelectedCount}
size="small"
style={{
backgroundColor: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
color: getThemeAwareColor('#fff', token.colorTextLightSolid)
}}
/>
</Space>
</div>
)}
{isExpanded && (
<div style={{ paddingLeft: groupBy !== 'none' ? '24px' : '0' }}>
{group.projects.map(project => (
<div
key={project.id}
style={dropdownStyles.projectItem}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary);
e.currentTarget.style.borderColor = getThemeAwareColor(token.colorBorderSecondary, token.colorBorder);
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.borderColor = 'transparent';
}}
>
<Checkbox
onClick={e => e.stopPropagation()}
checked={project.selected}
onChange={e => handleCheckboxChange(project.id || '', e.target.checked)}
>
<Space>
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: processColor((project as any).color_code, token.colorPrimary),
flexShrink: 0,
border: `1px solid ${getThemeAwareColor('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.2)')}`
}} />
<Text style={{
color: getThemeAwareColor(token.colorText, token.colorTextBase)
}}>
{project.name}
</Text>
</Space>
</Checkbox>
</div>
))}
</div>
)}
</div>
);
};
return (
@@ -40,71 +367,189 @@ const Projects: React.FC = () => {
menu={undefined}
placement="bottomLeft"
trigger={['click']}
open={dropdownVisible}
dropdownRender={() => (
<div style={{
background: token.colorBgContainer,
borderRadius: token.borderRadius,
boxShadow: token.boxShadow,
padding: '4px 0',
maxHeight: '330px',
...dropdownStyles.dropdown,
padding: '8px 0',
maxHeight: '500px',
width: '400px',
display: 'flex',
flexDirection: 'column'
}}>
<div style={{ padding: '8px', flexShrink: 0 }}>
<Input
onClick={e => e.stopPropagation()}
placeholder={t('searchByProject')}
value={searchText}
onChange={e => setSearchText(e.target.value)}
/>
{/* Header with search and controls */}
<div style={{ padding: '8px 12px', flexShrink: 0 }}>
<Space direction="vertical" style={{ width: '100%' }} size="small">
{/* Search input */}
<Input
placeholder={searchPlaceholder}
value={searchText}
onChange={e => setSearchText(e.target.value)}
prefix={<SearchOutlined style={{ color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary) }} />}
suffix={searchText && (
<Tooltip title={clearTooltip}>
<ClearOutlined
onClick={clearSearch}
style={{
cursor: 'pointer',
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
transition: 'color 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary);
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary);
}}
/>
</Tooltip>
)}
onClick={e => e.stopPropagation()}
/>
{/* Controls row */}
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Space size="small">
<Select
value={groupBy}
onChange={setGroupBy}
size="small"
style={{ width: '120px' }}
options={groupByOptions}
/>
{groupBy !== 'none' && (
<Space size="small">
<Button
type="text"
size="small"
onClick={() => toggleAllGroups(true)}
style={{
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
}}
>
{expandAllText}
</Button>
<Button
type="text"
size="small"
onClick={() => toggleAllGroups(false)}
style={{
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
}}
>
{collapseAllText}
</Button>
</Space>
)}
</Space>
<Tooltip title={showSelectedTooltip}>
<Button
type={showSelectedOnly ? 'primary' : 'text'}
size="small"
icon={<FilterOutlined />}
onClick={() => setShowSelectedOnly(!showSelectedOnly)}
style={!showSelectedOnly ? {
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary)
} : {}}
/>
</Tooltip>
</Space>
</Space>
</div>
<div style={{ padding: '0 12px', flexShrink: 0 }}>
{/* Select All checkbox */}
<div style={{ padding: '8px 12px', flexShrink: 0 }}>
<Checkbox
onClick={e => e.stopPropagation()}
onChange={handleSelectAllChange}
checked={selectAll}
checked={allSelected}
indeterminate={indeterminate}
>
{t('selectAll')}
<Space>
<Text style={{
color: getThemeAwareColor(token.colorText, token.colorTextBase)
}}>
{selectAllText}
</Text>
{selectedCount > 0 && (
<Badge
count={selectedCount}
size="small"
style={{
backgroundColor: getThemeAwareColor(token.colorSuccess, token.colorSuccessActive),
color: getThemeAwareColor('#fff', token.colorTextLightSolid)
}}
/>
)}
</Space>
</Checkbox>
</div>
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
<Divider style={{ margin: '8px 0', flexShrink: 0 }} />
{/* Projects list */}
<div style={{
overflowY: 'auto',
flex: 1
flex: 1,
padding: '0 12px'
}}>
{filteredItems.map(item => (
<div
key={item.id}
style={{
padding: '8px 12px',
cursor: 'pointer',
'&:hover': {
backgroundColor: token.colorBgTextHover
}
}}
>
<Checkbox
onClick={e => e.stopPropagation()}
checked={item.selected}
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
>
{item.name}
</Checkbox>
</div>
))}
{filteredProjects.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Text style={{
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary)
}}>
{searchText ? noProjectsText : noDataText}
</Text>
}
style={{ margin: '20px 0' }}
/>
) : (
groupedProjects.map(renderProjectGroup)
)}
</div>
{/* Footer with selection summary */}
{selectedCount > 0 && (
<>
<Divider style={{ margin: '8px 0', flexShrink: 0 }} />
<div style={{
padding: '8px 12px',
flexShrink: 0,
backgroundColor: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
borderRadius: `0 0 ${token.borderRadius}px ${token.borderRadius}px`,
borderTop: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`
}}>
<Text type="secondary" style={{
fontSize: '12px',
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary)
}}>
{selectedCount} {projectsSelectedText}
</Text>
</div>
</>
)}
</div>
)}
onOpenChange={visible => {
setDropdownVisible(visible);
if (!visible) {
setSearchText('');
setShowSelectedOnly(false);
}
}}
>
<Button loading={loadingProjects}>
{t('projects')} <CaretDownFilled />
</Button>
<Badge count={selectedCount} size="small" offset={[-5, 5]}>
<Button loading={loadingProjects}>
<Space>
{t('projects')}
<CaretDownFilled />
</Space>
</Button>
</Badge>
</Dropdown>
</div>
);

View File

@@ -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[];
}

View File

@@ -1,5 +1,10 @@
import { IProjectCategory } from '@/types/project/projectCategory.types';
import { IProjectStatus } from '@/types/project/projectStatus.types';
import { IProjectViewModel } from './projectViewModel.types';
import { NavigateFunction } from 'react-router-dom';
import { AppDispatch } from '@/app/store';
import { TablePaginationConfig } from 'antd';
import { FilterValue, SorterResult } from 'antd/es/table/interface';
export interface IProject {
id?: string;
@@ -45,3 +50,102 @@ export enum IProjectFilter {
Favourites = 'Favorites',
Archived = 'Archived',
}
export interface ProjectNameCellProps {
record: IProjectViewModel;
navigate: NavigateFunction;
}
export interface CategoryCellProps {
record: IProjectViewModel;
}
export interface ActionButtonsProps {
t: (key: string) => string;
record: IProjectViewModel;
setProjectId: (id: string) => void;
dispatch: AppDispatch;
isOwnerOrAdmin: boolean;
}
export interface TableColumnsProps {
navigate: NavigateFunction;
statuses: IProjectStatus[];
categories: IProjectCategory[];
setProjectId: (id: string) => void;
}
export interface ProjectListTableProps {
loading: boolean;
projects: IProjectViewModel[];
statuses: IProjectStatus[];
categories: IProjectCategory[];
pagination: TablePaginationConfig;
onTableChange: (
pagination: TablePaginationConfig,
filters: Record<string, FilterValue | null>,
sorter: SorterResult<IProjectViewModel> | SorterResult<IProjectViewModel>[]
) => void;
onProjectSelect: (id: string) => void;
onArchive: (id: string) => void;
}
export enum ProjectViewType {
LIST = 'list',
GROUP = 'group'
}
export enum ProjectGroupBy {
CLIENT = 'client',
CATEGORY = 'category'
}
export interface GroupedProject {
groupKey: string;
groupName: string;
projects: IProjectViewModel[];
count: number;
}
export interface ProjectViewControlsProps {
viewState: ProjectViewState;
onViewChange: (state: ProjectViewState) => void;
availableGroupByOptions?: ProjectGroupBy[];
t: (key: string) => string;
}
export interface ProjectGroupCardProps {
group: GroupedProject;
navigate: NavigateFunction;
onProjectSelect: (id: string) => void;
onArchive: (id: string) => void;
isOwnerOrAdmin: boolean;
t: (key: string) => string;
}
export interface ProjectGroupListProps {
groups: GroupedProject[];
navigate: NavigateFunction;
onProjectSelect: (id: string) => void;
onArchive: (id: string) => void;
isOwnerOrAdmin: boolean;
loading: boolean;
t: (key: string) => string;
}
export interface GroupedProject {
groupKey: string;
groupName: string;
groupColor?: string;
projects: IProjectViewModel[];
count: number;
totalProgress: number;
totalTasks: number;
averageProgress?: number;
}
export interface ProjectViewState {
mode: ProjectViewType;
groupBy: ProjectGroupBy;
lastUpdated?: string;
}

View File

@@ -7,4 +7,8 @@ export interface IProjectFilterConfig {
filter: string | null;
categories: string | null;
statuses: string | null;
current_tab: string | null;
projects_group_by: number;
current_view: number;
is_group_view: boolean;
}

View File

@@ -4,6 +4,13 @@ import { ITeam } from '@/types/teams/team.type';
export interface ISelectableProject extends IProject {
selected?: boolean;
// Additional properties for grouping
category_name?: string;
category_color?: string;
team_name?: string;
team_color?: string;
status_name?: string;
status_color?: string;
}
export interface ISelectableTeam extends ITeam {

View File

@@ -0,0 +1,47 @@
import { GroupedProject, ProjectGroupBy } from "@/types/project/project.types";
import { IProjectViewModel } from "@/types/project/projectViewModel.types";
export const groupProjects = (
projects: IProjectViewModel[],
groupBy: ProjectGroupBy
): GroupedProject[] => {
const grouped: Record<string, GroupedProject> = {};
projects?.forEach(project => {
let groupKey: string;
let groupName: string;
let groupColor: string;
switch (groupBy) {
case ProjectGroupBy.CLIENT:
groupKey = project.client_name || 'No Client';
groupName = groupKey;
groupColor = '#688';
break;
case ProjectGroupBy.CATEGORY:
default:
groupKey = project.category_name || 'Uncategorized';
groupName = groupKey;
groupColor = project.category_color || '#888';
}
if (!grouped[groupKey]) {
grouped[groupKey] = {
groupKey,
groupName,
groupColor,
projects: [],
count: 0,
totalProgress: 0,
totalTasks: 0
};
}
grouped[groupKey].projects.push(project);
grouped[groupKey].count++;
grouped[groupKey].totalProgress += project.progress || 0;
grouped[groupKey].totalTasks += project.task_count || 0;
});
return Object.values(grouped);
};