Merge pull request #159 from shancds/release/v2.0.3-kanban-handle-drag-over
Release/v2.0.3 kanban handle drag over
This commit is contained in:
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "worklenz",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
9131
worklenz-backend/package-lock.json
generated
9131
worklenz-backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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: [] }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
20
worklenz-backend/src/public/tinymce/package-lock.json
generated
Normal file
20
worklenz-backend/src/public/tinymce/package-lock.json
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,5 +28,8 @@
|
||||
"homepage": "https://www.tiny.cloud/",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tinymce/tinymce/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"tinymce": "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));
|
||||
|
||||
1533
worklenz-frontend/package-lock.json
generated
1533
worklenz-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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({
|
||||
@@ -113,6 +115,8 @@ export const store = configureStore({
|
||||
taskListCustomColumnsReducer: taskListCustomColumnsReducer,
|
||||
boardReducer: boardReducer,
|
||||
projectDrawerReducer: projectDrawerReducer,
|
||||
|
||||
projectViewReducer: projectViewReducer,
|
||||
|
||||
// Project Lookups
|
||||
projectCategoriesReducer: projectCategoriesReducer,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Avatar, Tooltip } from 'antd';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
|
||||
interface AvatarsProps {
|
||||
@@ -6,41 +7,54 @@ interface AvatarsProps {
|
||||
maxCount?: number;
|
||||
}
|
||||
|
||||
const renderAvatar = (member: InlineMember, index: number) => (
|
||||
<Tooltip
|
||||
key={member.team_member_id || index}
|
||||
title={member.end && member.names ? member.names.join(', ') : member.name}
|
||||
>
|
||||
{member.avatar_url ? (
|
||||
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
||||
</span>
|
||||
) : (
|
||||
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar
|
||||
size={28}
|
||||
key={member.team_member_id || index}
|
||||
style={{
|
||||
backgroundColor: member.color_code || '#ececec',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount }) => {
|
||||
const stopPropagation = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const renderAvatar = useCallback((member: InlineMember, index: number) => (
|
||||
<Tooltip
|
||||
key={member.team_member_id || index}
|
||||
title={member.end && member.names ? member.names.join(', ') : member.name}
|
||||
>
|
||||
{member.avatar_url ? (
|
||||
<span onClick={stopPropagation}>
|
||||
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
||||
</span>
|
||||
) : (
|
||||
<span onClick={stopPropagation}>
|
||||
<Avatar
|
||||
size={28}
|
||||
key={member.team_member_id || index}
|
||||
style={{
|
||||
backgroundColor: member.color_code || '#ececec',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
), [stopPropagation]);
|
||||
|
||||
const visibleMembers = useMemo(() => {
|
||||
return maxCount ? members.slice(0, maxCount) : members;
|
||||
}, [members, maxCount]);
|
||||
|
||||
const avatarElements = useMemo(() => {
|
||||
return visibleMembers.map((member, index) => renderAvatar(member, index));
|
||||
}, [visibleMembers, renderAvatar]);
|
||||
|
||||
const Avatars: React.FC<AvatarsProps> = ({ members, maxCount }) => {
|
||||
const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
|
||||
return (
|
||||
<div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<div onClick={stopPropagation}>
|
||||
<Avatar.Group>
|
||||
{visibleMembers.map((member, index) => renderAvatar(member, index))}
|
||||
{avatarElements}
|
||||
</Avatar.Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
Avatars.displayName = 'Avatars';
|
||||
|
||||
export default Avatars;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.priority-dropdown .ant-dropdown-menu {
|
||||
padding: 0 !important;
|
||||
margin-top: 8px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.priority-dropdown .ant-dropdown-menu-item {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.priority-dropdown-card .ant-card-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.priority-menu .ant-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Flex, Typography } from 'antd';
|
||||
import './priority-section.css';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
||||
import { DoubleLeftOutlined, MinusOutlined, PauseOutlined } from '@ant-design/icons';
|
||||
|
||||
type PrioritySectionProps = {
|
||||
task: IProjectTask;
|
||||
};
|
||||
|
||||
const PrioritySection = ({ task }: PrioritySectionProps) => {
|
||||
const [selectedPriority, setSelectedPriority] = useState<ITaskPriority | undefined>(undefined);
|
||||
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// Update selectedPriority whenever task.priority or priorityList changes
|
||||
useEffect(() => {
|
||||
if (!task.priority || !priorityList.length) {
|
||||
setSelectedPriority(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const foundPriority = priorityList.find(priority => priority.id === task.priority);
|
||||
setSelectedPriority(foundPriority);
|
||||
}, [task.priority, priorityList]);
|
||||
|
||||
const priorityIcon = useMemo(() => {
|
||||
if (!selectedPriority) return null;
|
||||
|
||||
const iconProps = {
|
||||
style: {
|
||||
color: themeMode === 'dark' ? selectedPriority.color_code_dark : selectedPriority.color_code,
|
||||
marginRight: '0.25rem',
|
||||
},
|
||||
};
|
||||
|
||||
switch (selectedPriority.name) {
|
||||
case 'Low':
|
||||
return <MinusOutlined {...iconProps} />;
|
||||
case 'Medium':
|
||||
return <PauseOutlined {...iconProps} style={{ ...iconProps.style, transform: 'rotate(90deg)' }} />;
|
||||
case 'High':
|
||||
return <DoubleLeftOutlined {...iconProps} style={{ ...iconProps.style, transform: 'rotate(90deg)' }} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [selectedPriority, themeMode]);
|
||||
|
||||
if (!task.priority || !selectedPriority) return null;
|
||||
|
||||
return (
|
||||
<Flex gap={4}>
|
||||
{priorityIcon}
|
||||
<Typography.Text
|
||||
style={{ fontWeight: 500 }}
|
||||
ellipsis={{ tooltip: task.name }}
|
||||
>
|
||||
{task.name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrioritySection;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
47
worklenz-frontend/src/features/project/project-view-slice.ts
Normal file
47
worklenz-frontend/src/features/project/project-view-slice.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')}
|
||||
@@ -264,4 +661,4 @@ const ProjectList: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectList;
|
||||
export default ProjectList;
|
||||
@@ -3,7 +3,7 @@ import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortabl
|
||||
import BoardSectionCard from './board-section-card/board-section-card';
|
||||
import BoardCreateSectionCard from './board-section-card/board-create-section-card';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { setTaskAssignee, setTaskEndDate } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { fetchTaskAssignees } from '@/features/taskAttributes/taskMemberSlice';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
@@ -113,4 +113,4 @@ const BoardSectionCardContainer = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSectionCardContainer;
|
||||
export default React.memo(BoardSectionCardContainer);
|
||||
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
evt_project_task_list_context_menu_delete,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import PrioritySection from '@/components/board/taskCard/priority-section/priority-section';
|
||||
|
||||
interface IBoardViewTaskCardProps {
|
||||
task: IProjectTask;
|
||||
@@ -65,7 +67,7 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('kanban-board');
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
const [isSubTaskShow, setIsSubTaskShow] = useState(false);
|
||||
@@ -234,42 +236,11 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
||||
},
|
||||
], [t, handleAssignToMe, handleArchive, handleDelete, updatingAssignToMe]);
|
||||
|
||||
const priorityIcon = useMemo(() => {
|
||||
if (task.priority_value === 0) {
|
||||
return (
|
||||
<MinusOutlined
|
||||
style={{
|
||||
color: '#52c41a',
|
||||
marginRight: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (task.priority_value === 1) {
|
||||
return (
|
||||
<PauseOutlined
|
||||
style={{
|
||||
color: '#faad14',
|
||||
transform: 'rotate(90deg)',
|
||||
marginRight: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<DoubleRightOutlined
|
||||
style={{
|
||||
color: '#f5222d',
|
||||
transform: 'rotate(-90deg)',
|
||||
marginRight: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [task.priority_value]);
|
||||
|
||||
|
||||
const renderLabels = useMemo(() => {
|
||||
if (!task?.labels?.length) return null;
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{task.labels.slice(0, 2).map((label: any) => (
|
||||
@@ -313,20 +284,12 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
||||
</Flex>
|
||||
|
||||
<Tooltip title={` ${task?.completed_count} / ${task?.total_tasks_count}`}>
|
||||
<Progress type="circle" percent={task?.complete_ratio } size={26} strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7} />
|
||||
<Progress type="circle" percent={task?.complete_ratio} size={26} strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
{/* Action Icons */}
|
||||
<Flex gap={4}>
|
||||
{priorityIcon}
|
||||
<Typography.Text
|
||||
style={{ fontWeight: 500 }}
|
||||
ellipsis={{ tooltip: task.name }}
|
||||
>
|
||||
{task.name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<PrioritySection task={task} />
|
||||
|
||||
<Flex vertical gap={8}>
|
||||
<Flex
|
||||
@@ -376,7 +339,7 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
||||
<Skeleton active paragraph={{ rows: 2 }} title={false} style={{ marginTop: 8 }} />
|
||||
</List.Item>
|
||||
)}
|
||||
|
||||
|
||||
{!task.sub_tasks_loading && task?.sub_tasks &&
|
||||
task?.sub_tasks.map((subtask: any) => (
|
||||
<BoardSubTaskCard key={subtask.id} subtask={subtask} sectionId={sectionId} />
|
||||
|
||||
@@ -245,7 +245,6 @@ const ProjectViewBoard = () => {
|
||||
if (
|
||||
activeGroupId &&
|
||||
overGroupId &&
|
||||
activeGroupId !== overGroupId &&
|
||||
active.data.current?.type === 'task'
|
||||
) {
|
||||
// Find the target index in the over group
|
||||
@@ -260,7 +259,6 @@ const ProjectViewBoard = () => {
|
||||
targetIndex = targetGroup.tasks.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Use debounced move task to prevent rapid updates
|
||||
debouncedMoveTask(
|
||||
activeId as string,
|
||||
@@ -342,7 +340,6 @@ const ProjectViewBoard = () => {
|
||||
|
||||
// Find indices
|
||||
let fromIndex = sourceGroup.tasks.findIndex(t => t.id === task.id);
|
||||
|
||||
// Handle case where task is not found in source group (might have been moved already in UI)
|
||||
if (fromIndex === -1) {
|
||||
logger.info('Task not found in source group. Using task sort_order from task object.');
|
||||
@@ -379,7 +376,7 @@ const ProjectViewBoard = () => {
|
||||
};
|
||||
|
||||
// logger.error('Emitting socket event with payload (task not found in source):', body);
|
||||
|
||||
|
||||
// Emit socket event
|
||||
if (socket) {
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
||||
@@ -416,7 +413,6 @@ const ProjectViewBoard = () => {
|
||||
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
|
||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||
-1;
|
||||
|
||||
// Prepare socket event payload
|
||||
const body = {
|
||||
project_id: projectId,
|
||||
@@ -429,7 +425,6 @@ const ProjectViewBoard = () => {
|
||||
task,
|
||||
team_id: currentSession?.team_id
|
||||
};
|
||||
|
||||
// Emit socket event
|
||||
if (socket) {
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
||||
@@ -443,7 +438,6 @@ const ProjectViewBoard = () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track analytics event
|
||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useCallback, useMemo } from 'react';
|
||||
import { Button, Card, Checkbox, Flex, Typography } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
@@ -25,29 +25,37 @@ const OverviewReports = () => {
|
||||
trackMixpanelEvent(evt_reporting_overview);
|
||||
}, [trackMixpanelEvent]);
|
||||
|
||||
const handleArchiveToggle = () => {
|
||||
const handleArchiveToggle = useCallback(() => {
|
||||
dispatch(toggleIncludeArchived());
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// Memoize the header children to prevent unnecessary re-renders
|
||||
const headerChildren = useMemo(() => (
|
||||
<Button type="text" onClick={handleArchiveToggle}>
|
||||
<Checkbox checked={includeArchivedProjects} />
|
||||
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
|
||||
</Button>
|
||||
), [handleArchiveToggle, includeArchivedProjects, t]);
|
||||
|
||||
// Memoize the teams text to prevent unnecessary re-renders
|
||||
const teamsText = useMemo(() => (
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
{t('teamsText')}
|
||||
</Typography.Text>
|
||||
), [t]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<CustomPageHeader
|
||||
title={t('overviewTitle')}
|
||||
children={
|
||||
<Button type="text" onClick={handleArchiveToggle}>
|
||||
<Checkbox checked={includeArchivedProjects} />
|
||||
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
|
||||
</Button>
|
||||
}
|
||||
children={headerChildren}
|
||||
/>
|
||||
|
||||
<OverviewStats />
|
||||
|
||||
<Card>
|
||||
<Flex vertical gap={12}>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
{t('teamsText')}
|
||||
</Typography.Text>
|
||||
{teamsText}
|
||||
<OverviewReportsTable />
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
@@ -1,32 +1,151 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Card, Flex, Typography } from 'antd';
|
||||
import { Card, Flex, Typography, theme } from 'antd';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
type InsightCardProps = {
|
||||
icon: ReactNode;
|
||||
interface InsightCardProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
children: React.ReactNode;
|
||||
loading?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const OverviewStatCard = React.memo(({ icon, title, children, loading = false }: InsightCardProps) => {
|
||||
const { token } = theme.useToken();
|
||||
// Better dark mode detection using multiple token properties
|
||||
const isDarkMode = token.colorBgContainer === '#1f1f1f' ||
|
||||
token.colorBgBase === '#141414' ||
|
||||
token.colorBgElevated === '#1f1f1f' ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
document.body.classList.contains('dark');
|
||||
|
||||
// Memoize enhanced card styles with dark mode support
|
||||
const cardStyles = useMemo(() => ({
|
||||
body: {
|
||||
padding: '24px',
|
||||
background: isDarkMode
|
||||
? '#1f1f1f !important'
|
||||
: '#ffffff !important',
|
||||
}
|
||||
}), [isDarkMode]);
|
||||
|
||||
// Memoize card container styles with dark mode support
|
||||
const cardContainerStyle = useMemo(() => ({
|
||||
width: '100%',
|
||||
borderRadius: '0px',
|
||||
border: isDarkMode
|
||||
? '1px solid #303030'
|
||||
: '1px solid #f0f0f0',
|
||||
boxShadow: isDarkMode
|
||||
? '0 2px 8px rgba(0, 0, 0, 0.3)'
|
||||
: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative' as const,
|
||||
cursor: 'default',
|
||||
backgroundColor: isDarkMode ? '#1f1f1f !important' : '#ffffff !important',
|
||||
}), [isDarkMode]);
|
||||
|
||||
// Memoize icon container styles with dark mode support
|
||||
const iconContainerStyle = useMemo(() => ({
|
||||
padding: '12px',
|
||||
borderRadius: '0px',
|
||||
background: isDarkMode
|
||||
? '#2a2a2a'
|
||||
: '#f8f9ff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '64px',
|
||||
minHeight: '64px',
|
||||
boxShadow: isDarkMode
|
||||
? '0 2px 4px rgba(24, 144, 255, 0.2)'
|
||||
: '0 2px 4px rgba(24, 144, 255, 0.1)',
|
||||
border: isDarkMode
|
||||
? '1px solid #404040'
|
||||
: '1px solid rgba(24, 144, 255, 0.1)',
|
||||
}), [isDarkMode]);
|
||||
|
||||
// Memoize title styles with dark mode support
|
||||
const titleStyle = useMemo(() => ({
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: isDarkMode ? '#ffffff !important' : '#262626 !important',
|
||||
marginBottom: '8px',
|
||||
lineHeight: '1.4',
|
||||
}), [isDarkMode]);
|
||||
|
||||
// Memoize decorative element styles with dark mode support
|
||||
const decorativeStyle = useMemo(() => ({
|
||||
position: 'absolute' as const,
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
background: isDarkMode
|
||||
? 'linear-gradient(135deg, rgba(24, 144, 255, 0.15) 0%, rgba(24, 144, 255, 0.08) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(24, 144, 255, 0.05) 0%, rgba(24, 144, 255, 0.02) 100%)',
|
||||
opacity: isDarkMode ? 0.8 : 0.6,
|
||||
clipPath: 'polygon(100% 0%, 0% 100%, 100% 100%)',
|
||||
}), [isDarkMode]);
|
||||
|
||||
const OverviewStatCard = ({ icon, title, children, loading = false }: InsightCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
style={{ width: '100%' }}
|
||||
styles={{ body: { paddingInline: 16 } }}
|
||||
loading={loading}
|
||||
<div
|
||||
className={`overview-stat-card ${isDarkMode ? 'dark-mode' : 'light-mode'}`}
|
||||
style={{
|
||||
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
|
||||
border: isDarkMode ? '1px solid #303030' : '1px solid #f0f0f0',
|
||||
borderRadius: '0px',
|
||||
boxShadow: isDarkMode
|
||||
? '0 2px 8px rgba(0, 0, 0, 0.3)'
|
||||
: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
cursor: 'default',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Flex gap={16} align="flex-start">
|
||||
{icon}
|
||||
<Card
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '0px',
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '24px',
|
||||
backgroundColor: 'transparent',
|
||||
}
|
||||
}}
|
||||
loading={loading}
|
||||
>
|
||||
<Flex gap={20} align="flex-start">
|
||||
<div style={iconContainerStyle}>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<Flex vertical gap={12}>
|
||||
<Typography.Text style={{ fontSize: 16 }}>{title}</Typography.Text>
|
||||
<Flex vertical gap={8} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text style={titleStyle}>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
|
||||
<>{children}</>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
{/* Decorative element */}
|
||||
<div style={decorativeStyle} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
OverviewStatCard.displayName = 'OverviewStatCard';
|
||||
|
||||
export default OverviewStatCard;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Flex, Typography } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Flex, Typography, theme } from 'antd';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import OverviewStatCard from './overview-stat-card';
|
||||
import { BankOutlined, FileOutlined, UsergroupAddOutlined } from '@ant-design/icons';
|
||||
import { colors } from '@/styles/colors';
|
||||
@@ -12,11 +12,12 @@ const OverviewStats = () => {
|
||||
const [stats, setStats] = useState<IRPTOverviewStatistics>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useTranslation('reporting-overview');
|
||||
const { token } = theme.useToken();
|
||||
const includeArchivedProjects = useAppSelector(
|
||||
state => state.reportingReducer.includeArchivedProjects
|
||||
);
|
||||
|
||||
const getOverviewStats = async () => {
|
||||
const getOverviewStats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { done, body } =
|
||||
@@ -29,17 +30,17 @@ const OverviewStats = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [includeArchivedProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
getOverviewStats();
|
||||
}, [includeArchivedProjects]);
|
||||
}, [getOverviewStats]);
|
||||
|
||||
const renderStatText = (count: number = 0, singularKey: string, pluralKey: string) => {
|
||||
const renderStatText = useCallback((count: number = 0, singularKey: string, pluralKey: string) => {
|
||||
return `${count} ${count === 1 ? t(singularKey) : t(pluralKey)}`;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const renderStatCard = (
|
||||
const renderStatCard = useCallback((
|
||||
icon: React.ReactNode,
|
||||
mainCount: number = 0,
|
||||
mainKey: string,
|
||||
@@ -52,81 +53,127 @@ const OverviewStats = () => {
|
||||
>
|
||||
<Flex vertical>
|
||||
{stats.map((stat, index) => (
|
||||
<Typography.Text key={index} type={stat.type}>
|
||||
<Typography.Text
|
||||
key={index}
|
||||
type={stat.type}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
color: stat.type === 'danger'
|
||||
? '#ff4d4f'
|
||||
: stat.type === 'secondary'
|
||||
? token.colorTextSecondary
|
||||
: token.colorText
|
||||
}}
|
||||
>
|
||||
{stat.text}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</Flex>
|
||||
</OverviewStatCard>
|
||||
);
|
||||
), [renderStatText, loading, token]);
|
||||
|
||||
// Memoize team stats to prevent unnecessary recalculations
|
||||
const teamStats = useMemo(() => [
|
||||
{
|
||||
text: renderStatText(stats?.teams?.projects, 'projectCount', 'projectCountPlural'),
|
||||
type: 'secondary' as const,
|
||||
},
|
||||
{
|
||||
text: renderStatText(stats?.teams?.members, 'memberCount', 'memberCountPlural'),
|
||||
type: 'secondary' as const,
|
||||
},
|
||||
], [stats?.teams?.projects, stats?.teams?.members, renderStatText]);
|
||||
|
||||
// Memoize project stats to prevent unnecessary recalculations
|
||||
const projectStats = useMemo(() => [
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.projects?.active,
|
||||
'activeProjectCount',
|
||||
'activeProjectCountPlural'
|
||||
),
|
||||
type: 'secondary' as const,
|
||||
},
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.projects?.overdue,
|
||||
'overdueProjectCount',
|
||||
'overdueProjectCountPlural'
|
||||
),
|
||||
type: 'danger' as const,
|
||||
},
|
||||
], [stats?.projects?.active, stats?.projects?.overdue, renderStatText]);
|
||||
|
||||
// Memoize member stats to prevent unnecessary recalculations
|
||||
const memberStats = useMemo(() => [
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.members?.unassigned,
|
||||
'unassignedMemberCount',
|
||||
'unassignedMemberCountPlural'
|
||||
),
|
||||
type: 'secondary' as const,
|
||||
},
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.members?.overdue,
|
||||
'memberWithOverdueTaskCount',
|
||||
'memberWithOverdueTaskCountPlural'
|
||||
),
|
||||
type: 'danger' as const,
|
||||
},
|
||||
], [stats?.members?.unassigned, stats?.members?.overdue, renderStatText]);
|
||||
|
||||
// Memoize icons with enhanced styling for better visibility
|
||||
const teamIcon = useMemo(() => (
|
||||
<BankOutlined style={{
|
||||
color: colors.skyBlue,
|
||||
fontSize: 42,
|
||||
filter: 'drop-shadow(0 2px 4px rgba(24, 144, 255, 0.3))'
|
||||
}} />
|
||||
), []);
|
||||
|
||||
const projectIcon = useMemo(() => (
|
||||
<FileOutlined style={{
|
||||
color: colors.limeGreen,
|
||||
fontSize: 42,
|
||||
filter: 'drop-shadow(0 2px 4px rgba(82, 196, 26, 0.3))'
|
||||
}} />
|
||||
), []);
|
||||
|
||||
const memberIcon = useMemo(() => (
|
||||
<UsergroupAddOutlined style={{
|
||||
color: colors.lightGray,
|
||||
fontSize: 42,
|
||||
filter: 'drop-shadow(0 2px 4px rgba(112, 112, 112, 0.3))'
|
||||
}} />
|
||||
), []);
|
||||
|
||||
return (
|
||||
<Flex gap={24}>
|
||||
{renderStatCard(
|
||||
<BankOutlined style={{ color: colors.skyBlue, fontSize: 42 }} />,
|
||||
teamIcon,
|
||||
stats?.teams?.count,
|
||||
'teamCount',
|
||||
[
|
||||
{
|
||||
text: renderStatText(stats?.teams?.projects, 'projectCount', 'projectCountPlural'),
|
||||
type: 'secondary',
|
||||
},
|
||||
{
|
||||
text: renderStatText(stats?.teams?.members, 'memberCount', 'memberCountPlural'),
|
||||
type: 'secondary',
|
||||
},
|
||||
]
|
||||
teamStats
|
||||
)}
|
||||
|
||||
{renderStatCard(
|
||||
<FileOutlined style={{ color: colors.limeGreen, fontSize: 42 }} />,
|
||||
projectIcon,
|
||||
stats?.projects?.count,
|
||||
'projectCount',
|
||||
[
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.projects?.active,
|
||||
'activeProjectCount',
|
||||
'activeProjectCountPlural'
|
||||
),
|
||||
type: 'secondary',
|
||||
},
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.projects?.overdue,
|
||||
'overdueProjectCount',
|
||||
'overdueProjectCountPlural'
|
||||
),
|
||||
type: 'danger',
|
||||
},
|
||||
]
|
||||
projectStats
|
||||
)}
|
||||
|
||||
{renderStatCard(
|
||||
<UsergroupAddOutlined style={{ color: colors.lightGray, fontSize: 42 }} />,
|
||||
memberIcon,
|
||||
stats?.members?.count,
|
||||
'memberCount',
|
||||
[
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.members?.unassigned,
|
||||
'unassignedMemberCount',
|
||||
'unassignedMemberCountPlural'
|
||||
),
|
||||
type: 'secondary',
|
||||
},
|
||||
{
|
||||
text: renderStatText(
|
||||
stats?.members?.overdue,
|
||||
'memberWithOverdueTaskCount',
|
||||
'memberWithOverdueTaskCountPlural'
|
||||
),
|
||||
type: 'danger',
|
||||
},
|
||||
]
|
||||
memberStats
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverviewStats;
|
||||
export default React.memo(OverviewStats);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { memo, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { ConfigProvider, Table, TableColumnsType } from 'antd';
|
||||
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
||||
import CustomTableTitle from '../../../../components/CustomTableTitle';
|
||||
@@ -11,7 +11,7 @@ import Avatars from '@/components/avatars/avatars';
|
||||
import OverviewTeamInfoDrawer from '@/components/reporting/drawers/overview-team-info/overview-team-info-drawer';
|
||||
import { toggleOverViewTeamDrawer } from '@/features/reporting/reporting.slice';
|
||||
|
||||
const OverviewReportsTable = () => {
|
||||
const OverviewReportsTable = memo(() => {
|
||||
const { t } = useTranslation('reporting-overview');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -22,7 +22,7 @@ const OverviewReportsTable = () => {
|
||||
const [teams, setTeams] = useState<IRPTTeam[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getTeams = async () => {
|
||||
const getTeams = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { done, body } = await reportingApiService.getOverviewTeams(includeArchivedProjects);
|
||||
@@ -34,18 +34,19 @@ const OverviewReportsTable = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [includeArchivedProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
getTeams();
|
||||
}, [includeArchivedProjects]);
|
||||
}, [getTeams]);
|
||||
|
||||
const handleDrawerOpen = (team: IRPTTeam) => {
|
||||
const handleDrawerOpen = useCallback((team: IRPTTeam) => {
|
||||
setSelectedTeam(team);
|
||||
dispatch(toggleOverViewTeamDrawer());
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const columns: TableColumnsType = [
|
||||
// Memoize table columns to prevent recreation on every render
|
||||
const columns: TableColumnsType<IRPTTeam> = useMemo(() => [
|
||||
{
|
||||
key: 'name',
|
||||
title: <CustomTableTitle title={t('nameColumn')} />,
|
||||
@@ -61,39 +62,45 @@ const OverviewReportsTable = () => {
|
||||
{
|
||||
key: 'members',
|
||||
title: <CustomTableTitle title={t('membersColumn')} />,
|
||||
render: record => <Avatars members={record.members} maxCount={3} />,
|
||||
render: (record: IRPTTeam) => <Avatars members={record.members} maxCount={3} />,
|
||||
},
|
||||
];
|
||||
], [t]);
|
||||
|
||||
// Memoize table configuration
|
||||
const tableConfig = useMemo(() => ({
|
||||
theme: {
|
||||
components: {
|
||||
Table: {
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
}), []);
|
||||
|
||||
// Memoize row props generator
|
||||
const getRowProps = useCallback((record: IRPTTeam) => ({
|
||||
onClick: () => handleDrawerOpen(record),
|
||||
style: { height: 48, cursor: 'pointer' },
|
||||
className: 'group even:bg-[#4e4e4e10]',
|
||||
}), [handleDrawerOpen]);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Table: {
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 10,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ConfigProvider {...tableConfig}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={teams}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey={record => record.id}
|
||||
loading={loading}
|
||||
onRow={record => {
|
||||
return {
|
||||
onClick: () => handleDrawerOpen(record as IRPTTeam),
|
||||
style: { height: 48, cursor: 'pointer' },
|
||||
className: 'group even:bg-[#4e4e4e10]',
|
||||
};
|
||||
}}
|
||||
onRow={getRowProps}
|
||||
/>
|
||||
|
||||
<OverviewTeamInfoDrawer team={selectedTeam} />
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default memo(OverviewReportsTable);
|
||||
OverviewReportsTable.displayName = 'OverviewReportsTable';
|
||||
|
||||
export default OverviewReportsTable;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Flex } from 'antd';
|
||||
import { useMemo, useCallback, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ProjectStatusFilterDropdown from './project-status-filter-dropdown';
|
||||
import ProjectHealthFilterDropdown from './project-health-filter-dropdown';
|
||||
@@ -15,26 +16,39 @@ const ProjectsReportsFilters = () => {
|
||||
const { t } = useTranslation('reporting-projects-filters');
|
||||
const { searchQuery } = useAppSelector(state => state.projectReportsReducer);
|
||||
|
||||
// Memoize the search query handler to prevent recreation on every render
|
||||
const handleSearchQueryChange = useCallback((text: string) => {
|
||||
dispatch(setSearchQuery(text));
|
||||
}, [dispatch]);
|
||||
|
||||
// Memoize the filter dropdowns container to prevent recreation on every render
|
||||
const filterDropdowns = useMemo(() => (
|
||||
<Flex gap={8} wrap={'wrap'}>
|
||||
<ProjectStatusFilterDropdown />
|
||||
<ProjectHealthFilterDropdown />
|
||||
<ProjectCategoriesFilterDropdown />
|
||||
<ProjectManagersFilterDropdown />
|
||||
</Flex>
|
||||
), []);
|
||||
|
||||
// Memoize the right side controls to prevent recreation on every render
|
||||
const rightControls = useMemo(() => (
|
||||
<Flex gap={12}>
|
||||
<ProjectTableShowFieldsDropdown />
|
||||
<CustomSearchbar
|
||||
placeholderText={t('searchByNamePlaceholder')}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={handleSearchQueryChange}
|
||||
/>
|
||||
</Flex>
|
||||
), [t, searchQuery, handleSearchQueryChange]);
|
||||
|
||||
return (
|
||||
<Flex gap={8} align="center" justify="space-between">
|
||||
<Flex gap={8} wrap={'wrap'}>
|
||||
<ProjectStatusFilterDropdown />
|
||||
<ProjectHealthFilterDropdown />
|
||||
<ProjectCategoriesFilterDropdown />
|
||||
<ProjectManagersFilterDropdown />
|
||||
</Flex>
|
||||
|
||||
<Flex gap={12}>
|
||||
<ProjectTableShowFieldsDropdown />
|
||||
|
||||
<CustomSearchbar
|
||||
placeholderText={t('searchByNamePlaceholder')}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={text => dispatch(setSearchQuery(text))}
|
||||
/>
|
||||
</Flex>
|
||||
{filterDropdowns}
|
||||
{rightControls}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsReportsFilters;
|
||||
export default memo(ProjectsReportsFilters);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState, useMemo, useCallback, memo } from 'react';
|
||||
import { Button, ConfigProvider, Flex, PaginationProps, Table, TableColumnsType } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -63,10 +63,11 @@ const ProjectsReportsTable = () => {
|
||||
|
||||
const columnsVisibility = useAppSelector(state => state.projectReportsTableColumnsReducer);
|
||||
|
||||
const handleDrawerOpen = (record: IRPTProject) => {
|
||||
// Memoize the drawer open handler to prevent recreation on every render
|
||||
const handleDrawerOpen = useCallback((record: IRPTProject) => {
|
||||
setSelectedProject(record);
|
||||
dispatch(toggleProjectReportsDrawer());
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const columns: TableColumnsType<IRPTProject> = useMemo(
|
||||
() => [
|
||||
@@ -231,7 +232,7 @@ const ProjectsReportsTable = () => {
|
||||
width: 200,
|
||||
},
|
||||
],
|
||||
[t, order]
|
||||
[t, order, handleDrawerOpen]
|
||||
);
|
||||
|
||||
// filter columns based on the `hidden` state from Redux
|
||||
@@ -240,12 +241,13 @@ const ProjectsReportsTable = () => {
|
||||
[columns, columnsVisibility]
|
||||
);
|
||||
|
||||
const handleTableChange = (pagination: PaginationProps, filters: any, sorter: any) => {
|
||||
// Memoize the table change handler to prevent recreation on every render
|
||||
const handleTableChange = useCallback((pagination: PaginationProps, filters: any, sorter: any) => {
|
||||
if (sorter.order) dispatch(setOrder(sorter.order));
|
||||
if (sorter.field) dispatch(setField(sorter.field));
|
||||
dispatch(setIndex(pagination.current));
|
||||
dispatch(setPageSize(pagination.pageSize));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) dispatch(fetchProjectData());
|
||||
@@ -268,7 +270,7 @@ const ProjectsReportsTable = () => {
|
||||
return () => {
|
||||
dispatch(resetProjectReports());
|
||||
};
|
||||
}, []);
|
||||
}, [dispatch]);
|
||||
|
||||
const tableRowProps = useMemo(
|
||||
() => ({
|
||||
@@ -292,27 +294,39 @@ const ProjectsReportsTable = () => {
|
||||
[]
|
||||
);
|
||||
|
||||
// Memoize pagination configuration to prevent recreation on every render
|
||||
const paginationConfig = useMemo(() => ({
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 10,
|
||||
total: total,
|
||||
current: index,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
}), [total, index]);
|
||||
|
||||
// Memoize scroll configuration to prevent recreation on every render
|
||||
const scrollConfig = useMemo(() => ({ x: 'max-content' }), []);
|
||||
|
||||
// Memoize row key function to prevent recreation on every render
|
||||
const getRowKey = useCallback((record: IRPTProject) => record.id, []);
|
||||
|
||||
// Memoize onRow function to prevent recreation on every render
|
||||
const getRowProps = useCallback(() => tableRowProps, [tableRowProps]);
|
||||
|
||||
return (
|
||||
<ConfigProvider {...tableConfig}>
|
||||
<Table
|
||||
columns={visibleColumns}
|
||||
dataSource={projectList}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 10,
|
||||
total: total,
|
||||
current: index,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={paginationConfig}
|
||||
scroll={scrollConfig}
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
rowKey={record => record.id}
|
||||
onRow={() => tableRowProps}
|
||||
rowKey={getRowKey}
|
||||
onRow={getRowProps}
|
||||
/>
|
||||
{createPortal(<ProjectReportsDrawer selectedProject={selectedProject} />, document.body)}
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsReportsTable;
|
||||
export default memo(ProjectsReportsTable);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button, Card, Checkbox, Dropdown, Flex, Space, Typography } from 'antd';
|
||||
import { useMemo, useCallback, memo } from 'react';
|
||||
import CustomPageHeader from '@/pages/reporting/page-header/custom-page-header';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
import ProjectReportsTable from './projects-reports-table/projects-reports-table';
|
||||
@@ -20,40 +21,60 @@ const ProjectsReports = () => {
|
||||
|
||||
const { total, archived } = useAppSelector(state => state.projectReportsReducer);
|
||||
|
||||
const handleExcelExport = () => {
|
||||
// Memoize the title to prevent recalculation on every render
|
||||
const pageTitle = useMemo(() => {
|
||||
return `${total === 1 ? `${total} ${t('projectCount')}` : `${total} ${t('projectCountPlural')}`} `;
|
||||
}, [total, t]);
|
||||
|
||||
// Memoize the Excel export handler to prevent recreation on every render
|
||||
const handleExcelExport = useCallback(() => {
|
||||
if (currentSession?.team_name) {
|
||||
reportingExportApiService.exportProjects(currentSession.team_name);
|
||||
}
|
||||
};
|
||||
}, [currentSession?.team_name]);
|
||||
|
||||
// Memoize the archived checkbox handler to prevent recreation on every render
|
||||
const handleArchivedChange = useCallback(() => {
|
||||
dispatch(setArchived(!archived));
|
||||
}, [dispatch, archived]);
|
||||
|
||||
// Memoize the dropdown menu items to prevent recreation on every render
|
||||
const dropdownMenuItems = useMemo(() => [
|
||||
{ key: '1', label: t('excelButton'), onClick: handleExcelExport }
|
||||
], [t, handleExcelExport]);
|
||||
|
||||
// Memoize the header children to prevent recreation on every render
|
||||
const headerChildren = useMemo(() => (
|
||||
<Space>
|
||||
<Button>
|
||||
<Checkbox checked={archived} onChange={handleArchivedChange}>
|
||||
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
|
||||
</Checkbox>
|
||||
</Button>
|
||||
|
||||
<Dropdown menu={{ items: dropdownMenuItems }}>
|
||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||
{t('exportButton')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
), [archived, handleArchivedChange, t, dropdownMenuItems]);
|
||||
|
||||
// Memoize the card title to prevent recreation on every render
|
||||
const cardTitle = useMemo(() => <ProjectsReportsFilters />, []);
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<CustomPageHeader
|
||||
title={`${total === 1 ? `${total} ${t('projectCount')}` : `${total} ${t('projectCountPlural')}`} `}
|
||||
children={
|
||||
<Space>
|
||||
<Button>
|
||||
<Checkbox checked={archived} onChange={() => dispatch(setArchived(!archived))}>
|
||||
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
|
||||
</Checkbox>
|
||||
</Button>
|
||||
|
||||
<Dropdown
|
||||
menu={{ items: [{ key: '1', label: t('excelButton'), onClick: handleExcelExport }] }}
|
||||
>
|
||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||
{t('exportButton')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
}
|
||||
title={pageTitle}
|
||||
children={headerChildren}
|
||||
/>
|
||||
|
||||
<Card title={<ProjectsReportsFilters />}>
|
||||
<Card title={cardTitle}>
|
||||
<ProjectReportsTable />
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsReports;
|
||||
export default memo(ProjectsReports);
|
||||
|
||||
@@ -42,10 +42,6 @@ const ReportingSider = () => {
|
||||
theme={{
|
||||
components: {
|
||||
Menu: {
|
||||
itemHoverBg: colors.transparent,
|
||||
itemHoverColor: colors.skyBlue,
|
||||
borderRadius: 12,
|
||||
itemMarginBlock: 4,
|
||||
subMenuItemBg: colors.transparent,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -79,6 +79,83 @@
|
||||
border: 1px solid #d3d3d3;
|
||||
}
|
||||
|
||||
/* Enhanced overview stat card styles */
|
||||
.overview-stat-card {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.overview-stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12) !important;
|
||||
}
|
||||
|
||||
/* Light mode stat cards */
|
||||
.overview-stat-card.light-mode {
|
||||
background-color: #ffffff !important;
|
||||
border: 1px solid #f0f0f0 !important;
|
||||
}
|
||||
|
||||
.overview-stat-card.light-mode:hover {
|
||||
border-color: #1890ff !important;
|
||||
box-shadow: 0 4px 16px rgba(24, 144, 255, 0.15) !important;
|
||||
}
|
||||
|
||||
.overview-stat-card.light-mode .ant-card {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.overview-stat-card.light-mode .ant-card-body {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Dark mode stat cards */
|
||||
.overview-stat-card.dark-mode {
|
||||
background-color: #1f1f1f !important;
|
||||
border: 1px solid #303030 !important;
|
||||
}
|
||||
|
||||
.overview-stat-card.dark-mode:hover {
|
||||
border-color: #1890ff !important;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
|
||||
.overview-stat-card.dark-mode .ant-card {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.overview-stat-card.dark-mode .ant-card-body {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Force dark mode styles when body has dark class or data attribute */
|
||||
body.dark .overview-stat-card,
|
||||
[data-theme="dark"] .overview-stat-card,
|
||||
.ant-theme-dark .overview-stat-card {
|
||||
background-color: #1f1f1f !important;
|
||||
border: 1px solid #303030 !important;
|
||||
}
|
||||
|
||||
body.dark .overview-stat-card .ant-card,
|
||||
[data-theme="dark"] .overview-stat-card .ant-card,
|
||||
.ant-theme-dark .overview-stat-card .ant-card {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
body.dark .overview-stat-card .ant-card-body,
|
||||
[data-theme="dark"] .overview-stat-card .ant-card-body,
|
||||
.ant-theme-dark .overview-stat-card .ant-card-body {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Ensure no border radius on card components */
|
||||
.overview-stat-card .ant-card,
|
||||
.overview-stat-card .ant-card-body {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* reporting sidebar */
|
||||
.custom-reporting-sider .ant-menu-item-selected {
|
||||
border-inline-end: 3px solid #1890ff !important;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
47
worklenz-frontend/src/utils/project-group.ts
Normal file
47
worklenz-frontend/src/utils/project-group.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user