Compare commits
55 Commits
feature/sh
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0cf5099f8 | ||
|
|
301b58f0ba | ||
|
|
4c4a860c76 | ||
|
|
193288013e | ||
|
|
39e8add103 | ||
|
|
0f82c9738b | ||
|
|
a4237a6f17 | ||
|
|
20039a07ff | ||
|
|
dfc38a6829 | ||
|
|
0e0d1a5f11 | ||
|
|
4dbaab060a | ||
|
|
b8811ab5b6 | ||
|
|
5248c26b76 | ||
|
|
eed0fb6eca | ||
|
|
2a9447b506 | ||
|
|
fb94028410 | ||
|
|
25639afe1a | ||
|
|
4426b5f3ef | ||
|
|
3cae2771de | ||
|
|
81f55adb41 | ||
|
|
bd4c88833d | ||
|
|
2374d7a357 | ||
|
|
91730026fd | ||
|
|
9d10b23ba7 | ||
|
|
d0c231ee43 | ||
|
|
58ce8e40c7 | ||
|
|
2aa4fe9673 | ||
|
|
ccb50e3c62 | ||
|
|
5ce9e66fea | ||
|
|
6492a4672b | ||
|
|
46acb26c42 | ||
|
|
c9aab73a2a | ||
|
|
13a202cca4 | ||
|
|
bdb9c9ca28 | ||
|
|
5ed5a86bad | ||
|
|
520888988e | ||
|
|
de28f87c62 | ||
|
|
e9e9bffd9a | ||
|
|
8f181c687b | ||
|
|
926c058d1e | ||
|
|
1583221232 | ||
|
|
585a65be31 | ||
|
|
2de9b7f6b7 | ||
|
|
323b17185c | ||
|
|
09f44a5685 | ||
|
|
0e67434515 | ||
|
|
f4ab7841fb | ||
|
|
3de4f69a62 | ||
|
|
102be2c24a | ||
|
|
378dc22bb0 | ||
|
|
3a39b25e64 | ||
|
|
32248f8424 | ||
|
|
7e431d645a | ||
|
|
cef4bffd69 | ||
|
|
75391641fd |
11
README.md
11
README.md
@@ -1,6 +1,6 @@
|
|||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
|
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
|
||||||
<img src="https://app.worklenz.com/assets/icons/icon-144x144.png" alt="Worklenz Logo" width="75">
|
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/icon-144x144.png" alt="Worklenz Logo" width="75">
|
||||||
</a>
|
</a>
|
||||||
<br>
|
<br>
|
||||||
Worklenz
|
Worklenz
|
||||||
@@ -315,6 +315,7 @@ docker-compose up -d
|
|||||||
docker-compose down
|
docker-compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## MinIO Integration
|
## MinIO Integration
|
||||||
|
|
||||||
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
|
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
|
||||||
@@ -403,6 +404,10 @@ This script generates properly configured environment files for both development
|
|||||||
- Frontend: http://localhost:5000
|
- Frontend: http://localhost:5000
|
||||||
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
|
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
|
||||||
|
|
||||||
|
4. Video Guide
|
||||||
|
|
||||||
|
For a visual walkthrough of the local Docker deployment process, check out our [step-by-step video guide](https://www.youtube.com/watch?v=AfwAKxJbqLg).
|
||||||
|
|
||||||
### Remote Server Deployment
|
### Remote Server Deployment
|
||||||
|
|
||||||
When deploying to a remote server:
|
When deploying to a remote server:
|
||||||
@@ -428,6 +433,10 @@ When deploying to a remote server:
|
|||||||
- Frontend: http://your-server-hostname:5000
|
- Frontend: http://your-server-hostname:5000
|
||||||
- Backend API: http://your-server-hostname:3000
|
- Backend API: http://your-server-hostname:3000
|
||||||
|
|
||||||
|
4. Video Guide
|
||||||
|
|
||||||
|
For a complete walkthrough of deploying Worklenz to a remote server, check out our [deployment video guide](https://www.youtube.com/watch?v=CAZGu2iOXQs&t=10s).
|
||||||
|
|
||||||
### Environment Configuration
|
### Environment Configuration
|
||||||
|
|
||||||
The Docker setup uses environment variables to configure the services:
|
The Docker setup uses environment variables to configure the services:
|
||||||
|
|||||||
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": {}
|
||||||
|
}
|
||||||
@@ -73,8 +73,8 @@ cat > worklenz-backend/.env << EOL
|
|||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
SESSION_NAME=worklenz.sid
|
SESSION_NAME=worklenz.sid
|
||||||
SESSION_SECRET=change_me_in_production
|
SESSION_SECRET=$(openssl rand -base64 48)
|
||||||
COOKIE_SECRET=change_me_in_production
|
COOKIE_SECRET=$(openssl rand -base64 48)
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
SOCKET_IO_CORS=${FRONTEND_URL}
|
SOCKET_IO_CORS=${FRONTEND_URL}
|
||||||
@@ -123,7 +123,7 @@ SLACK_WEBHOOK=
|
|||||||
COMMIT_BUILD_IMMEDIATELY=true
|
COMMIT_BUILD_IMMEDIATELY=true
|
||||||
|
|
||||||
# JWT Secret
|
# JWT Secret
|
||||||
JWT_SECRET=change_me_in_production
|
JWT_SECRET=$(openssl rand -base64 48)
|
||||||
EOL
|
EOL
|
||||||
|
|
||||||
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")
|
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")
|
||||||
@@ -138,4 +138,4 @@ echo "Frontend URL: ${FRONTEND_URL}"
|
|||||||
echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000"
|
echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000"
|
||||||
echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000"
|
echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000"
|
||||||
echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}"
|
echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}"
|
||||||
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"
|
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ BEGIN
|
|||||||
SET progress_value = NULL,
|
SET progress_value = NULL,
|
||||||
progress_mode = NULL
|
progress_mode = NULL
|
||||||
WHERE project_id = _project_id
|
WHERE project_id = _project_id
|
||||||
AND progress_mode = _old_mode;
|
AND progress_mode::text::progress_mode_type = _old_mode;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
|
|||||||
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-rate-limit": "^6.8.0",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"express-validator": "^6.15.0",
|
"express-validator": "^6.15.0",
|
||||||
|
"grunt-cli": "^1.5.0",
|
||||||
"helmet": "^6.2.0",
|
"helmet": "^6.2.0",
|
||||||
"hpp": "^0.2.3",
|
"hpp": "^0.2.3",
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
@@ -93,8 +94,10 @@
|
|||||||
"sharp": "^0.32.6",
|
"sharp": "^0.32.6",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"socket.io": "^4.7.1",
|
"socket.io": "^4.7.1",
|
||||||
|
"tinymce": "^7.8.0",
|
||||||
"uglify-js": "^3.17.4",
|
"uglify-js": "^3.17.4",
|
||||||
"winston": "^3.10.0",
|
"winston": "^3.10.0",
|
||||||
|
"worklenz-backend": "file:",
|
||||||
"xss-filters": "^1.2.7"
|
"xss-filters": "^1.2.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -102,15 +105,17 @@
|
|||||||
"@babel/preset-typescript": "^7.22.5",
|
"@babel/preset-typescript": "^7.22.5",
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/bluebird": "^3.5.38",
|
"@types/bluebird": "^3.5.38",
|
||||||
|
"@types/body-parser": "^1.19.2",
|
||||||
"@types/compression": "^1.7.2",
|
"@types/compression": "^1.7.2",
|
||||||
"@types/connect-flash": "^0.0.37",
|
"@types/connect-flash": "^0.0.37",
|
||||||
"@types/cookie-parser": "^1.4.3",
|
"@types/cookie-parser": "^1.4.3",
|
||||||
"@types/cron": "^2.0.1",
|
"@types/cron": "^2.0.1",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/csurf": "^1.11.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": "^1.0.2",
|
||||||
"@types/express-brute-redis": "^0.0.4",
|
"@types/express-brute-redis": "^0.0.4",
|
||||||
|
"@types/express-serve-static-core": "^4.17.34",
|
||||||
"@types/express-session": "^1.17.7",
|
"@types/express-session": "^1.17.7",
|
||||||
"@types/fs-extra": "^9.0.13",
|
"@types/fs-extra": "^9.0.13",
|
||||||
"@types/hpp": "^0.2.2",
|
"@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: [] }));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,11 +50,16 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
|||||||
task.progress = parseInt(task.progress_value);
|
task.progress = parseInt(task.progress_value);
|
||||||
task.complete_ratio = parseInt(task.progress_value);
|
task.complete_ratio = parseInt(task.progress_value);
|
||||||
}
|
}
|
||||||
// For tasks with no subtasks and no manual progress, calculate based on time
|
// For tasks with no subtasks and no manual progress
|
||||||
else {
|
else {
|
||||||
task.progress = task.total_minutes_spent && task.total_minutes
|
// Only calculate progress based on time if time-based progress is enabled for the project
|
||||||
? ~~(task.total_minutes_spent / task.total_minutes * 100)
|
if (task.project_use_time_progress && task.total_minutes_spent && task.total_minutes) {
|
||||||
: 0;
|
// Cap the progress at 100% to prevent showing more than 100% progress
|
||||||
|
task.progress = Math.min(~~(task.total_minutes_spent / task.total_minutes * 100), 100);
|
||||||
|
} else {
|
||||||
|
// Default to 0% progress when time-based calculation is not enabled
|
||||||
|
task.progress = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Set complete_ratio to match progress
|
// Set complete_ratio to match progress
|
||||||
task.complete_ratio = task.progress;
|
task.complete_ratio = task.progress;
|
||||||
|
|||||||
@@ -610,6 +610,21 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
return this.createTagList(result.rows);
|
return this.createTagList(result.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async getProjectSubscribers(projectId: string) {
|
||||||
|
const q = `
|
||||||
|
SELECT u.name, u.avatar_url, ps.user_id, ps.team_member_id, ps.project_id
|
||||||
|
FROM project_subscribers ps
|
||||||
|
LEFT JOIN users u ON ps.user_id = u.id
|
||||||
|
WHERE ps.project_id = $1;
|
||||||
|
`;
|
||||||
|
const result = await db.query(q, [projectId]);
|
||||||
|
|
||||||
|
for (const member of result.rows)
|
||||||
|
member.color_code = getColor(member.name);
|
||||||
|
|
||||||
|
return this.createTagList(result.rows);
|
||||||
|
}
|
||||||
|
|
||||||
public static async checkUserAssignedToTask(taskId: string, userId: string, teamId: string) {
|
public static async checkUserAssignedToTask(taskId: string, userId: string, teamId: string) {
|
||||||
const q = `
|
const q = `
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import { isProduction } from "../shared/utils";
|
|||||||
const pgSession = require("connect-pg-simple")(session);
|
const pgSession = require("connect-pg-simple")(session);
|
||||||
|
|
||||||
export default session({
|
export default session({
|
||||||
name: process.env.SESSION_NAME || "worklenz.sid",
|
name: process.env.SESSION_NAME,
|
||||||
secret: process.env.SESSION_SECRET || "development-secret-key",
|
secret: process.env.SESSION_SECRET || "development-secret-key",
|
||||||
proxy: true,
|
proxy: false,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: true,
|
||||||
rolling: true,
|
rolling: true,
|
||||||
store: new pgSession({
|
store: new pgSession({
|
||||||
pool: db.pool,
|
pool: db.pool,
|
||||||
@@ -18,9 +18,10 @@ export default session({
|
|||||||
}),
|
}),
|
||||||
cookie: {
|
cookie: {
|
||||||
path: "/",
|
path: "/",
|
||||||
secure: isProduction(), // Use secure cookies in production
|
// secure: isProduction(),
|
||||||
httpOnly: true,
|
// httpOnly: isProduction(),
|
||||||
sameSite: "lax", // Standard setting for same-origin requests
|
// sameSite: "none",
|
||||||
|
// domain: isProduction() ? ".worklenz.com" : undefined,
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
|
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
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/",
|
"homepage": "https://www.tiny.cloud/",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/tinymce/tinymce/issues"
|
"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.post("/", teamOwnerOrAdminValidator, projectsBodyValidator, safeControllerFunction(ProjectsController.create));
|
||||||
projectsApiRouter.get("/", safeControllerFunction(ProjectsController.get));
|
projectsApiRouter.get("/", safeControllerFunction(ProjectsController.get));
|
||||||
|
projectsApiRouter.get("/grouped", safeControllerFunction(ProjectsController.getGrouped));
|
||||||
projectsApiRouter.get("/my-task-projects", safeControllerFunction(ProjectsController.getMyProjectsToTasks));
|
projectsApiRouter.get("/my-task-projects", safeControllerFunction(ProjectsController.getMyProjectsToTasks));
|
||||||
projectsApiRouter.get("/my-projects", safeControllerFunction(ProjectsController.getMyProjects));
|
projectsApiRouter.get("/my-projects", safeControllerFunction(ProjectsController.getMyProjects));
|
||||||
projectsApiRouter.get("/all", safeControllerFunction(ProjectsController.getAllProjects));
|
projectsApiRouter.get("/all", safeControllerFunction(ProjectsController.getAllProjects));
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ export async function on_project_subscriber_change(_io: Server, socket: Socket,
|
|||||||
const isSubscribe = data.mode == 0;
|
const isSubscribe = data.mode == 0;
|
||||||
const q = isSubscribe
|
const q = isSubscribe
|
||||||
? `INSERT INTO project_subscribers (user_id, project_id, team_member_id)
|
? `INSERT INTO project_subscribers (user_id, project_id, team_member_id)
|
||||||
VALUES ($1, $2, $3);`
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, project_id, team_member_id) DO NOTHING;`
|
||||||
: `DELETE
|
: `DELETE
|
||||||
FROM project_subscribers
|
FROM project_subscribers
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
@@ -27,7 +28,7 @@ export async function on_project_subscriber_change(_io: Server, socket: Socket,
|
|||||||
AND team_member_id = $3;`;
|
AND team_member_id = $3;`;
|
||||||
await db.query(q, [data.user_id, data.project_id, data.team_member_id]);
|
await db.query(q, [data.user_id, data.project_id, data.team_member_id]);
|
||||||
|
|
||||||
const subscribers = await TasksControllerV2.getTaskSubscribers(data.project_id);
|
const subscribers = await TasksControllerV2.getProjectSubscribers(data.project_id);
|
||||||
socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), subscribers);
|
socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), subscribers);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ COPY . .
|
|||||||
RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \
|
RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \
|
||||||
echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js
|
echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js
|
||||||
|
|
||||||
RUN npm run build
|
RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS production
|
FROM node:22-alpine AS production
|
||||||
|
|
||||||
|
|||||||
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",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite dev",
|
||||||
|
"dev": "vite dev",
|
||||||
"prebuild": "node scripts/copy-tinymce.js",
|
"prebuild": "node scripts/copy-tinymce.js",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev-build": "vite build",
|
"dev-build": "vite build",
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/colors": "^7.1.0",
|
"@ant-design/colors": "^7.1.0",
|
||||||
"@ant-design/compatible": "^5.1.4",
|
"@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",
|
"@ant-design/pro-components": "^2.7.19",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"chart.js": "^4.4.7",
|
"chart.js": "^4.4.7",
|
||||||
"chartjs-plugin-datalabels": "^2.2.0",
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.2.5",
|
"dompurify": "^3.2.5",
|
||||||
"gantt-task-react": "^0.3.9",
|
"gantt-task-react": "^0.3.9",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"i18next-http-backend": "^2.7.3",
|
"i18next-http-backend": "^2.7.3",
|
||||||
"jspdf": "^3.0.0",
|
"jspdf": "^3.0.0",
|
||||||
"mixpanel-browser": "^2.56.0",
|
"mixpanel-browser": "^2.56.0",
|
||||||
|
"nanoid": "^5.1.5",
|
||||||
"primereact": "^10.8.4",
|
"primereact": "^10.8.4",
|
||||||
"re-resizable": "^6.10.3",
|
"re-resizable": "^6.10.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -52,7 +55,8 @@
|
|||||||
"react-window": "^1.8.11",
|
"react-window": "^1.8.11",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tinymce": "^7.7.2",
|
"tinymce": "^7.7.2",
|
||||||
"web-vitals": "^4.2.4"
|
"web-vitals": "^4.2.4",
|
||||||
|
"worklenz": "file:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
@@ -70,6 +74,7 @@
|
|||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.5.2",
|
"postcss": "^8.5.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.8",
|
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||||
|
"rollup": "^4.40.2",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"terser": "^5.39.0",
|
"terser": "^5.39.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
@@ -19,5 +19,13 @@
|
|||||||
"unarchiveConfirm": "Are you sure you want to unarchive this project?",
|
"unarchiveConfirm": "Are you sure you want to unarchive this project?",
|
||||||
"clickToFilter": "Click to filter by",
|
"clickToFilter": "Click to filter by",
|
||||||
"noProjects": "No projects found",
|
"noProjects": "No projects found",
|
||||||
"addToFavourites": "Add to favourites"
|
"addToFavourites": "Add to favourites",
|
||||||
|
"list": "List",
|
||||||
|
"group": "Group",
|
||||||
|
"listView": "List View",
|
||||||
|
"groupView": "Group View",
|
||||||
|
"groupBy": {
|
||||||
|
"category": "Category",
|
||||||
|
"client": "Client"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,5 +47,6 @@
|
|||||||
"weightedProgress": "Weighted Progress",
|
"weightedProgress": "Weighted Progress",
|
||||||
"weightedProgressTooltip": "Calculate progress based on subtask weights",
|
"weightedProgressTooltip": "Calculate progress based on subtask weights",
|
||||||
"timeProgress": "Time-based Progress",
|
"timeProgress": "Time-based Progress",
|
||||||
"timeProgressTooltip": "Calculate progress based on estimated time"
|
"timeProgressTooltip": "Calculate progress based on estimated time",
|
||||||
|
"enterProjectKey": "Enter project key"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,5 +40,18 @@
|
|||||||
"noCategory": "No Category",
|
"noCategory": "No Category",
|
||||||
"noProjects": "No projects found",
|
"noProjects": "No projects found",
|
||||||
"noTeams": "No teams 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?",
|
"unarchiveConfirm": "¿Estás seguro de que deseas desarchivar este proyecto?",
|
||||||
"clickToFilter": "Clique para filtrar por",
|
"clickToFilter": "Clique para filtrar por",
|
||||||
"noProjects": "No se encontraron proyectos",
|
"noProjects": "No se encontraron proyectos",
|
||||||
"addToFavourites": "Añadir a favoritos"
|
"addToFavourites": "Añadir a favoritos",
|
||||||
|
"list": "Lista",
|
||||||
|
"group": "Grupo",
|
||||||
|
"listView": "Vista de Lista",
|
||||||
|
"groupView": "Vista de Grupo",
|
||||||
|
"groupBy": {
|
||||||
|
"category": "Categoría",
|
||||||
|
"client": "Cliente"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,5 +47,6 @@
|
|||||||
"weightedProgress": "Progreso Ponderado",
|
"weightedProgress": "Progreso Ponderado",
|
||||||
"weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas",
|
"weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas",
|
||||||
"timeProgress": "Progreso Basado en Tiempo",
|
"timeProgress": "Progreso Basado en Tiempo",
|
||||||
"timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado"
|
"timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado",
|
||||||
|
"enterProjectKey": "Ingresa la clave del proyecto"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"selectAll": "Seleccionar Todo",
|
"selectAll": "Seleccionar Todo",
|
||||||
"teams": "Equipos",
|
"teams": "Equipos",
|
||||||
|
|
||||||
"searchByProject": "Buscar por nombre de proyecto",
|
"searchByProject": "Buscar por nombre del proyecto",
|
||||||
"projects": "Proyectos",
|
"projects": "Proyectos",
|
||||||
|
|
||||||
"searchByCategory": "Buscar por nombre de categoría",
|
"searchByCategory": "Buscar por nombre de categoría",
|
||||||
@@ -37,8 +37,21 @@
|
|||||||
"actualDays": "Días Reales",
|
"actualDays": "Días Reales",
|
||||||
|
|
||||||
"noCategories": "No se encontraron categorías",
|
"noCategories": "No se encontraron categorías",
|
||||||
"noCategory": "No Categoría",
|
"noCategory": "Sin Categoría",
|
||||||
"noProjects": "No se encontraron proyectos",
|
"noProjects": "No se encontraron proyectos",
|
||||||
"noTeams": "No se encontraron equipos",
|
"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?",
|
"unarchiveConfirm": "Tem certeza de que deseja desarquivar este projeto?",
|
||||||
"clickToFilter": "Clique para filtrar por",
|
"clickToFilter": "Clique para filtrar por",
|
||||||
"noProjects": "Nenhum projeto encontrado",
|
"noProjects": "Nenhum projeto encontrado",
|
||||||
"addToFavourites": "Adicionar aos favoritos"
|
"addToFavourites": "Adicionar aos favoritos",
|
||||||
|
"list": "Lista",
|
||||||
|
"group": "Grupo",
|
||||||
|
"listView": "Visualização em Lista",
|
||||||
|
"groupView": "Visualização em Grupo",
|
||||||
|
"groupBy": {
|
||||||
|
"category": "Categoria",
|
||||||
|
"client": "Cliente"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,5 +47,6 @@
|
|||||||
"weightedProgress": "Progresso Ponderado",
|
"weightedProgress": "Progresso Ponderado",
|
||||||
"weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas",
|
"weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas",
|
||||||
"timeProgress": "Progresso Baseado em Tempo",
|
"timeProgress": "Progresso Baseado em Tempo",
|
||||||
"timeProgressTooltip": "Calcular o progresso com base no tempo estimado"
|
"timeProgressTooltip": "Calcular o progresso com base no tempo estimado",
|
||||||
|
"enterProjectKey": "Insira a chave do projeto"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"timeSheet": "Folha de Tempo",
|
"timeSheet": "Folha de Tempo",
|
||||||
|
|
||||||
"searchByName": "Pesquisar por nome",
|
"searchByName": "Pesquisar por nome",
|
||||||
"selectAll": "Selecionar Todos",
|
"selectAll": "Selecionar Tudo",
|
||||||
"teams": "Equipes",
|
"teams": "Equipes",
|
||||||
|
|
||||||
"searchByProject": "Pesquisar por nome do projeto",
|
"searchByProject": "Pesquisar por nome do projeto",
|
||||||
@@ -13,32 +13,45 @@
|
|||||||
"searchByCategory": "Pesquisar por nome da categoria",
|
"searchByCategory": "Pesquisar por nome da categoria",
|
||||||
"categories": "Categorias",
|
"categories": "Categorias",
|
||||||
|
|
||||||
"billable": "Cobrável",
|
"billable": "Faturável",
|
||||||
"nonBillable": "Não Cobrável",
|
"nonBillable": "Não Faturável",
|
||||||
|
|
||||||
"total": "Total",
|
"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",
|
"exportToExcel": "Exportar para Excel",
|
||||||
"logged": "registrado",
|
"logged": "registrado",
|
||||||
"for": "para",
|
"for": "para",
|
||||||
|
|
||||||
"membersTimeSheet": "Folha de Tempo dos Membros",
|
"membersTimeSheet": "Folha de Tempo de Membros",
|
||||||
"member": "Membro",
|
"member": "Membro",
|
||||||
|
|
||||||
"estimatedVsActual": "Estimado vs Real",
|
"estimatedVsActual": "Estimado vs Real",
|
||||||
"workingDays": "Dias de Trabalho",
|
"workingDays": "Dias Úteis",
|
||||||
"manDays": "Dias-Homem",
|
"manDays": "Dias Homem",
|
||||||
"days": "Dias",
|
"days": "Dias",
|
||||||
"estimatedDays": "Dias Estimados",
|
"estimatedDays": "Dias Estimados",
|
||||||
"actualDays": "Dias Reais",
|
"actualDays": "Dias Reais",
|
||||||
|
|
||||||
"noCategories": "Nenhuma categoria encontrada",
|
"noCategories": "Nenhuma categoria encontrada",
|
||||||
"noCategory": "Nenhuma Categoria",
|
"noCategory": "Sem Categoria",
|
||||||
"noProjects": "Nenhum projeto encontrado",
|
"noProjects": "Nenhum projeto encontrado",
|
||||||
"noTeams": "Nenhum time encontrado",
|
"noTeams": "Nenhuma equipe encontrada",
|
||||||
"noData": "Nenhum dado encontrado"
|
"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 { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types';
|
||||||
import { IProjectMembersViewModel } from '@/types/projectMember.types';
|
import { IProjectMembersViewModel } from '@/types/projectMember.types';
|
||||||
import { IProjectManager } from '@/types/project/projectManager.types';
|
import { IProjectManager } from '@/types/project/projectManager.types';
|
||||||
|
import { IGroupedProjectsViewModel } from '@/types/project/groupedProjectsViewModel.types';
|
||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/projects`;
|
const rootUrl = `${API_BASE_URL}/projects`;
|
||||||
|
|
||||||
@@ -32,6 +33,23 @@ export const projectsApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getGroupedProjects: async (
|
||||||
|
index: number,
|
||||||
|
size: number,
|
||||||
|
field: string | null,
|
||||||
|
order: string | null,
|
||||||
|
search: string | null,
|
||||||
|
groupBy: string,
|
||||||
|
filter: number | null = null,
|
||||||
|
statuses: string | null = null,
|
||||||
|
categories: string | null = null
|
||||||
|
): Promise<IServerResponse<IGroupedProjectsViewModel>> => {
|
||||||
|
const s = encodeURIComponent(search || '');
|
||||||
|
const url = `${rootUrl}/grouped${toQueryString({ index, size, field, order, search: s, groupBy, filter, statuses, categories })}`;
|
||||||
|
const response = await apiClient.get<IServerResponse<IGroupedProjectsViewModel>>(`${url}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
getProject: async (id: string): Promise<IServerResponse<IProjectViewModel>> => {
|
getProject: async (id: string): Promise<IServerResponse<IProjectViewModel>> => {
|
||||||
const url = `${rootUrl}/${id}`;
|
const url = `${rootUrl}/${id}`;
|
||||||
const response = await apiClient.get<IServerResponse<IProjectViewModel>>(`${url}`);
|
const response = await apiClient.get<IServerResponse<IProjectViewModel>>(`${url}`);
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/g
|
|||||||
import homePageApiService from '@/api/home-page/home-page.api.service';
|
import homePageApiService from '@/api/home-page/home-page.api.service';
|
||||||
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||||
|
|
||||||
|
import projectViewReducer from '@features/project/project-view-slice';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
middleware: getDefaultMiddleware =>
|
middleware: getDefaultMiddleware =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
@@ -113,6 +115,8 @@ export const store = configureStore({
|
|||||||
taskListCustomColumnsReducer: taskListCustomColumnsReducer,
|
taskListCustomColumnsReducer: taskListCustomColumnsReducer,
|
||||||
boardReducer: boardReducer,
|
boardReducer: boardReducer,
|
||||||
projectDrawerReducer: projectDrawerReducer,
|
projectDrawerReducer: projectDrawerReducer,
|
||||||
|
|
||||||
|
projectViewReducer: projectViewReducer,
|
||||||
|
|
||||||
// Project Lookups
|
// Project Lookups
|
||||||
projectCategoriesReducer: projectCategoriesReducer,
|
projectCategoriesReducer: projectCategoriesReducer,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const AccountStorage = ({ themeMode }: IAccountStorageProps) => {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<div style={{ padding: '0 16px' }}>
|
<div style={{ padding: '0 16px' }}>
|
||||||
<Progress
|
<Progress
|
||||||
percent={billingInfo?.usedPercentage ?? 0}
|
percent={billingInfo?.used_percent ?? 0}
|
||||||
type="circle"
|
type="circle"
|
||||||
format={percent => <span style={{ fontSize: '13px' }}>{percent}% Used</span>}
|
format={percent => <span style={{ fontSize: '13px' }}>{percent}% Used</span>}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
import { Button, Card, Col, Modal, Row, Tooltip, Typography } from 'antd';
|
import { Card, Col, Row, Tooltip, Typography } from 'antd';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import './current-bill.css';
|
import './current-bill.css';
|
||||||
import { InfoCircleTwoTone } from '@ant-design/icons';
|
import { InfoCircleTwoTone } from '@ant-design/icons';
|
||||||
import ChargesTable from './billing-tables/charges-table';
|
import ChargesTable from './billing-tables/charges-table';
|
||||||
import InvoicesTable from './billing-tables/invoices-table';
|
import InvoicesTable from './billing-tables/invoices-table';
|
||||||
import UpgradePlansLKR from './drawers/upgrade-plans-lkr/upgrade-plans-lkr';
|
|
||||||
import UpgradePlans from './drawers/upgrade-plans/upgrade-plans';
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { useMediaQuery } from 'react-responsive';
|
import { useMediaQuery } from 'react-responsive';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
|
||||||
toggleDrawer,
|
|
||||||
toggleUpgradeModal,
|
|
||||||
} from '@/features/admin-center/billing/billing.slice';
|
|
||||||
import { fetchBillingInfo, fetchFreePlanSettings } from '@/features/admin-center/admin-center.slice';
|
import { fetchBillingInfo, fetchFreePlanSettings } from '@/features/admin-center/admin-center.slice';
|
||||||
import RedeemCodeDrawer from './drawers/redeem-code-drawer/redeem-code-drawer';
|
|
||||||
import CurrentPlanDetails from './current-plan-details/current-plan-details';
|
import CurrentPlanDetails from './current-plan-details/current-plan-details';
|
||||||
import AccountStorage from './account-storage/account-storage';
|
import AccountStorage from './account-storage/account-storage';
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
@@ -25,9 +21,7 @@ const CurrentBill: React.FC = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { t } = useTranslation('admin-center/current-bill');
|
const { t } = useTranslation('admin-center/current-bill');
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
const { isUpgradeModalOpen } = useAppSelector(state => state.adminCenterReducer);
|
|
||||||
const isTablet = useMediaQuery({ query: '(min-width: 1025px)' });
|
const isTablet = useMediaQuery({ query: '(min-width: 1025px)' });
|
||||||
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
||||||
const currentSession = useAuthService().getCurrentSession();
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,42 +40,7 @@ const CurrentBill: React.FC = () => {
|
|||||||
const renderMobileView = () => (
|
const renderMobileView = () => (
|
||||||
<div>
|
<div>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Card
|
<CurrentPlanDetails />
|
||||||
style={{ height: '100%' }}
|
|
||||||
title={<span style={titleStyle}>{t('currentPlanDetails')}</span>}
|
|
||||||
extra={
|
|
||||||
<div style={{ marginTop: '8px', marginRight: '8px' }}>
|
|
||||||
<Button type="primary" onClick={() => dispatch(toggleUpgradeModal())}>
|
|
||||||
{t('upgradePlan')}
|
|
||||||
</Button>
|
|
||||||
<Modal
|
|
||||||
open={isUpgradeModalOpen}
|
|
||||||
onCancel={() => dispatch(toggleUpgradeModal())}
|
|
||||||
width={1000}
|
|
||||||
centered
|
|
||||||
okButtonProps={{ hidden: true }}
|
|
||||||
cancelButtonProps={{ hidden: true }}
|
|
||||||
>
|
|
||||||
{browserTimeZone === 'Asia/Colombo' ? <UpgradePlansLKR /> : <UpgradePlans />}
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', width: '50%', padding: '0 12px' }}>
|
|
||||||
<div style={{ marginBottom: '14px' }}>
|
|
||||||
<Typography.Text style={{ fontWeight: 700 }}>{t('cardBodyText01')}</Typography.Text>
|
|
||||||
<Typography.Text>{t('cardBodyText02')}</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
style={{ margin: 0, padding: 0, width: '90px' }}
|
|
||||||
onClick={() => dispatch(toggleDrawer())}
|
|
||||||
>
|
|
||||||
{t('redeemCode')}
|
|
||||||
</Button>
|
|
||||||
<RedeemCodeDrawer />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col span={24} style={{ marginTop: '1.5rem' }}>
|
<Col span={24} style={{ marginTop: '1.5rem' }}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Avatar, Tooltip } from 'antd';
|
import { Avatar, Tooltip } from 'antd';
|
||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
|
|
||||||
interface AvatarsProps {
|
interface AvatarsProps {
|
||||||
@@ -6,41 +7,54 @@ interface AvatarsProps {
|
|||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderAvatar = (member: InlineMember, index: number) => (
|
const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount }) => {
|
||||||
<Tooltip
|
const stopPropagation = useCallback((e: React.MouseEvent) => {
|
||||||
key={member.team_member_id || index}
|
e.stopPropagation();
|
||||||
title={member.end && member.names ? member.names.join(', ') : member.name}
|
}, []);
|
||||||
>
|
|
||||||
{member.avatar_url ? (
|
const renderAvatar = useCallback((member: InlineMember, index: number) => (
|
||||||
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
<Tooltip
|
||||||
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
key={member.team_member_id || index}
|
||||||
</span>
|
title={member.end && member.names ? member.names.join(', ') : member.name}
|
||||||
) : (
|
>
|
||||||
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
{member.avatar_url ? (
|
||||||
<Avatar
|
<span onClick={stopPropagation}>
|
||||||
size={28}
|
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
||||||
key={member.team_member_id || index}
|
</span>
|
||||||
style={{
|
) : (
|
||||||
backgroundColor: member.color_code || '#ececec',
|
<span onClick={stopPropagation}>
|
||||||
fontSize: '14px',
|
<Avatar
|
||||||
}}
|
size={28}
|
||||||
>
|
key={member.team_member_id || index}
|
||||||
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
|
style={{
|
||||||
</Avatar>
|
backgroundColor: member.color_code || '#ececec',
|
||||||
</span>
|
fontSize: '14px',
|
||||||
)}
|
}}
|
||||||
</Tooltip>
|
>
|
||||||
);
|
{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 (
|
return (
|
||||||
<div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
<div onClick={stopPropagation}>
|
||||||
<Avatar.Group>
|
<Avatar.Group>
|
||||||
{visibleMembers.map((member, index) => renderAvatar(member, index))}
|
{avatarElements}
|
||||||
</Avatar.Group>
|
</Avatar.Group>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
Avatars.displayName = 'Avatars';
|
||||||
|
|
||||||
export default 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,60 @@
|
|||||||
|
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}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrioritySection;
|
||||||
@@ -21,10 +21,10 @@ const HomeTasksStatusDropdown = ({ task, teamId }: HomeTasksStatusDropdownProps)
|
|||||||
const { socket, connected } = useSocket();
|
const { socket, connected } = useSocket();
|
||||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||||
const {
|
const {
|
||||||
refetch
|
refetch
|
||||||
} = useGetMyTasksQuery(homeTasksConfig, {
|
} = useGetMyTasksQuery(homeTasksConfig, {
|
||||||
skip: true // Skip automatic queries entirely
|
skip: false, // Ensure this query runs
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState<ITaskStatus | undefined>(undefined);
|
const [selectedStatus, setSelectedStatus] = useState<ITaskStatus | undefined>(undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ const HomeTasksDatePicker = ({ record }: HomeTasksDatePickerProps) => {
|
|||||||
const { t } = useTranslation('home');
|
const { t } = useTranslation('home');
|
||||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||||
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
|
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
|
||||||
skip: true // Skip automatic queries entirely
|
skip: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use useMemo to avoid re-renders when record.end_date is the same
|
// Use useMemo to avoid re-renders when record.end_date is the same
|
||||||
const initialDate = useMemo(() =>
|
const initialDate = useMemo(() =>
|
||||||
record.end_date ? dayjs(record.end_date) : null
|
record.end_date ? dayjs(record.end_date) : null
|
||||||
, [record.end_date]);
|
, [record.end_date]);
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(initialDate);
|
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(initialDate);
|
||||||
|
|
||||||
// Update selected date when record changes
|
// Update selected date when record changes
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -11,7 +11,7 @@ import List from 'antd/es/list';
|
|||||||
import Space from 'antd/es/space';
|
import Space from 'antd/es/space';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -36,6 +36,13 @@ const LabelsFilterDropdown = () => {
|
|||||||
const tab = searchParams.get('tab');
|
const tab = searchParams.get('tab');
|
||||||
const projectView = tab === 'tasks-list' ? 'list' : 'kanban';
|
const projectView = tab === 'tasks-list' ? 'list' : 'kanban';
|
||||||
|
|
||||||
|
// Fetch labels when component mounts or projectId changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectId) {
|
||||||
|
dispatch(fetchLabelsByProject(projectId));
|
||||||
|
}
|
||||||
|
}, [dispatch, projectId]);
|
||||||
|
|
||||||
const filteredLabelData = useMemo(() => {
|
const filteredLabelData = useMemo(() => {
|
||||||
if (projectView === 'list') {
|
if (projectView === 'list') {
|
||||||
return labels.filter(label => label.name?.toLowerCase().includes(searchQuery.toLowerCase()));
|
return labels.filter(label => label.name?.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
@@ -81,9 +88,6 @@ const LabelsFilterDropdown = () => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
labelInputRef.current?.focus();
|
labelInputRef.current?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
if (projectView === 'kanban') {
|
|
||||||
dispatch(setBoardLabels(labels));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ const MembersFilterDropdown = () => {
|
|||||||
|
|
||||||
const handleSelectedFiltersCount = useCallback(async (memberId: string | undefined, checked: boolean) => {
|
const handleSelectedFiltersCount = useCallback(async (memberId: string | undefined, checked: boolean) => {
|
||||||
if (!memberId || !projectId) return;
|
if (!memberId || !projectId) return;
|
||||||
if (!memberId || !projectId) return;
|
|
||||||
|
|
||||||
const updateMembers = async (members: Member[], setAction: any, fetchAction: any) => {
|
const updateMembers = async (members: Member[], setAction: any, fetchAction: any) => {
|
||||||
const updatedMembers = members.map(member =>
|
const updatedMembers = members.map(member =>
|
||||||
@@ -142,11 +141,12 @@ const MembersFilterDropdown = () => {
|
|||||||
const handleMembersDropdownOpen = useCallback((open: boolean) => {
|
const handleMembersDropdownOpen = useCallback((open: boolean) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setTimeout(() => membersInputRef.current?.focus(), 0);
|
setTimeout(() => membersInputRef.current?.focus(), 0);
|
||||||
if (taskAssignees.length) {
|
// Only sync the members if board members are empty
|
||||||
|
if (projectView === 'kanban' && boardTaskAssignees.length === 0 && taskAssignees.length > 0) {
|
||||||
dispatch(setBoardMembers(taskAssignees));
|
dispatch(setBoardMembers(taskAssignees));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [dispatch, taskAssignees]);
|
}, [dispatch, taskAssignees, boardTaskAssignees, projectView]);
|
||||||
|
|
||||||
const buttonStyle = {
|
const buttonStyle = {
|
||||||
backgroundColor: selectedCount > 0
|
backgroundColor: selectedCount > 0
|
||||||
|
|||||||
@@ -97,30 +97,28 @@ const InfoTabFooter = () => {
|
|||||||
// mentions options
|
// mentions options
|
||||||
const mentionsOptions =
|
const mentionsOptions =
|
||||||
members?.map(member => ({
|
members?.map(member => ({
|
||||||
value: member.id,
|
value: member.name,
|
||||||
label: member.name,
|
label: member.name,
|
||||||
|
key: member.id,
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => {
|
const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => {
|
||||||
console.log('member', member);
|
console.log('member', member);
|
||||||
if (!member?.value || !member?.label) return;
|
if (!member?.value || !member?.label) return;
|
||||||
|
|
||||||
|
// Find the member ID from the members list using the name
|
||||||
|
const selectedMember = members.find(m => m.name === member.value);
|
||||||
|
if (!selectedMember) return;
|
||||||
|
|
||||||
|
// Add to selected members if not already present
|
||||||
setSelectedMembers(prev =>
|
setSelectedMembers(prev =>
|
||||||
prev.some(mention => mention.team_member_id === member.value)
|
prev.some(mention => mention.team_member_id === selectedMember.id)
|
||||||
? prev
|
? prev
|
||||||
: [...prev, { team_member_id: member.value, name: member.label }]
|
: [...prev, { team_member_id: selectedMember.id!, name: selectedMember.name! }]
|
||||||
);
|
);
|
||||||
|
}, [members]);
|
||||||
setCommentValue(prev => {
|
|
||||||
const parts = prev.split('@');
|
|
||||||
const lastPart = parts[parts.length - 1];
|
|
||||||
const mentionText = member.label;
|
|
||||||
// Keep only the part before the @ and add the new mention
|
|
||||||
return prev.slice(0, prev.length - lastPart.length) + mentionText;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCommentChange = useCallback((value: string) => {
|
const handleCommentChange = useCallback((value: string) => {
|
||||||
// Only update the value without trying to replace mentions
|
|
||||||
setCommentValue(value);
|
setCommentValue(value);
|
||||||
setCharacterLength(value.trim().length);
|
setCharacterLength(value.trim().length);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -275,6 +273,12 @@ const InfoTabFooter = () => {
|
|||||||
maxLength={5000}
|
maxLength={5000}
|
||||||
onClick={() => setIsCommentBoxExpand(true)}
|
onClick={() => setIsCommentBoxExpand(true)}
|
||||||
onChange={e => setCharacterLength(e.length)}
|
onChange={e => setCharacterLength(e.length)}
|
||||||
|
prefix="@"
|
||||||
|
filterOption={(input, option) => {
|
||||||
|
if (!input) return true;
|
||||||
|
const optionLabel = (option as any)?.label || '';
|
||||||
|
return optionLabel.toLowerCase().includes(input.toLowerCase());
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
minHeight: 60,
|
minHeight: 60,
|
||||||
resize: 'none',
|
resize: 'none',
|
||||||
@@ -371,7 +375,11 @@ const InfoTabFooter = () => {
|
|||||||
onSelect={option => memberSelectHandler(option as IMentionMemberSelectOption)}
|
onSelect={option => memberSelectHandler(option as IMentionMemberSelectOption)}
|
||||||
onChange={handleCommentChange}
|
onChange={handleCommentChange}
|
||||||
prefix="@"
|
prefix="@"
|
||||||
split=""
|
filterOption={(input, option) => {
|
||||||
|
if (!input) return true;
|
||||||
|
const optionLabel = (option as any)?.label || '';
|
||||||
|
return optionLabel.toLowerCase().includes(input.toLowerCase());
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
minHeight: 100,
|
minHeight: 100,
|
||||||
maxHeight: 200,
|
maxHeight: 200,
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ import alertService from '@/services/alerts/alertService';
|
|||||||
|
|
||||||
interface ITaskAssignee {
|
interface ITaskAssignee {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
team_member_id: string;
|
team_member_id: string;
|
||||||
|
|||||||
@@ -459,10 +459,24 @@ const boardSlice = createSlice({
|
|||||||
const { body, sectionId, taskId } = action.payload;
|
const { body, sectionId, taskId } = action.payload;
|
||||||
const section = state.taskGroups.find(sec => sec.id === sectionId);
|
const section = state.taskGroups.find(sec => sec.id === sectionId);
|
||||||
if (section) {
|
if (section) {
|
||||||
const task = section.tasks.find(task => task.id === taskId);
|
// First try to find the task in main tasks
|
||||||
if (task) {
|
const mainTask = section.tasks.find(task => task.id === taskId);
|
||||||
task.assignees = body.assignees;
|
if (mainTask) {
|
||||||
task.names = body.names;
|
mainTask.assignees = body.assignees;
|
||||||
|
mainTask.names = body.names;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found in main tasks, look in subtasks
|
||||||
|
for (const parentTask of section.tasks) {
|
||||||
|
if (!parentTask.sub_tasks) continue;
|
||||||
|
|
||||||
|
const subtask = parentTask.sub_tasks.find(st => st.id === taskId);
|
||||||
|
if (subtask) {
|
||||||
|
subtask.assignees = body.assignees;
|
||||||
|
subtask.names = body.names;
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { authApiService } from '@/api/auth/auth.api.service';
|
|||||||
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
|
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import TimerButton from './timers/timer-button';
|
import TimerButton from './timers/timer-button';
|
||||||
|
import HelpButton from './help/HelpButton';
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const [current, setCurrent] = useState<string>('home');
|
const [current, setCurrent] = useState<string>('home');
|
||||||
@@ -145,7 +146,8 @@ const Navbar = () => {
|
|||||||
<Flex align="center">
|
<Flex align="center">
|
||||||
<SwitchTeamButton />
|
<SwitchTeamButton />
|
||||||
<NotificationButton />
|
<NotificationButton />
|
||||||
<TimerButton />
|
{/* <TimerButton /> */}
|
||||||
|
<HelpButton />
|
||||||
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
|
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -17,38 +17,70 @@ const TimerButton = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [currentTimes, setCurrentTimes] = useState<Record<string, string>>({});
|
const [currentTimes, setCurrentTimes] = useState<Record<string, string>>({});
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { t } = useTranslation('navbar');
|
const { t } = useTranslation('navbar');
|
||||||
const { token } = useToken();
|
const { token } = useToken();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { socket } = useSocket();
|
const { socket } = useSocket();
|
||||||
|
|
||||||
|
const logError = (message: string, error?: any) => {
|
||||||
|
// Production-safe error logging
|
||||||
|
console.error(`[TimerButton] ${message}`, error);
|
||||||
|
setError(message);
|
||||||
|
};
|
||||||
|
|
||||||
const fetchRunningTimers = useCallback(async () => {
|
const fetchRunningTimers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
const response = await taskTimeLogsApiService.getRunningTimers();
|
const response = await taskTimeLogsApiService.getRunningTimers();
|
||||||
if (response.done) {
|
|
||||||
setRunningTimers(response.body || []);
|
if (response && response.done) {
|
||||||
|
const timers = Array.isArray(response.body) ? response.body : [];
|
||||||
|
setRunningTimers(timers);
|
||||||
|
} else {
|
||||||
|
logError('Invalid response from getRunningTimers API');
|
||||||
|
setRunningTimers([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching running timers:', error);
|
logError('Error fetching running timers', error);
|
||||||
|
setRunningTimers([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateCurrentTimes = () => {
|
const updateCurrentTimes = useCallback(() => {
|
||||||
const newTimes: Record<string, string> = {};
|
try {
|
||||||
runningTimers.forEach(timer => {
|
if (!Array.isArray(runningTimers) || runningTimers.length === 0) return;
|
||||||
const startTime = moment(timer.start_time);
|
|
||||||
const now = moment();
|
const newTimes: Record<string, string> = {};
|
||||||
const duration = moment.duration(now.diff(startTime));
|
runningTimers.forEach(timer => {
|
||||||
const hours = Math.floor(duration.asHours());
|
try {
|
||||||
const minutes = duration.minutes();
|
if (!timer || !timer.task_id || !timer.start_time) return;
|
||||||
const seconds = duration.seconds();
|
|
||||||
newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
const startTime = moment(timer.start_time);
|
||||||
});
|
if (!startTime.isValid()) {
|
||||||
setCurrentTimes(newTimes);
|
logError(`Invalid start time for timer ${timer.task_id}: ${timer.start_time}`);
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = moment();
|
||||||
|
const duration = moment.duration(now.diff(startTime));
|
||||||
|
const hours = Math.floor(duration.asHours());
|
||||||
|
const minutes = duration.minutes();
|
||||||
|
const seconds = duration.seconds();
|
||||||
|
newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
} catch (error) {
|
||||||
|
logError(`Error updating time for timer ${timer?.task_id}`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setCurrentTimes(newTimes);
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error in updateCurrentTimes', error);
|
||||||
|
}
|
||||||
|
}, [runningTimers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRunningTimers();
|
fetchRunningTimers();
|
||||||
@@ -67,209 +99,281 @@ const TimerButton = () => {
|
|||||||
const interval = setInterval(updateCurrentTimes, 1000);
|
const interval = setInterval(updateCurrentTimes, 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, [runningTimers]);
|
}, [runningTimers, updateCurrentTimes]);
|
||||||
|
|
||||||
// Listen for timer start/stop events and project updates to refresh the count
|
// Listen for timer start/stop events and project updates to refresh the count
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket) return;
|
if (!socket) {
|
||||||
|
logError('Socket not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handleTimerStart = (data: string) => {
|
const handleTimerStart = (data: string) => {
|
||||||
try {
|
try {
|
||||||
const { id } = typeof data === 'string' ? JSON.parse(data) : data;
|
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
||||||
|
const { id } = parsed || {};
|
||||||
if (id) {
|
if (id) {
|
||||||
// Refresh the running timers list when a new timer is started
|
// Refresh the running timers list when a new timer is started
|
||||||
fetchRunningTimers();
|
fetchRunningTimers();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing timer start event:', error);
|
logError('Error parsing timer start event', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimerStop = (data: string) => {
|
const handleTimerStop = (data: string) => {
|
||||||
try {
|
try {
|
||||||
const { id } = typeof data === 'string' ? JSON.parse(data) : data;
|
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
||||||
|
const { id } = parsed || {};
|
||||||
if (id) {
|
if (id) {
|
||||||
// Refresh the running timers list when a timer is stopped
|
// Refresh the running timers list when a timer is stopped
|
||||||
fetchRunningTimers();
|
fetchRunningTimers();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing timer stop event:', error);
|
logError('Error parsing timer stop event', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProjectUpdates = () => {
|
const handleProjectUpdates = () => {
|
||||||
// Refresh timers when project updates are available
|
try {
|
||||||
fetchRunningTimers();
|
// Refresh timers when project updates are available
|
||||||
|
fetchRunningTimers();
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error handling project updates', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
|
try {
|
||||||
socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
|
socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
|
||||||
socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
|
socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
|
||||||
|
socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
|
try {
|
||||||
socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
|
socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
|
||||||
socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
|
socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
|
||||||
};
|
socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error cleaning up socket listeners', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error setting up socket listeners', error);
|
||||||
|
}
|
||||||
}, [socket, fetchRunningTimers]);
|
}, [socket, fetchRunningTimers]);
|
||||||
|
|
||||||
const hasRunningTimers = () => {
|
const hasRunningTimers = () => {
|
||||||
return runningTimers.length > 0;
|
return Array.isArray(runningTimers) && runningTimers.length > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const timerCount = () => {
|
const timerCount = () => {
|
||||||
return runningTimers.length;
|
return Array.isArray(runningTimers) ? runningTimers.length : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStopTimer = (taskId: string) => {
|
const handleStopTimer = (taskId: string) => {
|
||||||
if (!socket) return;
|
if (!socket) {
|
||||||
|
logError('Socket not available for stopping timer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId }));
|
if (!taskId) {
|
||||||
dispatch(updateTaskTimeTracking({ taskId, timeTracking: null }));
|
logError('Invalid task ID for stopping timer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId }));
|
||||||
|
dispatch(updateTaskTimeTracking({ taskId, timeTracking: null }));
|
||||||
|
} catch (error) {
|
||||||
|
logError(`Error stopping timer for task ${taskId}`, error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropdownContent = (
|
const renderDropdownContent = () => {
|
||||||
<div
|
try {
|
||||||
style={{
|
if (error) {
|
||||||
width: 350,
|
return (
|
||||||
maxHeight: 400,
|
<div style={{ padding: 16, textAlign: 'center', width: 350 }}>
|
||||||
overflow: 'auto',
|
<Text type="danger">Error loading timers</Text>
|
||||||
backgroundColor: token.colorBgElevated,
|
|
||||||
borderRadius: token.borderRadius,
|
|
||||||
boxShadow: token.boxShadowSecondary,
|
|
||||||
border: `1px solid ${token.colorBorderSecondary}`
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{runningTimers.length === 0 ? (
|
|
||||||
<div style={{ padding: 16, textAlign: 'center' }}>
|
|
||||||
<Text type="secondary">No running timers</Text>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<List
|
|
||||||
dataSource={runningTimers}
|
|
||||||
renderItem={(timer) => (
|
|
||||||
<List.Item
|
|
||||||
style={{
|
|
||||||
padding: '12px 16px',
|
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
|
||||||
backgroundColor: 'transparent'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ width: '100%' }}>
|
|
||||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
|
||||||
<Text strong style={{ fontSize: 14, color: token.colorText }}>
|
|
||||||
{timer.task_name}
|
|
||||||
</Text>
|
|
||||||
<div style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
backgroundColor: token.colorPrimaryBg,
|
|
||||||
color: token.colorPrimary,
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: token.borderRadiusSM,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 500,
|
|
||||||
marginTop: 2
|
|
||||||
}}>
|
|
||||||
{timer.project_name}
|
|
||||||
</div>
|
|
||||||
{timer.parent_task_name && (
|
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
||||||
Parent: {timer.parent_task_name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
||||||
Started: {moment(timer.start_time).format('HH:mm')}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: token.colorPrimary,
|
|
||||||
fontFamily: 'monospace'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentTimes[timer.task_id] || '00:00:00'}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<StopOutlined />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleStopTimer(timer.task_id);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
backgroundColor: token.colorErrorBg,
|
|
||||||
borderColor: token.colorError,
|
|
||||||
color: token.colorError,
|
|
||||||
fontWeight: 500
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{runningTimers.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Divider style={{ margin: 0, borderColor: token.colorBorderSecondary }} />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
textAlign: 'center',
|
|
||||||
backgroundColor: token.colorFillQuaternary,
|
|
||||||
borderBottomLeftRadius: token.borderRadius,
|
|
||||||
borderBottomRightRadius: token.borderRadius
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
||||||
{runningTimers.length} timer{runningTimers.length !== 1 ? 's' : ''} running
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
);
|
||||||
)}
|
}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<div
|
||||||
popupRender={() => dropdownContent}
|
style={{
|
||||||
trigger={['click']}
|
width: 350,
|
||||||
placement="bottomRight"
|
maxHeight: 400,
|
||||||
open={dropdownOpen}
|
overflow: 'auto',
|
||||||
onOpenChange={(open) => {
|
backgroundColor: token.colorBgElevated,
|
||||||
setDropdownOpen(open);
|
borderRadius: token.borderRadius,
|
||||||
if (open) {
|
boxShadow: token.boxShadowSecondary,
|
||||||
fetchRunningTimers();
|
border: `1px solid ${token.colorBorderSecondary}`
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{!Array.isArray(runningTimers) || runningTimers.length === 0 ? (
|
||||||
<Tooltip title="Running Timers">
|
<div style={{ padding: 16, textAlign: 'center' }}>
|
||||||
|
<Text type="secondary">No running timers</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<List
|
||||||
|
dataSource={runningTimers}
|
||||||
|
renderItem={(timer) => {
|
||||||
|
if (!timer || !timer.task_id) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||||
|
<Text strong style={{ fontSize: 14, color: token.colorText }}>
|
||||||
|
{timer.task_name || 'Unnamed Task'}
|
||||||
|
</Text>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
backgroundColor: token.colorPrimaryBg,
|
||||||
|
color: token.colorPrimary,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: token.borderRadiusSM,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginTop: 2
|
||||||
|
}}>
|
||||||
|
{timer.project_name || 'Unnamed Project'}
|
||||||
|
</div>
|
||||||
|
{timer.parent_task_name && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
Parent: {timer.parent_task_name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
Started: {timer.start_time ? moment(timer.start_time).format('HH:mm') : '--:--'}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: token.colorPrimary,
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentTimes[timer.task_id] || '00:00:00'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<StopOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleStopTimer(timer.task_id);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
backgroundColor: token.colorErrorBg,
|
||||||
|
borderColor: token.colorError,
|
||||||
|
color: token.colorError,
|
||||||
|
fontWeight: 500
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasRunningTimers() && (
|
||||||
|
<>
|
||||||
|
<Divider style={{ margin: 0, borderColor: token.colorBorderSecondary }} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
backgroundColor: token.colorFillQuaternary,
|
||||||
|
borderBottomLeftRadius: token.borderRadius,
|
||||||
|
borderBottomRightRadius: token.borderRadius
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{timerCount()} timer{timerCount() !== 1 ? 's' : ''} running
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error rendering dropdown content', error);
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16, textAlign: 'center', width: 350 }}>
|
||||||
|
<Text type="danger">Error rendering timers</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDropdownOpenChange = (open: boolean) => {
|
||||||
|
try {
|
||||||
|
setDropdownOpen(open);
|
||||||
|
if (open) {
|
||||||
|
fetchRunningTimers();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error handling dropdown open change', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
popupRender={() => renderDropdownContent()}
|
||||||
|
trigger={['click']}
|
||||||
|
placement="bottomRight"
|
||||||
|
open={dropdownOpen}
|
||||||
|
onOpenChange={handleDropdownOpenChange}
|
||||||
|
>
|
||||||
|
<Tooltip title="Running Timers">
|
||||||
|
<Button
|
||||||
|
style={{ height: '62px', width: '60px' }}
|
||||||
|
type="text"
|
||||||
|
icon={
|
||||||
|
hasRunningTimers() ? (
|
||||||
|
<Badge count={timerCount()}>
|
||||||
|
<ClockCircleOutlined style={{ fontSize: 20 }} />
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<ClockCircleOutlined style={{ fontSize: 20 }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error rendering TimerButton', error);
|
||||||
|
return (
|
||||||
|
<Tooltip title="Timer Error">
|
||||||
<Button
|
<Button
|
||||||
style={{ height: '62px', width: '60px' }}
|
style={{ height: '62px', width: '60px' }}
|
||||||
type="text"
|
type="text"
|
||||||
icon={
|
icon={<ClockCircleOutlined style={{ fontSize: 20 }} />}
|
||||||
hasRunningTimers() ? (
|
disabled
|
||||||
<Badge count={timerCount()}>
|
|
||||||
<ClockCircleOutlined style={{ fontSize: 20 }} />
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<ClockCircleOutlined style={{ fontSize: 20 }} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Dropdown>
|
);
|
||||||
);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TimerButton;
|
export default TimerButton;
|
||||||
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 { IProjectCategory } from '@/types/project/projectCategory.types';
|
||||||
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
|
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
|
||||||
import { IProjectManager } from '@/types/project/projectManager.types';
|
import { IProjectManager } from '@/types/project/projectManager.types';
|
||||||
|
import { IGroupedProjectsViewModel } from '@/types/project/groupedProjectsViewModel.types';
|
||||||
|
|
||||||
interface ProjectState {
|
interface ProjectState {
|
||||||
projects: {
|
projects: {
|
||||||
data: IProjectViewModel[];
|
data: IProjectViewModel[];
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
groupedProjects: {
|
||||||
|
data: IGroupedProjectsViewModel | null;
|
||||||
|
loading: boolean;
|
||||||
|
};
|
||||||
categories: IProjectCategory[];
|
categories: IProjectCategory[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
creatingProject: boolean;
|
creatingProject: boolean;
|
||||||
@@ -29,6 +34,17 @@ interface ProjectState {
|
|||||||
statuses: string | null;
|
statuses: string | null;
|
||||||
categories: string | null;
|
categories: string | null;
|
||||||
};
|
};
|
||||||
|
groupedRequestParams: {
|
||||||
|
index: number;
|
||||||
|
size: number;
|
||||||
|
field: string;
|
||||||
|
order: string;
|
||||||
|
search: string;
|
||||||
|
groupBy: string;
|
||||||
|
filter: number;
|
||||||
|
statuses: string | null;
|
||||||
|
categories: string | null;
|
||||||
|
};
|
||||||
projectManagers: IProjectManager[];
|
projectManagers: IProjectManager[];
|
||||||
projectManagersLoading: boolean;
|
projectManagersLoading: boolean;
|
||||||
}
|
}
|
||||||
@@ -38,6 +54,10 @@ const initialState: ProjectState = {
|
|||||||
data: [],
|
data: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
},
|
},
|
||||||
|
groupedProjects: {
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
categories: [],
|
categories: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
creatingProject: false,
|
creatingProject: false,
|
||||||
@@ -56,6 +76,17 @@ const initialState: ProjectState = {
|
|||||||
statuses: null,
|
statuses: null,
|
||||||
categories: null,
|
categories: null,
|
||||||
},
|
},
|
||||||
|
groupedRequestParams: {
|
||||||
|
index: 1,
|
||||||
|
size: DEFAULT_PAGE_SIZE,
|
||||||
|
field: 'name',
|
||||||
|
order: 'ascend',
|
||||||
|
search: '',
|
||||||
|
groupBy: '',
|
||||||
|
filter: 0,
|
||||||
|
statuses: null,
|
||||||
|
categories: null,
|
||||||
|
},
|
||||||
projectManagers: [],
|
projectManagers: [],
|
||||||
projectManagersLoading: false,
|
projectManagersLoading: false,
|
||||||
};
|
};
|
||||||
@@ -98,6 +129,46 @@ export const fetchProjects = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Create async thunk for fetching grouped projects
|
||||||
|
export const fetchGroupedProjects = createAsyncThunk(
|
||||||
|
'projects/fetchGroupedProjects',
|
||||||
|
async (
|
||||||
|
params: {
|
||||||
|
index: number;
|
||||||
|
size: number;
|
||||||
|
field: string;
|
||||||
|
order: string;
|
||||||
|
search: string;
|
||||||
|
groupBy: string;
|
||||||
|
filter: number;
|
||||||
|
statuses: string | null;
|
||||||
|
categories: string | null;
|
||||||
|
},
|
||||||
|
{ rejectWithValue }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const groupedProjectsResponse = await projectsApiService.getGroupedProjects(
|
||||||
|
params.index,
|
||||||
|
params.size,
|
||||||
|
params.field,
|
||||||
|
params.order,
|
||||||
|
params.search,
|
||||||
|
params.groupBy,
|
||||||
|
params.filter,
|
||||||
|
params.statuses,
|
||||||
|
params.categories
|
||||||
|
);
|
||||||
|
return groupedProjectsResponse.body;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fetch Grouped Projects', error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
return rejectWithValue('Failed to fetch grouped projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const toggleFavoriteProject = createAsyncThunk(
|
export const toggleFavoriteProject = createAsyncThunk(
|
||||||
'projects/toggleFavoriteProject',
|
'projects/toggleFavoriteProject',
|
||||||
async (id: string, { rejectWithValue }) => {
|
async (id: string, { rejectWithValue }) => {
|
||||||
@@ -131,7 +202,7 @@ export const createProject = createAsyncThunk(
|
|||||||
export const updateProject = createAsyncThunk(
|
export const updateProject = createAsyncThunk(
|
||||||
'projects/updateProject',
|
'projects/updateProject',
|
||||||
async ({ id, project }: { id: string; project: IProjectViewModel }, { rejectWithValue }) => {
|
async ({ id, project }: { id: string; project: IProjectViewModel }, { rejectWithValue }) => {
|
||||||
const response = await projectsApiService.updateProject(id, project);
|
const response = await projectsApiService.updateProject({ id, ...project });
|
||||||
return response.body;
|
return response.body;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -196,6 +267,12 @@ const projectSlice = createSlice({
|
|||||||
...action.payload,
|
...action.payload,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
setGroupedRequestParams: (state, action: PayloadAction<Partial<ProjectState['groupedRequestParams']>>) => {
|
||||||
|
state.groupedRequestParams = {
|
||||||
|
...state.groupedRequestParams,
|
||||||
|
...action.payload,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder
|
builder
|
||||||
@@ -213,6 +290,16 @@ const projectSlice = createSlice({
|
|||||||
.addCase(fetchProjects.rejected, state => {
|
.addCase(fetchProjects.rejected, state => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
})
|
})
|
||||||
|
.addCase(fetchGroupedProjects.pending, state => {
|
||||||
|
state.groupedProjects.loading = true;
|
||||||
|
})
|
||||||
|
.addCase(fetchGroupedProjects.fulfilled, (state, action) => {
|
||||||
|
state.groupedProjects.loading = false;
|
||||||
|
state.groupedProjects.data = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(fetchGroupedProjects.rejected, state => {
|
||||||
|
state.groupedProjects.loading = false;
|
||||||
|
})
|
||||||
.addCase(createProject.pending, state => {
|
.addCase(createProject.pending, state => {
|
||||||
state.creatingProject = true;
|
state.creatingProject = true;
|
||||||
})
|
})
|
||||||
@@ -248,5 +335,6 @@ export const {
|
|||||||
setFilteredCategories,
|
setFilteredCategories,
|
||||||
setFilteredStatuses,
|
setFilteredStatuses,
|
||||||
setRequestParams,
|
setRequestParams,
|
||||||
|
setGroupedRequestParams,
|
||||||
} = projectSlice.actions;
|
} = projectSlice.actions;
|
||||||
export default projectSlice.reducer;
|
export default projectSlice.reducer;
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
import { useMemo, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
DragEndEvent,
|
|
||||||
DragOverEvent,
|
|
||||||
DragStartEvent,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
KeyboardSensor,
|
|
||||||
TouchSensor,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
|
||||||
import { updateTaskStatus } from '@/features/tasks/tasks.slice';
|
|
||||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
|
||||||
|
|
||||||
export const useTaskDragAndDrop = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
// Memoize the selector to prevent unnecessary rerenders
|
|
||||||
const taskGroups = useAppSelector(state => state.taskReducer.taskGroups);
|
|
||||||
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
|
|
||||||
|
|
||||||
// Memoize sensors configuration for better performance
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 8,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
}),
|
|
||||||
useSensor(TouchSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
delay: 250,
|
|
||||||
tolerance: 5,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
||||||
// Add visual feedback for drag start
|
|
||||||
const { active } = event;
|
|
||||||
if (active) {
|
|
||||||
document.body.style.cursor = 'grabbing';
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
|
||||||
// Handle drag over logic if needed
|
|
||||||
// This can be used for visual feedback during drag
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
|
||||||
(event: DragEndEvent) => {
|
|
||||||
// Reset cursor
|
|
||||||
document.body.style.cursor = '';
|
|
||||||
|
|
||||||
const { active, over } = event;
|
|
||||||
|
|
||||||
if (!active || !over || !taskGroups) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const activeId = active.id as string;
|
|
||||||
const overId = over.id as string;
|
|
||||||
|
|
||||||
// Find the task being dragged
|
|
||||||
let draggedTask: IProjectTask | null = null;
|
|
||||||
let sourceGroupId: string | null = null;
|
|
||||||
|
|
||||||
for (const group of taskGroups) {
|
|
||||||
const task = group.tasks?.find((t: IProjectTask) => t.id === activeId);
|
|
||||||
if (task) {
|
|
||||||
draggedTask = task;
|
|
||||||
sourceGroupId = group.id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!draggedTask || !sourceGroupId) {
|
|
||||||
console.warn('Could not find dragged task');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine target group
|
|
||||||
let targetGroupId: string | null = null;
|
|
||||||
|
|
||||||
// Check if dropped on a group container
|
|
||||||
const targetGroup = taskGroups.find((group: ITaskListGroup) => group.id === overId);
|
|
||||||
if (targetGroup) {
|
|
||||||
targetGroupId = targetGroup.id;
|
|
||||||
} else {
|
|
||||||
// Check if dropped on another task
|
|
||||||
for (const group of taskGroups) {
|
|
||||||
const targetTask = group.tasks?.find((t: IProjectTask) => t.id === overId);
|
|
||||||
if (targetTask) {
|
|
||||||
targetGroupId = group.id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetGroupId || targetGroupId === sourceGroupId) {
|
|
||||||
return; // No change needed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update task status based on group change
|
|
||||||
const targetGroupData = taskGroups.find((group: ITaskListGroup) => group.id === targetGroupId);
|
|
||||||
if (targetGroupData && groupBy === 'status') {
|
|
||||||
const updatePayload: any = {
|
|
||||||
task_id: draggedTask.id,
|
|
||||||
status_id: targetGroupData.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (draggedTask.parent_task_id) {
|
|
||||||
updatePayload.parent_task = draggedTask.parent_task_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(updateTaskStatus(updatePayload));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error handling drag end:', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[taskGroups, groupBy, dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize the drag and drop configuration
|
|
||||||
const dragAndDropConfig = useMemo(
|
|
||||||
() => ({
|
|
||||||
sensors,
|
|
||||||
onDragStart: handleDragStart,
|
|
||||||
onDragOver: handleDragOver,
|
|
||||||
onDragEnd: handleDragEnd,
|
|
||||||
}),
|
|
||||||
[sensors, handleDragStart, handleDragOver, handleDragEnd]
|
|
||||||
);
|
|
||||||
|
|
||||||
return dragAndDropConfig;
|
|
||||||
};
|
|
||||||
@@ -57,20 +57,31 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const calculateEndDate = (dueDate: string): Date | undefined => {
|
const calculateEndDate = (dueDate: string): string | undefined => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
let targetDate: Date;
|
||||||
|
|
||||||
switch (dueDate) {
|
switch (dueDate) {
|
||||||
case 'Today':
|
case 'Today':
|
||||||
return today;
|
targetDate = new Date(today);
|
||||||
|
break;
|
||||||
case 'Tomorrow':
|
case 'Tomorrow':
|
||||||
return new Date(today.setDate(today.getDate() + 1));
|
targetDate = new Date(today);
|
||||||
|
targetDate.setDate(today.getDate() + 1);
|
||||||
|
break;
|
||||||
case 'Next Week':
|
case 'Next Week':
|
||||||
return new Date(today.setDate(today.getDate() + 7));
|
targetDate = new Date(today);
|
||||||
|
targetDate.setDate(today.getDate() + 7);
|
||||||
|
break;
|
||||||
case 'Next Month':
|
case 'Next Month':
|
||||||
return new Date(today.setMonth(today.getMonth() + 1));
|
targetDate = new Date(today);
|
||||||
|
targetDate.setMonth(today.getMonth() + 1);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return targetDate.toISOString().split('T')[0]; // Returns YYYY-MM-DD format
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectOptions = [
|
const projectOptions = [
|
||||||
@@ -82,12 +93,16 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const handleTaskSubmit = (values: { name: string; project: string; dueDate: string }) => {
|
const handleTaskSubmit = (values: { name: string; project: string; dueDate: string }) => {
|
||||||
const newTask: IHomeTaskCreateRequest = {
|
const endDate = calendarView
|
||||||
|
? homeTasksConfig.selected_date?.format('YYYY-MM-DD')
|
||||||
|
: calculateEndDate(values.dueDate);
|
||||||
|
|
||||||
|
const newTask = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
project_id: values.project,
|
project_id: values.project,
|
||||||
reporter_id: currentSession?.id,
|
reporter_id: currentSession?.id,
|
||||||
team_id: currentSession?.team_id,
|
team_id: currentSession?.team_id,
|
||||||
end_date: (calendarView ? homeTasksConfig.selected_date?.format('YYYY-MM-DD') : calculateEndDate(values.dueDate)),
|
end_date: endDate || new Date().toISOString().split('T')[0], // Fallback to today if undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(newTask));
|
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(newTask));
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleSelectTask = useCallback((task : IMyTask) => {
|
const handleSelectTask = useCallback((task: IMyTask) => {
|
||||||
dispatch(setSelectedTaskId(task.id || ''));
|
dispatch(setSelectedTaskId(task.id || ''));
|
||||||
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
|
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
|
||||||
dispatch(setProjectId(task.project_id || ''));
|
dispatch(setProjectId(task.project_id || ''));
|
||||||
@@ -155,7 +155,7 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
return (
|
return (
|
||||||
<Tooltip title={record.project_name}>
|
<Tooltip title={record.project_name}>
|
||||||
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth:120 }} ellipsis={{ tooltip: true }}>
|
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth: 120 }} ellipsis={{ tooltip: true }}>
|
||||||
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
|
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
|
||||||
{record.project_name}
|
{record.project_name}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
@@ -271,10 +271,10 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
columns={columns as TableProps<IMyTask>['columns']}
|
columns={columns as TableProps<IMyTask>['columns']}
|
||||||
size="middle"
|
size="middle"
|
||||||
rowClassName={() => 'custom-row-height'}
|
rowClassName={() => 'custom-row-height'}
|
||||||
loading={homeTasksFetching && !skipAutoRefetch}
|
loading={homeTasksFetching && skipAutoRefetch}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ marginTop: 16, textAlign: 'right', display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ marginTop: 16, textAlign: 'right', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<Pagination
|
<Pagination
|
||||||
current={currentPage}
|
current={currentPage}
|
||||||
|
|||||||
@@ -25,3 +25,84 @@
|
|||||||
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-bottom > div > .ant-tabs-nav::before {
|
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-bottom > div > .ant-tabs-nav::before {
|
||||||
border: none;
|
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 React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ProjectViewType, ProjectGroupBy } from '@/types/project/project.types';
|
||||||
|
import { setViewMode, setGroupBy } from '@features/project/project-view-slice';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Empty,
|
Empty,
|
||||||
Flex,
|
Flex,
|
||||||
Input,
|
Input,
|
||||||
|
Pagination,
|
||||||
Segmented,
|
Segmented,
|
||||||
|
Select,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Table,
|
Table,
|
||||||
TablePaginationConfig,
|
TablePaginationConfig,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { PageHeader } from '@ant-design/pro-components';
|
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 type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||||
|
|
||||||
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
||||||
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
||||||
import TableColumns from '@/components/project-list/TableColumns';
|
import { ColumnsType } from 'antd/es/table';
|
||||||
|
import { ColumnFilterItem } from 'antd/es/table/interface';
|
||||||
|
import Avatars from '@/components/avatars/avatars';
|
||||||
|
import { ActionButtons } from '@/components/project-list/project-list-table/project-list-actions/project-list-actions';
|
||||||
|
import { CategoryCell } from '@/components/project-list/project-list-table/project-list-category/project-list-category';
|
||||||
|
import { ProgressListProgress } from '@/components/project-list/project-list-table/project-list-progress/progress-list-progress';
|
||||||
|
import { ProjectListUpdatedAt } from '@/components/project-list/project-list-table/project-list-updated-at/project-list-updated';
|
||||||
|
import { ProjectNameCell } from '@/components/project-list/project-list-table/project-name/project-name-cell';
|
||||||
|
import { ProjectRateCell } from '@/components/project-list/project-list-table/project-list-favorite/project-rate-cell';
|
||||||
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
|
|
||||||
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
|
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
|
||||||
|
|
||||||
@@ -43,6 +61,8 @@ import {
|
|||||||
setFilteredCategories,
|
setFilteredCategories,
|
||||||
setFilteredStatuses,
|
setFilteredStatuses,
|
||||||
setRequestParams,
|
setRequestParams,
|
||||||
|
setGroupedRequestParams,
|
||||||
|
fetchGroupedProjects,
|
||||||
} from '@/features/projects/projectsSlice';
|
} from '@/features/projects/projectsSlice';
|
||||||
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
|
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
|
||||||
import { fetchProjectCategories } from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
|
import { fetchProjectCategories } from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
|
||||||
@@ -50,12 +70,22 @@ import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/pr
|
|||||||
import { setProjectId, setStatuses } from '@/features/project/project.slice';
|
import { setProjectId, setStatuses } from '@/features/project/project.slice';
|
||||||
import { setProject } from '@/features/project/project.slice';
|
import { setProject } from '@/features/project/project.slice';
|
||||||
import { createPortal } from 'react-dom';
|
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 { 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 ProjectList: React.FC = () => {
|
||||||
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
|
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const { t } = useTranslation('all-project-list');
|
const { t } = useTranslation('all-project-list');
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -63,6 +93,23 @@ const ProjectList: React.FC = () => {
|
|||||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
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(() => {
|
const getFilterIndex = useCallback(() => {
|
||||||
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
|
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -76,42 +123,139 @@ const ProjectList: React.FC = () => {
|
|||||||
localStorage.setItem(PROJECT_SORT_ORDER, order);
|
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), []);
|
const filters = useMemo(() => Object.values(IProjectFilter), []);
|
||||||
|
|
||||||
// Create translated segment options for the filters
|
|
||||||
const segmentOptions = useMemo(() => {
|
const segmentOptions = useMemo(() => {
|
||||||
return filters.map(filter => ({
|
return filters.map(filter => ({
|
||||||
value: filter,
|
value: filter,
|
||||||
label: t(filter.toLowerCase())
|
label: t(filter.toLowerCase()),
|
||||||
}));
|
}));
|
||||||
}, [filters, t]);
|
}, [filters, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
const viewToggleOptions = useMemo(
|
||||||
setIsLoading(loadingProjects || isFetchingProjects);
|
() => [
|
||||||
}, [loadingProjects, isFetchingProjects]);
|
{
|
||||||
|
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 groupByOptions = useMemo(
|
||||||
const filterIndex = getFilterIndex();
|
() => [
|
||||||
dispatch(setRequestParams({ filter: filterIndex }));
|
{
|
||||||
}, [dispatch, getFilterIndex]);
|
value: ProjectGroupBy.CATEGORY,
|
||||||
|
label: t('groupBy.category'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: ProjectGroupBy.CLIENT,
|
||||||
|
label: t('groupBy.client'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
// Memoize category filters to prevent unnecessary recalculations
|
||||||
trackMixpanelEvent(evt_projects_page_visit);
|
const categoryFilters = useMemo(() =>
|
||||||
refetchProjects();
|
createFilters(
|
||||||
}, [requestParams, refetchProjects]);
|
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(
|
const handleTableChange = useCallback(
|
||||||
(
|
(
|
||||||
@@ -124,7 +268,6 @@ const ProjectList: React.FC = () => {
|
|||||||
newParams.statuses = null;
|
newParams.statuses = null;
|
||||||
dispatch(setFilteredStatuses([]));
|
dispatch(setFilteredStatuses([]));
|
||||||
} else {
|
} else {
|
||||||
// dispatch(setFilteredStatuses(filters.status_id as Array<string>));
|
|
||||||
newParams.statuses = filters.status_id.join(' ');
|
newParams.statuses = filters.status_id.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +275,6 @@ const ProjectList: React.FC = () => {
|
|||||||
newParams.categories = null;
|
newParams.categories = null;
|
||||||
dispatch(setFilteredCategories([]));
|
dispatch(setFilteredCategories([]));
|
||||||
} else {
|
} else {
|
||||||
// dispatch(setFilteredCategories(filters.category_id as Array<string>));
|
|
||||||
newParams.categories = filters.category_id.join(' ');
|
newParams.categories = filters.category_id.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,66 +291,289 @@ const ProjectList: React.FC = () => {
|
|||||||
newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
|
newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
dispatch(setRequestParams(newParams));
|
dispatch(setRequestParams(newParams));
|
||||||
|
|
||||||
|
// Also update grouped request params to keep them in sync
|
||||||
|
dispatch(setGroupedRequestParams({
|
||||||
|
...groupedRequestParams,
|
||||||
|
statuses: newParams.statuses,
|
||||||
|
categories: newParams.categories,
|
||||||
|
order: newParams.order,
|
||||||
|
field: newParams.field,
|
||||||
|
index: newParams.index,
|
||||||
|
size: newParams.size,
|
||||||
|
}));
|
||||||
|
|
||||||
setFilteredInfo(filters);
|
setFilteredInfo(filters);
|
||||||
},
|
},
|
||||||
[setSortingValues]
|
[dispatch, setSortingValues, groupedRequestParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGroupedTableChange = useCallback(
|
||||||
|
(newPagination: TablePaginationConfig) => {
|
||||||
|
const newParams: Partial<typeof groupedRequestParams> = {
|
||||||
|
index: newPagination.current || 1,
|
||||||
|
size: newPagination.pageSize || DEFAULT_PAGE_SIZE,
|
||||||
|
};
|
||||||
|
dispatch(setGroupedRequestParams(newParams));
|
||||||
|
},
|
||||||
|
[dispatch, groupedRequestParams]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
trackMixpanelEvent(evt_projects_refresh_click);
|
trackMixpanelEvent(evt_projects_refresh_click);
|
||||||
refetchProjects();
|
if (viewMode === ProjectViewType.LIST) {
|
||||||
}, [refetchProjects, requestParams]);
|
refetchProjects();
|
||||||
|
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
|
||||||
|
dispatch(fetchGroupedProjects(groupedRequestParams));
|
||||||
|
}
|
||||||
|
}, [trackMixpanelEvent, refetchProjects, viewMode, groupBy, dispatch, groupedRequestParams]);
|
||||||
|
|
||||||
const handleSegmentChange = useCallback(
|
const handleSegmentChange = useCallback(
|
||||||
(value: IProjectFilter) => {
|
(value: IProjectFilter) => {
|
||||||
const newFilterIndex = filters.indexOf(value);
|
const newFilterIndex = filters.indexOf(value);
|
||||||
setFilterIndex(newFilterIndex);
|
setFilterIndex(newFilterIndex);
|
||||||
|
|
||||||
|
// Update both request params for consistency
|
||||||
dispatch(setRequestParams({ filter: newFilterIndex }));
|
dispatch(setRequestParams({ filter: newFilterIndex }));
|
||||||
refetchProjects();
|
dispatch(setGroupedRequestParams({
|
||||||
|
...groupedRequestParams,
|
||||||
|
filter: newFilterIndex,
|
||||||
|
index: 1 // Reset to first page when changing filter
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Refresh data based on current view mode
|
||||||
|
if (viewMode === ProjectViewType.LIST) {
|
||||||
|
refetchProjects();
|
||||||
|
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
|
||||||
|
dispatch(fetchGroupedProjects({
|
||||||
|
...groupedRequestParams,
|
||||||
|
filter: newFilterIndex,
|
||||||
|
index: 1
|
||||||
|
}));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[filters, setFilterIndex, refetchProjects]
|
[filters, setFilterIndex, dispatch, refetchProjects, viewMode, groupBy, groupedRequestParams]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
// Debounced search for grouped projects
|
||||||
trackMixpanelEvent(evt_projects_search);
|
const debouncedGroupedSearch = useCallback(
|
||||||
const value = e.target.value;
|
debounce((params: typeof groupedRequestParams) => {
|
||||||
dispatch(setRequestParams({ search: value }));
|
if (groupBy) {
|
||||||
}, []);
|
dispatch(fetchGroupedProjects(params));
|
||||||
|
}
|
||||||
const paginationConfig = useMemo(
|
}, 300),
|
||||||
() => ({
|
[dispatch, groupBy]
|
||||||
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 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(setProject({} as IProjectViewModel));
|
||||||
dispatch(setProjectId(null));
|
dispatch(setProjectId(null));
|
||||||
};
|
}, [dispatch]);
|
||||||
const navigateToProject = (project_id: string | undefined, default_view: string | undefined) => {
|
|
||||||
|
const navigateToProject = useCallback((project_id: string | undefined, default_view: string | undefined) => {
|
||||||
if (project_id) {
|
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(() => {
|
useEffect(() => {
|
||||||
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
|
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
|
||||||
if (projectCategories.length === 0) dispatch(fetchProjectCategories());
|
if (projectCategories.length === 0) dispatch(fetchProjectCategories());
|
||||||
if (projectHealths.length === 0) dispatch(fetchProjectHealth());
|
if (projectHealths.length === 0) dispatch(fetchProjectHealth());
|
||||||
}, [requestParams]);
|
}, [dispatch, projectStatuses.length, projectCategories.length, projectHealths.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
className="site-page-header"
|
className="site-page-header"
|
||||||
title={`${projectsData?.body?.total || 0} ${t('projects')}`}
|
title={`${projectCount} ${t('projects')}`}
|
||||||
style={{ padding: '16px 0' }}
|
style={{ padding: '16px 0' }}
|
||||||
extra={
|
extra={
|
||||||
<Flex gap={8} align="center">
|
<Flex gap={8} align="center">
|
||||||
@@ -225,6 +590,19 @@ const ProjectList: React.FC = () => {
|
|||||||
defaultValue={filters[getFilterIndex()] ?? filters[0]}
|
defaultValue={filters[getFilterIndex()] ?? filters[0]}
|
||||||
onChange={handleSegmentChange}
|
onChange={handleSegmentChange}
|
||||||
/>
|
/>
|
||||||
|
<Segmented
|
||||||
|
options={viewToggleOptions}
|
||||||
|
value={viewMode}
|
||||||
|
onChange={handleViewToggle}
|
||||||
|
/>
|
||||||
|
{viewMode === ProjectViewType.GROUP && (
|
||||||
|
<Select
|
||||||
|
value={groupBy}
|
||||||
|
onChange={handleGroupByChange}
|
||||||
|
options={groupByOptions}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('placeholder')}
|
placeholder={t('placeholder')}
|
||||||
suffix={<SearchOutlined />}
|
suffix={<SearchOutlined />}
|
||||||
@@ -238,25 +616,44 @@ const ProjectList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Card className="project-card">
|
<Card className="project-card">
|
||||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
<Skeleton active loading={isLoading} className="mt-4 p-4">
|
||||||
<Table<IProjectViewModel>
|
{viewMode === ProjectViewType.LIST ? (
|
||||||
columns={TableColumns({
|
<Table<IProjectViewModel>
|
||||||
navigate,
|
columns={tableColumns}
|
||||||
filteredInfo,
|
dataSource={tableDataSource}
|
||||||
})}
|
rowKey={record => record.id || ''}
|
||||||
dataSource={projectsData?.body?.data || []}
|
loading={loadingProjects}
|
||||||
rowKey={record => record.id || ''}
|
size="small"
|
||||||
loading={loadingProjects}
|
onChange={handleTableChange}
|
||||||
size="small"
|
pagination={paginationConfig}
|
||||||
onChange={handleTableChange}
|
locale={{ emptyText }}
|
||||||
pagination={paginationConfig}
|
onRow={record => ({
|
||||||
locale={{ emptyText: <Empty description={t('noProjects')} /> }}
|
onClick: () => navigateToProject(record.id, record.team_member_default_view),
|
||||||
onRow={record => ({
|
})}
|
||||||
onClick: () => navigateToProject(record.id, record.team_member_default_view), // Navigate to project on row click
|
/>
|
||||||
})}
|
) : (
|
||||||
/>
|
<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>
|
</Skeleton>
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{createPortal(<ProjectDrawer onClose={handleDrawerClose} />, document.body, 'project-drawer')}
|
{createPortal(<ProjectDrawer onClose={handleDrawerClose} />, document.body, 'project-drawer')}
|
||||||
@@ -264,4 +661,4 @@ const ProjectList: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProjectList;
|
export default ProjectList;
|
||||||
@@ -1,12 +1,110 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { Checkbox, Flex, Tag, Tooltip } from 'antd';
|
import { Checkbox, Flex, Tag, Tooltip } from 'antd';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { HolderOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragStartEvent,
|
||||||
|
DragOverlay,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
KeyboardSensor,
|
||||||
|
TouchSensor,
|
||||||
|
UniqueIdentifier,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { reorderTasks } from '@/features/tasks/tasks.slice';
|
||||||
|
import { evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
|
||||||
|
|
||||||
|
// Draggable Row Component
|
||||||
|
interface DraggableRowProps {
|
||||||
|
task: IProjectTask;
|
||||||
|
visibleColumns: Array<{ key: string; width: number }>;
|
||||||
|
renderCell: (columnKey: string | number, task: IProjectTask, isSubtask?: boolean) => React.ReactNode;
|
||||||
|
hoverRow: string | null;
|
||||||
|
onRowHover: (taskId: string | null) => void;
|
||||||
|
isSubtask?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DraggableRow = ({
|
||||||
|
task,
|
||||||
|
visibleColumns,
|
||||||
|
renderCell,
|
||||||
|
hoverRow,
|
||||||
|
onRowHover,
|
||||||
|
isSubtask = false
|
||||||
|
}: DraggableRowProps) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: task.id as UniqueIdentifier,
|
||||||
|
data: {
|
||||||
|
type: 'task',
|
||||||
|
task,
|
||||||
|
},
|
||||||
|
disabled: isSubtask, // Disable drag for subtasks
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
zIndex: isDragging ? 1000 : 'auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="flex w-full border-b hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
onMouseEnter={() => onRowHover(task.id)}
|
||||||
|
onMouseLeave={() => onRowHover(null)}
|
||||||
|
>
|
||||||
|
<div className="sticky left-0 z-10 w-8 flex items-center justify-center">
|
||||||
|
{!isSubtask && (
|
||||||
|
<div {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing">
|
||||||
|
<HolderOutlined />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{visibleColumns.map(column => (
|
||||||
|
<div
|
||||||
|
key={column.key}
|
||||||
|
className={`flex items-center px-3 border-r ${
|
||||||
|
hoverRow === task.id ? 'bg-gray-50 dark:bg-gray-800' : ''
|
||||||
|
}`}
|
||||||
|
style={{ width: column.width }}
|
||||||
|
>
|
||||||
|
{renderCell(column.key, task, isSubtask)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const TaskListTable = ({
|
const TaskListTable = ({
|
||||||
taskListGroup,
|
taskListGroup,
|
||||||
|
tableId,
|
||||||
visibleColumns,
|
visibleColumns,
|
||||||
onTaskSelect,
|
onTaskSelect,
|
||||||
onTaskExpand,
|
onTaskExpand,
|
||||||
@@ -18,11 +116,38 @@ const TaskListTable = ({
|
|||||||
onTaskExpand?: (taskId: string) => void;
|
onTaskExpand?: (taskId: string) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
||||||
|
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
|
||||||
const tableRef = useRef<HTMLDivElement | null>(null);
|
const tableRef = useRef<HTMLDivElement | null>(null);
|
||||||
const parentRef = useRef<HTMLDivElement | null>(null);
|
const parentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
|
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||||
|
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { socket } = useSocket();
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||||
|
|
||||||
// Memoize all tasks including subtasks for virtualization
|
// Configure sensors for drag and drop
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 250,
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize all tasks including subtasks
|
||||||
const flattenedTasks = useMemo(() => {
|
const flattenedTasks = useMemo(() => {
|
||||||
return taskListGroup.tasks.reduce((acc: IProjectTask[], task: IProjectTask) => {
|
return taskListGroup.tasks.reduce((acc: IProjectTask[], task: IProjectTask) => {
|
||||||
acc.push(task);
|
acc.push(task);
|
||||||
@@ -33,13 +158,10 @@ const TaskListTable = ({
|
|||||||
}, []);
|
}, []);
|
||||||
}, [taskListGroup.tasks]);
|
}, [taskListGroup.tasks]);
|
||||||
|
|
||||||
// Virtual row renderer
|
// Get only main tasks for sortable context (exclude subtasks)
|
||||||
const rowVirtualizer = useVirtualizer({
|
const mainTasks = useMemo(() => {
|
||||||
count: flattenedTasks.length,
|
return taskListGroup.tasks.filter(task => !task.isSubtask);
|
||||||
getScrollElement: () => parentRef.current,
|
}, [taskListGroup.tasks]);
|
||||||
estimateSize: () => 42, // row height
|
|
||||||
overscan: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Memoize cell render functions
|
// Memoize cell render functions
|
||||||
const renderCell = useCallback(
|
const renderCell = useCallback(
|
||||||
@@ -54,7 +176,7 @@ const TaskListTable = ({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
task: () => (
|
task: () => (
|
||||||
<Flex align="center" className="pl-2">
|
<Flex align="center" className={isSubtask ? "pl-6" : "pl-2"}>
|
||||||
{task.name}
|
{task.name}
|
||||||
</Flex>
|
</Flex>
|
||||||
),
|
),
|
||||||
@@ -66,6 +188,77 @@ const TaskListTable = ({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle drag start
|
||||||
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id);
|
||||||
|
document.body.style.cursor = 'grabbing';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle drag end with socket integration
|
||||||
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
setActiveId(null);
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIndex = mainTasks.findIndex(task => task.id === active.id);
|
||||||
|
const overIndex = mainTasks.findIndex(task => task.id === over.id);
|
||||||
|
|
||||||
|
if (activeIndex !== -1 && overIndex !== -1) {
|
||||||
|
const activeTask = mainTasks[activeIndex];
|
||||||
|
const overTask = mainTasks[overIndex];
|
||||||
|
|
||||||
|
// Create updated task arrays
|
||||||
|
const updatedTasks = [...mainTasks];
|
||||||
|
updatedTasks.splice(activeIndex, 1);
|
||||||
|
updatedTasks.splice(overIndex, 0, activeTask);
|
||||||
|
|
||||||
|
// Dispatch Redux action for optimistic update
|
||||||
|
dispatch(reorderTasks({
|
||||||
|
activeGroupId: tableId,
|
||||||
|
overGroupId: tableId,
|
||||||
|
fromIndex: activeIndex,
|
||||||
|
toIndex: overIndex,
|
||||||
|
task: activeTask,
|
||||||
|
updatedSourceTasks: updatedTasks,
|
||||||
|
updatedTargetTasks: updatedTasks,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Emit socket event for backend persistence
|
||||||
|
if (socket && projectId && currentSession?.team_id) {
|
||||||
|
const toPos = overTask?.sort_order || mainTasks[mainTasks.length - 1]?.sort_order || -1;
|
||||||
|
|
||||||
|
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
||||||
|
project_id: projectId,
|
||||||
|
from_index: activeTask.sort_order,
|
||||||
|
to_index: toPos,
|
||||||
|
to_last_index: overIndex === mainTasks.length - 1,
|
||||||
|
from_group: tableId,
|
||||||
|
to_group: tableId,
|
||||||
|
group_by: groupBy,
|
||||||
|
task: activeTask,
|
||||||
|
team_id: currentSession.team_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track analytics event
|
||||||
|
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
mainTasks,
|
||||||
|
tableId,
|
||||||
|
dispatch,
|
||||||
|
socket,
|
||||||
|
projectId,
|
||||||
|
currentSession?.team_id,
|
||||||
|
groupBy,
|
||||||
|
trackMixpanelEvent
|
||||||
|
]);
|
||||||
|
|
||||||
// Memoize header rendering
|
// Memoize header rendering
|
||||||
const TableHeader = useMemo(
|
const TableHeader = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -94,48 +287,55 @@ const TaskListTable = ({
|
|||||||
target.classList.toggle('show-shadow', hasHorizontalShadow);
|
target.classList.toggle('show-shadow', hasHorizontalShadow);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
// Find active task for drag overlay
|
||||||
<div ref={parentRef} className="h-[400px] overflow-auto" onScroll={handleScroll}>
|
const activeTask = activeId ? flattenedTasks.find(task => task.id === activeId) : null;
|
||||||
{TableHeader}
|
|
||||||
|
|
||||||
<div
|
return (
|
||||||
ref={tableRef}
|
<DndContext
|
||||||
style={{
|
sensors={sensors}
|
||||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
onDragStart={handleDragStart}
|
||||||
width: '100%',
|
onDragEnd={handleDragEnd}
|
||||||
position: 'relative',
|
>
|
||||||
|
<div ref={parentRef} className="h-[400px] overflow-auto" onScroll={handleScroll}>
|
||||||
|
{TableHeader}
|
||||||
|
|
||||||
|
<SortableContext items={mainTasks.map(task => task.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div ref={tableRef} style={{ width: '100%' }}>
|
||||||
|
{flattenedTasks.map((task, index) => (
|
||||||
|
<DraggableRow
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
visibleColumns={visibleColumns}
|
||||||
|
renderCell={renderCell}
|
||||||
|
hoverRow={hoverRow}
|
||||||
|
onRowHover={setHoverRow}
|
||||||
|
isSubtask={task.isSubtask}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay
|
||||||
|
dropAnimation={{
|
||||||
|
duration: 200,
|
||||||
|
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{rowVirtualizer.getVirtualItems().map(virtualRow => {
|
{activeTask && (
|
||||||
const task = flattenedTasks[virtualRow.index];
|
<div className="bg-white dark:bg-gray-800 shadow-lg rounded border">
|
||||||
return (
|
<DraggableRow
|
||||||
<div
|
task={activeTask}
|
||||||
key={task.id}
|
visibleColumns={visibleColumns}
|
||||||
className="absolute top-0 left-0 flex w-full border-b hover:bg-gray-50 dark:hover:bg-gray-800"
|
renderCell={renderCell}
|
||||||
style={{
|
hoverRow={null}
|
||||||
height: 42,
|
onRowHover={() => {}}
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
isSubtask={activeTask.isSubtask}
|
||||||
}}
|
/>
|
||||||
>
|
</div>
|
||||||
<div className="sticky left-0 z-10 w-8 flex items-center justify-center">
|
)}
|
||||||
{/* <Checkbox checked={task.selected} /> */}
|
</DragOverlay>
|
||||||
</div>
|
</DndContext>
|
||||||
{visibleColumns.map(column => (
|
|
||||||
<div
|
|
||||||
key={column.key}
|
|
||||||
className={`flex items-center px-3 border-r ${
|
|
||||||
hoverRow === task.id ? 'bg-gray-50 dark:bg-gray-800' : ''
|
|
||||||
}`}
|
|
||||||
style={{ width: column.width }}
|
|
||||||
>
|
|
||||||
{renderCell(column.key, task, task.is_sub_task)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Row,
|
Row,
|
||||||
Column,
|
Column,
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -78,19 +77,6 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
|
|||||||
|
|
||||||
const { rows } = table.getRowModel();
|
const { rows } = table.getRowModel();
|
||||||
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
|
||||||
count: rows.length,
|
|
||||||
getScrollElement: () => tableContainerRef.current,
|
|
||||||
estimateSize: () => 50,
|
|
||||||
overscan: 20,
|
|
||||||
});
|
|
||||||
|
|
||||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
|
||||||
const totalSize = rowVirtualizer.getTotalSize();
|
|
||||||
const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
|
|
||||||
const paddingBottom =
|
|
||||||
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
|
|
||||||
|
|
||||||
const columnToggleItems = columns.map(column => ({
|
const columnToggleItems = columns.map(column => ({
|
||||||
key: column.id as string,
|
key: column.id as string,
|
||||||
label: (
|
label: (
|
||||||
@@ -125,6 +111,7 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
|
overflowY: 'auto',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -161,80 +148,75 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="table-body">
|
<div className="table-body">
|
||||||
{paddingTop > 0 && <div style={{ height: `${paddingTop}px` }} />}
|
{rows.map(row => (
|
||||||
{virtualRows.map(virtualRow => {
|
<React.Fragment key={row.id}>
|
||||||
const row = rows[virtualRow.index];
|
<div
|
||||||
return (
|
className="table-row"
|
||||||
<React.Fragment key={row.id}>
|
style={{
|
||||||
<div
|
'&:hover div': {
|
||||||
className="table-row"
|
background: `${token.colorFillAlter} !important`,
|
||||||
style={{
|
},
|
||||||
'&:hover div': {
|
}}
|
||||||
background: `${token.colorFillAlter} !important`,
|
>
|
||||||
},
|
{row.getVisibleCells().map((cell, index) => (
|
||||||
}}
|
<div
|
||||||
>
|
key={cell.id}
|
||||||
{row.getVisibleCells().map((cell, index) => (
|
className={`${cell.column.getIsPinned() === 'left' ? 'sticky left-0 z-10' : ''}`}
|
||||||
<div
|
style={{
|
||||||
key={cell.id}
|
width: cell.column.getSize(),
|
||||||
className={`${cell.column.getIsPinned() === 'left' ? 'sticky left-0 z-10' : ''}`}
|
position: index < 2 ? 'sticky' : 'relative',
|
||||||
style={{
|
left: 'auto',
|
||||||
width: cell.column.getSize(),
|
background: token.colorBgContainer,
|
||||||
position: index < 2 ? 'sticky' : 'relative',
|
color: token.colorText,
|
||||||
left: 'auto',
|
height: '42px',
|
||||||
background: token.colorBgContainer,
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
color: token.colorText,
|
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||||
height: '42px',
|
padding: '8px 0px 8px 8px',
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
}}
|
||||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
>
|
||||||
padding: '8px 0px 8px 8px',
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
}}
|
</div>
|
||||||
>
|
))}
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
</div>
|
||||||
</div>
|
{expandedRows[row.id] &&
|
||||||
))}
|
row.original.sub_tasks?.map(subTask => (
|
||||||
</div>
|
<div
|
||||||
{expandedRows[row.id] &&
|
key={subTask.task_key}
|
||||||
row.original.sub_tasks?.map(subTask => (
|
className="table-row"
|
||||||
<div
|
style={{
|
||||||
key={subTask.task_key}
|
'&:hover div': {
|
||||||
className="table-row"
|
background: `${token.colorFillAlter} !important`,
|
||||||
style={{
|
},
|
||||||
'&:hover div': {
|
}}
|
||||||
background: `${token.colorFillAlter} !important`,
|
>
|
||||||
},
|
{columns.map((col, index) => (
|
||||||
}}
|
<div
|
||||||
>
|
key={`${subTask.task_key}-${col.id}`}
|
||||||
{columns.map((col, index) => (
|
style={{
|
||||||
<div
|
width: col.getSize(),
|
||||||
key={`${subTask.task_key}-${col.id}`}
|
position: index < 2 ? 'sticky' : 'relative',
|
||||||
style={{
|
left: index < 2 ? `${index * col.getSize()}px` : 'auto',
|
||||||
width: col.getSize(),
|
background: token.colorBgContainer,
|
||||||
position: index < 2 ? 'sticky' : 'relative',
|
color: token.colorText,
|
||||||
left: index < 2 ? `${index * col.getSize()}px` : 'auto',
|
height: '42px',
|
||||||
background: token.colorBgContainer,
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
color: token.colorText,
|
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||||
height: '42px',
|
paddingLeft: index === 3 ? '32px' : '8px',
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
paddingRight: '8px',
|
||||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
}}
|
||||||
paddingLeft: index === 3 ? '32px' : '8px',
|
>
|
||||||
paddingRight: '8px',
|
{flexRender(col.cell, {
|
||||||
}}
|
getValue: () => subTask[col.id as keyof typeof subTask] ?? null,
|
||||||
>
|
row: { original: subTask } as Row<IProjectTask>,
|
||||||
{flexRender(col.cell, {
|
column: col as Column<IProjectTask>,
|
||||||
getValue: () => subTask[col.id as keyof typeof subTask] ?? null,
|
table,
|
||||||
row: { original: subTask } as Row<IProjectTask>,
|
})}
|
||||||
column: col as Column<IProjectTask>,
|
</div>
|
||||||
table,
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</React.Fragment>
|
||||||
</div>
|
))}
|
||||||
))}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{paddingBottom > 0 && <div style={{ height: `${paddingBottom}px` }} />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { TaskType } from '@/types/task.types';
|
|||||||
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
|
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
import './task-list-table-wrapper.css';
|
import './task-list-table-wrapper.css';
|
||||||
import TaskListTable from '../task-list-table-old/task-list-table-old';
|
import TaskListTable from '../table-v2';
|
||||||
import { MenuProps } from 'antd/lib';
|
import { MenuProps } from 'antd/lib';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||||
import TaskListCustom from '../task-list-custom';
|
import { columnList as defaultColumnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList';
|
||||||
|
|
||||||
type TaskListTableWrapperProps = {
|
type TaskListTableWrapperProps = {
|
||||||
taskList: ITaskListGroup;
|
taskList: ITaskListGroup;
|
||||||
@@ -37,6 +37,22 @@ const TaskListTableWrapper = ({
|
|||||||
// localization
|
// localization
|
||||||
const { t } = useTranslation('task-list-table');
|
const { t } = useTranslation('task-list-table');
|
||||||
|
|
||||||
|
// Get column visibility from Redux
|
||||||
|
const columnVisibilityList = useAppSelector(
|
||||||
|
state => state.projectViewTaskListColumnsReducer.columnList
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter visible columns and format them for table-v2
|
||||||
|
const visibleColumns = defaultColumnList
|
||||||
|
.filter(column => {
|
||||||
|
const visibilityConfig = columnVisibilityList.find(col => col.key === column.key);
|
||||||
|
return visibilityConfig?.isVisible ?? false;
|
||||||
|
})
|
||||||
|
.map(column => ({
|
||||||
|
key: column.key,
|
||||||
|
width: column.width,
|
||||||
|
}));
|
||||||
|
|
||||||
// function to handle toggle expand
|
// function to handle toggle expand
|
||||||
const handlToggleExpand = () => {
|
const handlToggleExpand = () => {
|
||||||
setIsExpanded(!isExpanded);
|
setIsExpanded(!isExpanded);
|
||||||
@@ -98,6 +114,14 @@ const TaskListTableWrapper = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const handleTaskSelect = (taskId: string) => {
|
||||||
|
console.log('Task selected:', taskId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTaskExpand = (taskId: string) => {
|
||||||
|
console.log('Task expanded:', taskId);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
wave={{ disabled: true }}
|
wave={{ disabled: true }}
|
||||||
@@ -172,11 +196,13 @@ const TaskListTableWrapper = ({
|
|||||||
key: groupId || '1',
|
key: groupId || '1',
|
||||||
className: `custom-collapse-content-box relative after:content after:absolute after:h-full after:w-1 ${color} after:z-10 after:top-0 after:left-0`,
|
className: `custom-collapse-content-box relative after:content after:absolute after:h-full after:w-1 ${color} after:z-10 after:top-0 after:left-0`,
|
||||||
children: (
|
children: (
|
||||||
<TaskListCustom
|
<TaskListTable
|
||||||
key={groupId}
|
key={groupId}
|
||||||
groupId={groupId}
|
taskListGroup={taskList}
|
||||||
tasks={taskList.tasks}
|
tableId={groupId || ''}
|
||||||
color={color || ''}
|
visibleColumns={visibleColumns}
|
||||||
|
onTaskSelect={handleTaskSelect}
|
||||||
|
onTaskExpand={handleTaskExpand}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import { ITaskListConfigV2, ITaskListGroup } from '@/types/tasks/taskList.types'
|
|||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { fetchTaskGroups } from '@/features/tasks/taskSlice';
|
import { fetchTaskGroups } from '@/features/tasks/taskSlice';
|
||||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||||
|
import TaskListTableWrapper from './task-list-table-wrapper/task-list-table-wrapper';
|
||||||
import { columnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList';
|
|
||||||
import StatusGroupTables from '../taskList/statusTables/StatusGroupTables';
|
|
||||||
|
|
||||||
const TaskList = () => {
|
const TaskList = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -31,6 +29,7 @@ const TaskList = () => {
|
|||||||
const onTaskExpand = (taskId: string) => {
|
const onTaskExpand = (taskId: string) => {
|
||||||
console.log('taskId:', taskId);
|
console.log('taskId:', taskId);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
const config: ITaskListConfigV2 = {
|
const config: ITaskListConfigV2 = {
|
||||||
@@ -54,9 +53,15 @@ const TaskList = () => {
|
|||||||
<Flex vertical gap={16}>
|
<Flex vertical gap={16}>
|
||||||
<TaskListFilters position="list" />
|
<TaskListFilters position="list" />
|
||||||
<Skeleton active loading={loadingGroups}>
|
<Skeleton active loading={loadingGroups}>
|
||||||
{/* {taskGroups.map((group: ITaskListGroup) => (
|
{taskGroups.map((group: ITaskListGroup) => (
|
||||||
|
<TaskListTableWrapper
|
||||||
))} */}
|
key={group.id}
|
||||||
|
taskList={group}
|
||||||
|
groupId={group.id}
|
||||||
|
name={group.name}
|
||||||
|
color={group.color_code}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortabl
|
|||||||
import BoardSectionCard from './board-section-card/board-section-card';
|
import BoardSectionCard from './board-section-card/board-section-card';
|
||||||
import BoardCreateSectionCard from './board-section-card/board-create-section-card';
|
import BoardCreateSectionCard from './board-section-card/board-create-section-card';
|
||||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
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 { setTaskAssignee, setTaskEndDate } from '@/features/task-drawer/task-drawer.slice';
|
||||||
import { fetchTaskAssignees } from '@/features/taskAttributes/taskMemberSlice';
|
import { fetchTaskAssignees } from '@/features/taskAttributes/taskMemberSlice';
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
@@ -113,4 +113,4 @@ const BoardSectionCardContainer = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BoardSectionCardContainer;
|
export default React.memo(BoardSectionCardContainer);
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
import { Col, Flex, Typography, List } from 'antd';
|
import { Col, Flex, Typography, List, Dropdown, MenuProps, Popconfirm } from 'antd';
|
||||||
|
import { UserAddOutlined, DeleteOutlined, ExclamationCircleFilled, InboxOutlined } from '@ant-design/icons';
|
||||||
import CustomAvatarGroup from '@/components/board/custom-avatar-group';
|
import CustomAvatarGroup from '@/components/board/custom-avatar-group';
|
||||||
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
|
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { colors } from '@/styles/colors';
|
||||||
|
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
||||||
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
|
import {
|
||||||
|
evt_project_task_list_context_menu_assign_me,
|
||||||
|
evt_project_task_list_context_menu_delete,
|
||||||
|
evt_project_task_list_context_menu_archive,
|
||||||
|
} from '@/shared/worklenz-analytics-events';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { deleteBoardTask, updateBoardTaskAssignee } from '@features/board/board-slice';
|
||||||
|
import { IBulkAssignRequest } from '@/types/tasks/bulk-action-bar.types';
|
||||||
|
|
||||||
interface IBoardSubTaskCardProps {
|
interface IBoardSubTaskCardProps {
|
||||||
subtask: IProjectTask;
|
subtask: IProjectTask;
|
||||||
@@ -14,48 +28,153 @@ interface IBoardSubTaskCardProps {
|
|||||||
|
|
||||||
const BoardSubTaskCard = ({ subtask, sectionId }: IBoardSubTaskCardProps) => {
|
const BoardSubTaskCard = ({ subtask, sectionId }: IBoardSubTaskCardProps) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const { t } = useTranslation('kanban-board');
|
||||||
|
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||||
|
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||||
|
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
|
||||||
const [subtaskDueDate, setSubtaskDueDate] = useState<Dayjs | null>(
|
const [subtaskDueDate, setSubtaskDueDate] = useState<Dayjs | null>(
|
||||||
subtask?.end_date ? dayjs(subtask?.end_date) : null
|
subtask?.end_date ? dayjs(subtask?.end_date) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCardClick = (e: React.MouseEvent, id: string) => {
|
const handleCardClick = (e: React.MouseEvent, id: string) => {
|
||||||
// Prevent the event from propagating to parent elements
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// Add a small delay to ensure it's a click and not the start of a drag
|
|
||||||
const clickTimeout = setTimeout(() => {
|
const clickTimeout = setTimeout(() => {
|
||||||
dispatch(setSelectedTaskId(id));
|
dispatch(setSelectedTaskId(id));
|
||||||
dispatch(setShowTaskDrawer(true));
|
dispatch(setShowTaskDrawer(true));
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
return () => clearTimeout(clickTimeout);
|
return () => clearTimeout(clickTimeout);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleAssignToMe = useCallback(async () => {
|
||||||
<List.Item
|
if (!projectId || !subtask.id || updatingAssignToMe) return;
|
||||||
key={subtask.id}
|
|
||||||
className="group"
|
try {
|
||||||
style={{
|
setUpdatingAssignToMe(true);
|
||||||
width: '100%',
|
const body: IBulkAssignRequest = {
|
||||||
}}
|
tasks: [subtask.id],
|
||||||
onClick={e => handleCardClick(e, subtask.id || '')}
|
project_id: projectId,
|
||||||
>
|
};
|
||||||
<Col span={10}>
|
const res = await taskListBulkActionsApiService.assignToMe(body);
|
||||||
<Typography.Text
|
if (res.done) {
|
||||||
style={{ fontWeight: 500, fontSize: 14 }}
|
trackMixpanelEvent(evt_project_task_list_context_menu_assign_me);
|
||||||
delete={subtask.status === 'done'}
|
dispatch(
|
||||||
ellipsis={{ expanded: false }}
|
updateBoardTaskAssignee({
|
||||||
|
body: res.body,
|
||||||
|
sectionId,
|
||||||
|
taskId: subtask.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error assigning task to me:', error);
|
||||||
|
} finally {
|
||||||
|
setUpdatingAssignToMe(false);
|
||||||
|
}
|
||||||
|
}, [projectId, subtask.id, updatingAssignToMe, dispatch, trackMixpanelEvent, sectionId]);
|
||||||
|
|
||||||
|
// const handleArchive = async () => {
|
||||||
|
// if (!projectId || !subtask.id) return;
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// const res = await taskListBulkActionsApiService.archiveTasks(
|
||||||
|
// {
|
||||||
|
// tasks: [subtask.id],
|
||||||
|
// project_id: projectId,
|
||||||
|
// },
|
||||||
|
// false
|
||||||
|
// );
|
||||||
|
|
||||||
|
// if (res.done) {
|
||||||
|
// trackMixpanelEvent(evt_project_task_list_context_menu_archive);
|
||||||
|
// dispatch(deleteBoardTask({ sectionId, taskId: subtask.id }));
|
||||||
|
// }
|
||||||
|
// } catch (error) {
|
||||||
|
// logger.error('Error archiving subtask:', error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!projectId || !subtask.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await taskListBulkActionsApiService.deleteTasks({ tasks: [subtask.id] }, projectId);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
|
||||||
|
dispatch(deleteBoardTask({ sectionId, taskId: subtask.id }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting subtask:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const items: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<UserAddOutlined />
|
||||||
|
|
||||||
|
<Typography.Text>{t('assignToMe')}</Typography.Text>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
key: '1',
|
||||||
|
onClick: () => handleAssignToMe(),
|
||||||
|
disabled: updatingAssignToMe,
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// label: (
|
||||||
|
// <span>
|
||||||
|
// <InboxOutlined />
|
||||||
|
//
|
||||||
|
// <Typography.Text>{t('archive')}</Typography.Text>
|
||||||
|
// </span>
|
||||||
|
// ),
|
||||||
|
// key: '2',
|
||||||
|
// onClick: () => handleArchive(),
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<Popconfirm
|
||||||
|
title={t('deleteConfirmationTitle')}
|
||||||
|
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||||
|
okText={t('deleteConfirmationOk')}
|
||||||
|
cancelText={t('deleteConfirmationCancel')}
|
||||||
|
onConfirm={() => handleDelete()}
|
||||||
>
|
>
|
||||||
{subtask.name}
|
<DeleteOutlined />
|
||||||
</Typography.Text>
|
|
||||||
</Col>
|
{t('delete')}
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
key: '3',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
<Flex gap={8} justify="end" style={{ width: '100%' }}>
|
return (
|
||||||
<CustomAvatarGroup task={subtask} sectionId={sectionId} />
|
<Dropdown menu={{ items }} trigger={['contextMenu']}>
|
||||||
|
<List.Item
|
||||||
|
key={subtask.id}
|
||||||
|
className="group"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
onClick={e => handleCardClick(e, subtask.id || '')}
|
||||||
|
>
|
||||||
|
<Col span={10}>
|
||||||
|
<Typography.Text
|
||||||
|
style={{ fontWeight: 500, fontSize: 14 }}
|
||||||
|
delete={subtask.status === 'done'}
|
||||||
|
ellipsis={{ expanded: false }}
|
||||||
|
>
|
||||||
|
{subtask.name}
|
||||||
|
</Typography.Text>
|
||||||
|
</Col>
|
||||||
|
|
||||||
<CustomDueDatePicker task={subtask} onDateChange={setSubtaskDueDate} />
|
<Flex gap={8} justify="end" style={{ width: '100%' }}>
|
||||||
</Flex>
|
<CustomAvatarGroup task={subtask} sectionId={sectionId} />
|
||||||
</List.Item>
|
<CustomDueDatePicker task={subtask} onDateChange={setSubtaskDueDate} />
|
||||||
|
</Flex>
|
||||||
|
</List.Item>
|
||||||
|
</Dropdown>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ import {
|
|||||||
evt_project_task_list_context_menu_delete,
|
evt_project_task_list_context_menu_delete,
|
||||||
} from '@/shared/worklenz-analytics-events';
|
} from '@/shared/worklenz-analytics-events';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import PrioritySection from '@/components/board/taskCard/priority-section/priority-section';
|
||||||
|
|
||||||
interface IBoardViewTaskCardProps {
|
interface IBoardViewTaskCardProps {
|
||||||
task: IProjectTask;
|
task: IProjectTask;
|
||||||
@@ -65,7 +67,7 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { t } = useTranslation('kanban-board');
|
const { t } = useTranslation('kanban-board');
|
||||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||||
const [isSubTaskShow, setIsSubTaskShow] = useState(false);
|
const [isSubTaskShow, setIsSubTaskShow] = useState(false);
|
||||||
@@ -234,42 +236,11 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
|||||||
},
|
},
|
||||||
], [t, handleAssignToMe, handleArchive, handleDelete, updatingAssignToMe]);
|
], [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(() => {
|
const renderLabels = useMemo(() => {
|
||||||
if (!task?.labels?.length) return null;
|
if (!task?.labels?.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{task.labels.slice(0, 2).map((label: any) => (
|
{task.labels.slice(0, 2).map((label: any) => (
|
||||||
@@ -285,50 +256,49 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
|||||||
}, [task.labels, themeMode]);
|
}, [task.labels, themeMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown menu={{ items }} trigger={['contextMenu']}>
|
<Flex
|
||||||
<Flex
|
ref={setNodeRef}
|
||||||
ref={setNodeRef}
|
{...attributes}
|
||||||
{...attributes}
|
{...listeners}
|
||||||
{...listeners}
|
vertical
|
||||||
vertical
|
gap={12}
|
||||||
gap={12}
|
style={{
|
||||||
style={{
|
...style,
|
||||||
...style,
|
width: '100%',
|
||||||
width: '100%',
|
padding: 12,
|
||||||
padding: 12,
|
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
|
||||||
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
|
borderRadius: 6,
|
||||||
borderRadius: 6,
|
cursor: 'grab',
|
||||||
cursor: 'grab',
|
overflow: 'hidden',
|
||||||
overflow: 'hidden',
|
}}
|
||||||
}}
|
className={`group outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline board-task-card`}
|
||||||
className={`group outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline board-task-card`}
|
data-id={task.id}
|
||||||
onClick={e => handleCardClick(e, task.id || '')}
|
data-dragging={isDragging ? "true" : "false"}
|
||||||
data-id={task.id}
|
>
|
||||||
data-dragging={isDragging ? "true" : "false"}
|
<Dropdown menu={{ items }} trigger={['contextMenu']}>
|
||||||
>
|
{/* Task Card */}
|
||||||
{/* Labels and Progress */}
|
<Flex vertical gap={8}
|
||||||
<Flex align="center" justify="space-between">
|
onClick={e => handleCardClick(e, task.id || '')}>
|
||||||
<Flex>
|
{/* Labels and Progress */}
|
||||||
{renderLabels}
|
<Flex align="center" justify="space-between">
|
||||||
|
<Flex>
|
||||||
|
{renderLabels}
|
||||||
|
</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} />
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
<Flex gap={4} align="center">
|
||||||
|
{/* Action Icons */}
|
||||||
|
<PrioritySection task={task} />
|
||||||
|
<Typography.Text
|
||||||
|
style={{ fontWeight: 500 }}
|
||||||
|
ellipsis={{ tooltip: task.name }}
|
||||||
|
>
|
||||||
|
{task.name}
|
||||||
|
</Typography.Text>
|
||||||
</Flex>
|
</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} />
|
|
||||||
</Tooltip>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{/* Action Icons */}
|
|
||||||
<Flex gap={4}>
|
|
||||||
{priorityIcon}
|
|
||||||
<Typography.Text
|
|
||||||
style={{ fontWeight: 500 }}
|
|
||||||
ellipsis={{ tooltip: task.name }}
|
|
||||||
>
|
|
||||||
{task.name}
|
|
||||||
</Typography.Text>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<Flex vertical gap={8}>
|
|
||||||
<Flex
|
<Flex
|
||||||
align="center"
|
align="center"
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
@@ -366,47 +336,50 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{isSubTaskShow && (
|
|
||||||
<Flex vertical>
|
|
||||||
<Divider style={{ marginBlock: 0 }} />
|
|
||||||
<List>
|
|
||||||
{task.sub_tasks_loading && (
|
|
||||||
<List.Item>
|
|
||||||
<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} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{showNewSubtaskCard && (
|
|
||||||
<BoardCreateSubtaskCard
|
|
||||||
sectionId={sectionId}
|
|
||||||
parentTaskId={task.id || ''}
|
|
||||||
setShowNewSubtaskCard={setShowNewSubtaskCard}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
style={{
|
|
||||||
width: 'fit-content',
|
|
||||||
borderRadius: 6,
|
|
||||||
boxShadow: 'none',
|
|
||||||
}}
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={handleAddSubtaskClick}
|
|
||||||
>
|
|
||||||
{t('addSubtask', 'Add Subtask')}
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
|
</Dropdown>
|
||||||
|
{/* Subtask Section */}
|
||||||
|
<Flex vertical gap={8}>
|
||||||
|
{isSubTaskShow && (
|
||||||
|
<Flex vertical>
|
||||||
|
<Divider style={{ marginBlock: 0 }} />
|
||||||
|
<List>
|
||||||
|
{task.sub_tasks_loading && (
|
||||||
|
<List.Item>
|
||||||
|
<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} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showNewSubtaskCard && (
|
||||||
|
<BoardCreateSubtaskCard
|
||||||
|
sectionId={sectionId}
|
||||||
|
parentTaskId={task.id || ''}
|
||||||
|
setShowNewSubtaskCard={setShowNewSubtaskCard}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
style={{
|
||||||
|
width: 'fit-content',
|
||||||
|
borderRadius: 6,
|
||||||
|
boxShadow: 'none',
|
||||||
|
}}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddSubtaskClick}
|
||||||
|
>
|
||||||
|
{t('addSubtask', 'Add Subtask')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Dropdown>
|
</Flex>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
|
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
|
||||||
import { Flex, Skeleton } from 'antd';
|
import { Flex, Skeleton } from 'antd';
|
||||||
@@ -16,12 +16,16 @@ import {
|
|||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
DragOverEvent,
|
DragOverEvent,
|
||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
closestCorners,
|
closestCenter,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
MouseSensor,
|
MouseSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
|
getFirstCollision,
|
||||||
|
pointerWithin,
|
||||||
|
rectIntersection,
|
||||||
|
UniqueIdentifier,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import BoardViewTaskCard from './board-section/board-task-card/board-view-task-card';
|
import BoardViewTaskCard from './board-section/board-task-card/board-view-task-card';
|
||||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||||
@@ -36,6 +40,17 @@ import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-reque
|
|||||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
|
||||||
|
import { updateTaskPriority as updateBoardTaskPriority } from '@/features/board/board-slice';
|
||||||
|
interface DroppableContainer {
|
||||||
|
id: UniqueIdentifier;
|
||||||
|
data: {
|
||||||
|
current?: {
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const ProjectViewBoard = () => {
|
const ProjectViewBoard = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -47,7 +62,7 @@ const ProjectViewBoard = () => {
|
|||||||
const [currentTaskIndex, setCurrentTaskIndex] = useState(-1);
|
const [currentTaskIndex, setCurrentTaskIndex] = useState(-1);
|
||||||
// Add local loading state to immediately show skeleton
|
// Add local loading state to immediately show skeleton
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||||
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
|
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
|
||||||
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
||||||
@@ -57,6 +72,10 @@ const ProjectViewBoard = () => {
|
|||||||
|
|
||||||
// Store the original source group ID when drag starts
|
// Store the original source group ID when drag starts
|
||||||
const originalSourceGroupIdRef = useRef<string | null>(null);
|
const originalSourceGroupIdRef = useRef<string | null>(null);
|
||||||
|
const lastOverId = useRef<UniqueIdentifier | null>(null);
|
||||||
|
const recentlyMovedToNewContainer = useRef(false);
|
||||||
|
const [clonedItems, setClonedItems] = useState<any>(null);
|
||||||
|
const isDraggingRef = useRef(false);
|
||||||
|
|
||||||
// Update loading state based on all loading conditions
|
// Update loading state based on all loading conditions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,33 +87,33 @@ const ProjectViewBoard = () => {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (projectId && groupBy && projectView === 'kanban') {
|
if (projectId && groupBy && projectView === 'kanban') {
|
||||||
const promises = [];
|
const promises = [];
|
||||||
|
|
||||||
if (!loadingGroups) {
|
if (!loadingGroups) {
|
||||||
promises.push(dispatch(fetchBoardTaskGroups(projectId)));
|
promises.push(dispatch(fetchBoardTaskGroups(projectId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!statusCategories.length) {
|
if (!statusCategories.length) {
|
||||||
promises.push(dispatch(fetchStatusesCategories()));
|
promises.push(dispatch(fetchStatusesCategories()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all data to load
|
// Wait for all data to load
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
}, [dispatch, projectId, groupBy, projectView, search, archived]);
|
}, [dispatch, projectId, groupBy, projectView, search, archived]);
|
||||||
|
|
||||||
// Create sensors with memoization to prevent unnecessary re-renders
|
// Create sensors with memoization to prevent unnecessary re-renders
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, {
|
useSensor(MouseSensor, {
|
||||||
// Require the mouse to move by 10 pixels before activating
|
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
distance: 10,
|
distance: 10,
|
||||||
|
delay: 100,
|
||||||
|
tolerance: 5,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
useSensor(TouchSensor, {
|
useSensor(TouchSensor, {
|
||||||
// Press delay of 250ms, with tolerance of 5px of movement
|
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
delay: 250,
|
delay: 250,
|
||||||
tolerance: 5,
|
tolerance: 5,
|
||||||
@@ -102,90 +121,177 @@ const ProjectViewBoard = () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const collisionDetectionStrategy = useCallback(
|
||||||
|
(args: {
|
||||||
|
active: { id: UniqueIdentifier; data: { current?: { type?: string } } };
|
||||||
|
droppableContainers: DroppableContainer[];
|
||||||
|
}) => {
|
||||||
|
if (activeItem?.type === 'section') {
|
||||||
|
return closestCenter({
|
||||||
|
...args,
|
||||||
|
droppableContainers: args.droppableContainers.filter(
|
||||||
|
(container: DroppableContainer) => container.data.current?.type === 'section'
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start by finding any intersecting droppable
|
||||||
|
const pointerIntersections = pointerWithin(args);
|
||||||
|
const intersections =
|
||||||
|
pointerIntersections.length > 0
|
||||||
|
? pointerIntersections
|
||||||
|
: rectIntersection(args);
|
||||||
|
let overId = getFirstCollision(intersections, 'id');
|
||||||
|
|
||||||
|
if (overId !== null) {
|
||||||
|
const overContainer = args.droppableContainers.find(
|
||||||
|
(container: DroppableContainer) => container.id === overId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (overContainer?.data.current?.type === 'section') {
|
||||||
|
const containerItems = taskGroups.find(
|
||||||
|
(group) => group.id === overId
|
||||||
|
)?.tasks || [];
|
||||||
|
|
||||||
|
if (containerItems.length > 0) {
|
||||||
|
overId = closestCenter({
|
||||||
|
...args,
|
||||||
|
droppableContainers: args.droppableContainers.filter(
|
||||||
|
(container: DroppableContainer) =>
|
||||||
|
container.id !== overId &&
|
||||||
|
container.data.current?.type === 'task'
|
||||||
|
),
|
||||||
|
})[0]?.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastOverId.current = overId;
|
||||||
|
return [{ id: overId }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentlyMovedToNewContainer.current) {
|
||||||
|
lastOverId.current = activeItem?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastOverId.current ? [{ id: lastOverId.current }] : [];
|
||||||
|
},
|
||||||
|
[activeItem, taskGroups]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTaskProgress = (data: {
|
const handleTaskProgress = (data: {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
complete_ratio: number;
|
complete_ratio: number;
|
||||||
completed_count: number;
|
completed_count: number;
|
||||||
total_tasks_count: number;
|
total_tasks_count: number;
|
||||||
parent_task: string;
|
parent_task: string;
|
||||||
}) => {
|
}) => {
|
||||||
dispatch(updateTaskProgress(data));
|
dispatch(updateTaskProgress(data));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
// Debounced move task function to prevent rapid updates
|
||||||
const { active } = event;
|
const debouncedMoveTask = useCallback(
|
||||||
setActiveItem(active.data.current);
|
debounce((taskId: string, sourceGroupId: string, targetGroupId: string, targetIndex: number) => {
|
||||||
setCurrentTaskIndex(active.data.current?.sortable.index);
|
|
||||||
// Store the original source group ID when drag starts
|
|
||||||
if (active.data.current?.type === 'task') {
|
|
||||||
originalSourceGroupIdRef.current = active.data.current.sectionId;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (event: DragOverEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over) return;
|
|
||||||
|
|
||||||
const activeId = active.id;
|
|
||||||
const overId = over.id;
|
|
||||||
|
|
||||||
if (activeId === overId) return;
|
|
||||||
|
|
||||||
const isActiveTask = active.data.current?.type === 'task';
|
|
||||||
const isOverTask = over.data.current?.type === 'task';
|
|
||||||
const isOverSection = over.data.current?.type === 'section';
|
|
||||||
|
|
||||||
// Handle task movement between sections
|
|
||||||
if (isActiveTask && (isOverTask || isOverSection)) {
|
|
||||||
// If we're over a task, we want to insert at that position
|
|
||||||
// If we're over a section, we want to append to the end
|
|
||||||
const activeTaskId = active.data.current?.task.id;
|
|
||||||
|
|
||||||
// Use the original source group ID from ref instead of the potentially modified one
|
|
||||||
const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId;
|
|
||||||
|
|
||||||
// Fix: Ensure we correctly identify the target group ID
|
|
||||||
let targetGroupId;
|
|
||||||
if (isOverTask) {
|
|
||||||
// If over a task, get its section ID
|
|
||||||
targetGroupId = over.data.current?.sectionId;
|
|
||||||
} else if (isOverSection) {
|
|
||||||
// If over a section directly
|
|
||||||
targetGroupId = over.id;
|
|
||||||
} else {
|
|
||||||
// Fallback
|
|
||||||
targetGroupId = over.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the target index
|
|
||||||
let targetIndex = -1;
|
|
||||||
if (isOverTask) {
|
|
||||||
const overTaskId = over.data.current?.task.id;
|
|
||||||
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
|
|
||||||
if (targetGroup) {
|
|
||||||
targetIndex = targetGroup.tasks.findIndex(task => task.id === overTaskId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch the action to move the task
|
|
||||||
dispatch(
|
dispatch(
|
||||||
moveTaskBetweenGroups({
|
moveTaskBetweenGroups({
|
||||||
taskId: activeTaskId,
|
taskId,
|
||||||
sourceGroupId,
|
sourceGroupId,
|
||||||
targetGroupId,
|
targetGroupId,
|
||||||
targetIndex,
|
targetIndex,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
}, 100),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
const { active } = event;
|
||||||
|
isDraggingRef.current = true;
|
||||||
|
setActiveItem(active.data.current);
|
||||||
|
setCurrentTaskIndex(active.data.current?.sortable.index);
|
||||||
|
if (active.data.current?.type === 'task') {
|
||||||
|
originalSourceGroupIdRef.current = active.data.current.sectionId;
|
||||||
|
}
|
||||||
|
setClonedItems(taskGroups);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findGroupForId = (id: string) => {
|
||||||
|
// If id is a sectionId
|
||||||
|
if (taskGroups.some(group => group.id === id)) return id;
|
||||||
|
// If id is a taskId, find the group containing it
|
||||||
|
const group = taskGroups.find(g => g.tasks.some(t => t.id === id));
|
||||||
|
return group?.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragOverEvent) => {
|
||||||
|
try {
|
||||||
|
if (!isDraggingRef.current) return;
|
||||||
|
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
// Get the ids
|
||||||
|
const activeId = active.id;
|
||||||
|
const overId = over.id;
|
||||||
|
|
||||||
|
// Find the group (section) for each
|
||||||
|
const activeGroupId = findGroupForId(activeId as string);
|
||||||
|
const overGroupId = findGroupForId(overId as string);
|
||||||
|
|
||||||
|
// Only move if both groups exist and are different, and the active is a task
|
||||||
|
if (
|
||||||
|
activeGroupId &&
|
||||||
|
overGroupId &&
|
||||||
|
active.data.current?.type === 'task'
|
||||||
|
) {
|
||||||
|
// Find the target index in the over group
|
||||||
|
const targetGroup = taskGroups.find(g => g.id === overGroupId);
|
||||||
|
let targetIndex = 0;
|
||||||
|
if (targetGroup) {
|
||||||
|
// If over is a task, insert before it; if over is a section, append to end
|
||||||
|
if (over.data.current?.type === 'task') {
|
||||||
|
targetIndex = targetGroup.tasks.findIndex(t => t.id === overId);
|
||||||
|
if (targetIndex === -1) targetIndex = targetGroup.tasks.length;
|
||||||
|
} else {
|
||||||
|
targetIndex = targetGroup.tasks.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Use debounced move task to prevent rapid updates
|
||||||
|
debouncedMoveTask(
|
||||||
|
activeId as string,
|
||||||
|
activeGroupId,
|
||||||
|
overGroupId,
|
||||||
|
targetIndex
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('handleDragOver error:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePriorityChange = (taskId: string, priorityId: string) => {
|
||||||
|
if (!taskId || !priorityId || !socket) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
task_id: taskId,
|
||||||
|
priority_id: priorityId,
|
||||||
|
team_id: currentSession?.team_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify(payload));
|
||||||
|
socket.once(SocketEvents.TASK_PRIORITY_CHANGE.toString(), (data: ITaskListPriorityChangeResponse) => {
|
||||||
|
dispatch(updateBoardTaskPriority(data));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragEnd = async (event: DragEndEvent) => {
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
isDraggingRef.current = false;
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
if (!over || !projectId) {
|
if (!over || !projectId) {
|
||||||
setActiveItem(null);
|
setActiveItem(null);
|
||||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
originalSourceGroupIdRef.current = null;
|
||||||
|
setClonedItems(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +328,7 @@ const ProjectViewBoard = () => {
|
|||||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetGroupId !== sourceGroupId) {
|
if (targetGroupId !== sourceGroupId) {
|
||||||
const canContinue = await checkTaskDependencyStatus(task.id, targetGroupId);
|
const canContinue = await checkTaskDependencyStatus(task.id, targetGroupId);
|
||||||
if (!canContinue) {
|
if (!canContinue) {
|
||||||
@@ -237,7 +344,7 @@ const ProjectViewBoard = () => {
|
|||||||
targetIndex: currentTaskIndex !== -1 ? currentTaskIndex : 0, // Original position or append to end
|
targetIndex: currentTaskIndex !== -1 ? currentTaskIndex : 0, // Original position or append to end
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
setActiveItem(null);
|
setActiveItem(null);
|
||||||
originalSourceGroupIdRef.current = null;
|
originalSourceGroupIdRef.current = null;
|
||||||
return;
|
return;
|
||||||
@@ -246,7 +353,6 @@ const ProjectViewBoard = () => {
|
|||||||
|
|
||||||
// Find indices
|
// Find indices
|
||||||
let fromIndex = sourceGroup.tasks.findIndex(t => t.id === task.id);
|
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)
|
// Handle case where task is not found in source group (might have been moved already in UI)
|
||||||
if (fromIndex === -1) {
|
if (fromIndex === -1) {
|
||||||
logger.info('Task not found in source group. Using task sort_order from task object.');
|
logger.info('Task not found in source group. Using task sort_order from task object.');
|
||||||
@@ -282,8 +388,6 @@ const ProjectViewBoard = () => {
|
|||||||
team_id: currentSession?.team_id
|
team_id: currentSession?.team_id
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.error('Emitting socket event with payload (task not found in source):', body);
|
|
||||||
|
|
||||||
// Emit socket event
|
// Emit socket event
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
||||||
@@ -296,6 +400,11 @@ const ProjectViewBoard = () => {
|
|||||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle priority change if groupBy is priority
|
||||||
|
if (groupBy === IGroupBy.PRIORITY) {
|
||||||
|
handlePriorityChange(task.id, targetGroupId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track analytics event
|
// Track analytics event
|
||||||
@@ -320,7 +429,6 @@ const ProjectViewBoard = () => {
|
|||||||
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
|
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
|
||||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||||
-1;
|
-1;
|
||||||
|
|
||||||
// Prepare socket event payload
|
// Prepare socket event payload
|
||||||
const body = {
|
const body = {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
@@ -333,7 +441,6 @@ const ProjectViewBoard = () => {
|
|||||||
task,
|
task,
|
||||||
team_id: currentSession?.team_id
|
team_id: currentSession?.team_id
|
||||||
};
|
};
|
||||||
|
|
||||||
// Emit socket event
|
// Emit socket event
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
||||||
@@ -347,7 +454,6 @@ const ProjectViewBoard = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track analytics event
|
// Track analytics event
|
||||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||||
}
|
}
|
||||||
@@ -406,7 +512,24 @@ const ProjectViewBoard = () => {
|
|||||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleDragCancel = () => {
|
||||||
|
isDraggingRef.current = false;
|
||||||
|
if (clonedItems) {
|
||||||
|
dispatch(reorderTaskGroups(clonedItems));
|
||||||
|
}
|
||||||
|
setActiveItem(null);
|
||||||
|
setClonedItems(null);
|
||||||
|
originalSourceGroupIdRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset the recently moved flag after animation frame
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
recentlyMovedToNewContainer.current = false;
|
||||||
|
});
|
||||||
|
}, [taskGroups]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||||
}
|
}
|
||||||
@@ -421,17 +544,24 @@ const ProjectViewBoard = () => {
|
|||||||
trackMixpanelEvent(evt_project_board_visit);
|
trackMixpanelEvent(evt_project_board_visit);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup debounced function on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
debouncedMoveTask.cancel();
|
||||||
|
};
|
||||||
|
}, [debouncedMoveTask]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical gap={16}>
|
<Flex vertical gap={16}>
|
||||||
<TaskListFilters position={'board'} />
|
<TaskListFilters position={'board'} />
|
||||||
|
|
||||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCorners}
|
collisionDetection={collisionDetectionStrategy}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
>
|
>
|
||||||
<BoardSectionCardContainer
|
<BoardSectionCardContainer
|
||||||
datasource={taskGroups}
|
datasource={taskGroups}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const ProjectViewHeader = () => {
|
|||||||
const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer);
|
const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer);
|
||||||
|
|
||||||
const [creatingTask, setCreatingTask] = useState(false);
|
const [creatingTask, setCreatingTask] = useState(false);
|
||||||
|
const [subscriptionLoading, setSubscriptionLoading] = useState(false);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
@@ -98,17 +99,51 @@ const ProjectViewHeader = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubscribe = () => {
|
const handleSubscribe = () => {
|
||||||
if (selectedProject?.id) {
|
if (!selectedProject?.id || !socket || subscriptionLoading) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubscriptionLoading(true);
|
||||||
const newSubscriptionState = !selectedProject.subscribed;
|
const newSubscriptionState = !selectedProject.subscribed;
|
||||||
|
|
||||||
dispatch(setProject({ ...selectedProject, subscribed: newSubscriptionState }));
|
// Emit socket event first, then update state based on response
|
||||||
|
socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), {
|
||||||
socket?.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), {
|
|
||||||
project_id: selectedProject.id,
|
project_id: selectedProject.id,
|
||||||
user_id: currentSession?.id,
|
user_id: currentSession?.id,
|
||||||
team_member_id: currentSession?.team_member_id,
|
team_member_id: currentSession?.team_member_id,
|
||||||
mode: newSubscriptionState ? 1 : 0,
|
mode: newSubscriptionState ? 0 : 1, // Fixed: 0 for subscribe, 1 for unsubscribe
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for the response to confirm the operation
|
||||||
|
socket.once(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), (response) => {
|
||||||
|
try {
|
||||||
|
// Update the project state with the confirmed subscription status
|
||||||
|
dispatch(setProject({
|
||||||
|
...selectedProject,
|
||||||
|
subscribed: newSubscriptionState
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error handling project subscription response:', error);
|
||||||
|
// Revert optimistic update on error
|
||||||
|
dispatch(setProject({
|
||||||
|
...selectedProject,
|
||||||
|
subscribed: selectedProject.subscribed
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setSubscriptionLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add timeout in case socket response never comes
|
||||||
|
setTimeout(() => {
|
||||||
|
if (subscriptionLoading) {
|
||||||
|
setSubscriptionLoading(false);
|
||||||
|
logger.error('Project subscription timeout - no response from server');
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating project subscription:', error);
|
||||||
|
setSubscriptionLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -239,6 +274,7 @@ const ProjectViewHeader = () => {
|
|||||||
<Tooltip title={t('subscribe')}>
|
<Tooltip title={t('subscribe')}>
|
||||||
<Button
|
<Button
|
||||||
shape="round"
|
shape="round"
|
||||||
|
loading={subscriptionLoading}
|
||||||
icon={selectedProject?.subscribed ? <BellFilled /> : <BellOutlined />}
|
icon={selectedProject?.subscribed ? <BellFilled /> : <BellOutlined />}
|
||||||
onClick={handleSubscribe}
|
onClick={handleSubscribe}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ import { createPortal } from 'react-dom';
|
|||||||
import Flex from 'antd/es/flex';
|
import Flex from 'antd/es/flex';
|
||||||
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
|
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
|
||||||
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
pointerWithin,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
|
|
||||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
|
||||||
@@ -16,7 +11,6 @@ import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-a
|
|||||||
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||||
|
|
||||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
import { useTaskDragAndDrop } from '@/hooks/useTaskDragAndDrop';
|
|
||||||
|
|
||||||
interface TaskGroupWrapperOptimizedProps {
|
interface TaskGroupWrapperOptimizedProps {
|
||||||
taskGroups: ITaskListGroup[];
|
taskGroups: ITaskListGroup[];
|
||||||
@@ -28,14 +22,6 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti
|
|||||||
|
|
||||||
// Use extracted hooks
|
// Use extracted hooks
|
||||||
useTaskSocketHandlers();
|
useTaskSocketHandlers();
|
||||||
const {
|
|
||||||
activeId,
|
|
||||||
sensors,
|
|
||||||
handleDragStart,
|
|
||||||
handleDragEnd,
|
|
||||||
handleDragOver,
|
|
||||||
resetTaskRowStyles,
|
|
||||||
} = useTaskDragAndDrop({ taskGroups, groupBy });
|
|
||||||
|
|
||||||
// Memoize task groups with colors
|
// Memoize task groups with colors
|
||||||
const taskGroupsWithColors = useMemo(() =>
|
const taskGroupsWithColors = useMemo(() =>
|
||||||
@@ -46,18 +32,17 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti
|
|||||||
[taskGroups, themeMode]
|
[taskGroups, themeMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add drag styles
|
// Add drag styles without animations
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
.task-row[data-is-dragging="true"] {
|
.task-row[data-is-dragging="true"] {
|
||||||
opacity: 0.5 !important;
|
opacity: 0.5 !important;
|
||||||
transform: rotate(5deg) !important;
|
|
||||||
z-index: 1000 !important;
|
z-index: 1000 !important;
|
||||||
position: relative !important;
|
position: relative !important;
|
||||||
}
|
}
|
||||||
.task-row {
|
.task-row {
|
||||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
/* Remove transitions during drag operations */
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
@@ -67,45 +52,31 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle animation cleanup after drag ends
|
// Remove the animation cleanup since we're simplifying the approach
|
||||||
useIsomorphicLayoutEffect(() => {
|
|
||||||
if (activeId === null) {
|
|
||||||
const timeoutId = setTimeout(resetTaskRowStyles, 50);
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}, [activeId, resetTaskRowStyles]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<Flex gap={24} vertical>
|
||||||
sensors={sensors}
|
{taskGroupsWithColors.map(taskGroup => (
|
||||||
collisionDetection={pointerWithin}
|
<TaskListTableWrapper
|
||||||
onDragStart={handleDragStart}
|
key={taskGroup.id}
|
||||||
onDragEnd={handleDragEnd}
|
taskList={taskGroup.tasks}
|
||||||
onDragOver={handleDragOver}
|
tableId={taskGroup.id}
|
||||||
>
|
name={taskGroup.name}
|
||||||
<Flex gap={24} vertical>
|
groupBy={groupBy}
|
||||||
{taskGroupsWithColors.map(taskGroup => (
|
statusCategory={taskGroup.category_id}
|
||||||
<TaskListTableWrapper
|
color={taskGroup.displayColor}
|
||||||
key={taskGroup.id}
|
activeId={null}
|
||||||
taskList={taskGroup.tasks}
|
/>
|
||||||
tableId={taskGroup.id}
|
))}
|
||||||
name={taskGroup.name}
|
|
||||||
groupBy={groupBy}
|
|
||||||
statusCategory={taskGroup.category_id}
|
|
||||||
color={taskGroup.displayColor}
|
|
||||||
activeId={activeId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
|
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
|
||||||
|
|
||||||
{createPortal(
|
{createPortal(
|
||||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
||||||
document.body,
|
document.body,
|
||||||
'task-template-drawer'
|
'task-template-drawer'
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</DndContext>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from "nanoid";
|
||||||
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
|
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
|
||||||
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
|
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
|
||||||
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from "nanoid";
|
||||||
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
|
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
|
||||||
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
|
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
|
||||||
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ const TaskListTableWrapper = ({
|
|||||||
className={`border-l-[3px] relative after:content after:absolute after:h-full after:w-1 after:z-10 after:top-0 after:left-0`}
|
className={`border-l-[3px] relative after:content after:absolute after:h-full after:w-1 after:z-10 after:top-0 after:left-0`}
|
||||||
color={color}
|
color={color}
|
||||||
>
|
>
|
||||||
<TaskListTable taskList={taskList} tableId={tableId} activeId={activeId} />
|
<TaskListTable taskList={taskList} tableId={tableId} activeId={activeId} groupBy={groupBy} />
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Flex>
|
</Flex>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { useSortable } from '@dnd-kit/sortable';
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { DraggableAttributes, UniqueIdentifier } from '@dnd-kit/core';
|
import { DraggableAttributes, UniqueIdentifier } from '@dnd-kit/core';
|
||||||
import { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
import { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
||||||
import { DragOverlay } from '@dnd-kit/core';
|
import { DragOverlay, DndContext, PointerSensor, useSensor, useSensors, KeyboardSensor, TouchSensor } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { DragEndEvent } from '@dnd-kit/core';
|
import { DragEndEvent } from '@dnd-kit/core';
|
||||||
import { List, Card, Avatar, Dropdown, Empty, Divider, Button } from 'antd';
|
import { List, Card, Avatar, Dropdown, Empty, Divider, Button } from 'antd';
|
||||||
@@ -50,19 +50,20 @@ import StatusDropdown from '@/components/task-list-common/status-dropdown/status
|
|||||||
import PriorityDropdown from '@/components/task-list-common/priorityDropdown/priority-dropdown';
|
import PriorityDropdown from '@/components/task-list-common/priorityDropdown/priority-dropdown';
|
||||||
import AddCustomColumnButton from './custom-columns/custom-column-modal/add-custom-column-button';
|
import AddCustomColumnButton from './custom-columns/custom-column-modal/add-custom-column-button';
|
||||||
import { fetchSubTasks, reorderTasks, toggleTaskRowExpansion, updateCustomColumnValue } from '@/features/tasks/tasks.slice';
|
import { fetchSubTasks, reorderTasks, toggleTaskRowExpansion, updateCustomColumnValue } from '@/features/tasks/tasks.slice';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
import ConfigPhaseButton from '@/features/projects/singleProject/phase/ConfigPhaseButton';
|
import ConfigPhaseButton from '@/features/projects/singleProject/phase/ConfigPhaseButton';
|
||||||
import PhaseDropdown from '@/components/taskListCommon/phase-dropdown/phase-dropdown';
|
import PhaseDropdown from '@/components/taskListCommon/phase-dropdown/phase-dropdown';
|
||||||
import CustomColumnModal from './custom-columns/custom-column-modal/custom-column-modal';
|
import CustomColumnModal from './custom-columns/custom-column-modal/custom-column-modal';
|
||||||
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
||||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||||
import { useSocket } from '@/socket/socketContext';
|
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
|
||||||
|
|
||||||
interface TaskListTableProps {
|
interface TaskListTableProps {
|
||||||
taskList: IProjectTask[] | null;
|
taskList: IProjectTask[] | null;
|
||||||
tableId: string;
|
tableId: string;
|
||||||
activeId?: string | null;
|
activeId?: string | null;
|
||||||
|
groupBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DraggableRowProps {
|
interface DraggableRowProps {
|
||||||
@@ -71,44 +72,50 @@ interface DraggableRowProps {
|
|||||||
groupId: string;
|
groupId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a simplified EmptyRow component that doesn't use hooks
|
// Remove the EmptyRow component and fix the DraggableRow
|
||||||
const EmptyRow = () => null;
|
|
||||||
|
|
||||||
// Simplify DraggableRow to eliminate conditional hook calls
|
|
||||||
const DraggableRow = ({ task, children, groupId }: DraggableRowProps) => {
|
const DraggableRow = ({ task, children, groupId }: DraggableRowProps) => {
|
||||||
// Return the EmptyRow component without using any hooks
|
// Always call hooks in the same order - never conditionally
|
||||||
if (!task?.id) return <EmptyRow />;
|
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: task.id as UniqueIdentifier,
|
id: task?.id || 'empty-task', // Provide fallback ID
|
||||||
data: {
|
data: {
|
||||||
type: 'task',
|
type: 'task',
|
||||||
task,
|
task,
|
||||||
groupId,
|
groupId,
|
||||||
},
|
},
|
||||||
|
disabled: !task?.id, // Disable dragging for invalid tasks
|
||||||
|
transition: null, // Disable sortable transitions
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If task is invalid, return null to not render anything
|
||||||
|
if (!task?.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition: isDragging ? 'none' : transition, // Disable transition during drag
|
||||||
opacity: isDragging ? 0.3 : 1,
|
opacity: isDragging ? 0.3 : 1,
|
||||||
position: 'relative' as const,
|
position: 'relative' as const,
|
||||||
zIndex: isDragging ? 1 : 'auto',
|
zIndex: isDragging ? 1 : 'auto',
|
||||||
backgroundColor: isDragging ? 'var(--dragging-bg)' : undefined,
|
backgroundColor: isDragging ? 'var(--dragging-bg)' : undefined,
|
||||||
};
|
// Handle border styling to avoid conflicts between shorthand and individual properties
|
||||||
|
...(isDragging ? {
|
||||||
// Handle border styling separately to avoid conflicts
|
borderTopWidth: '1px',
|
||||||
const borderStyle = {
|
borderRightWidth: '1px',
|
||||||
borderStyle: isDragging ? 'solid' : undefined,
|
borderBottomWidth: '1px',
|
||||||
borderWidth: isDragging ? '1px' : undefined,
|
borderLeftWidth: '1px',
|
||||||
borderColor: isDragging ? 'var(--border-color)' : undefined,
|
borderStyle: 'solid',
|
||||||
borderBottomWidth: document.documentElement.getAttribute('data-theme') === 'light' && !isDragging ? '2px' : undefined
|
borderColor: 'var(--border-color)',
|
||||||
|
} : {
|
||||||
|
// Only set borderBottomWidth when not dragging to avoid conflicts
|
||||||
|
borderBottomWidth: document.documentElement.getAttribute('data-theme') === 'light' ? '2px' : undefined
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={{ ...style, ...borderStyle }}
|
style={style}
|
||||||
className={`task-row h-[42px] ${isDragging ? 'shadow-lg' : ''}`}
|
className={`task-row h-[42px] ${isDragging ? 'shadow-lg' : ''}`}
|
||||||
data-is-dragging={isDragging ? 'true' : 'false'}
|
data-is-dragging={isDragging ? 'true' : 'false'}
|
||||||
data-group-id={groupId}
|
data-group-id={groupId}
|
||||||
@@ -1208,12 +1215,33 @@ const renderCustomColumnContent = (
|
|||||||
return customComponents[fieldType] ? customComponents[fieldType]() : null;
|
return customComponents[fieldType] ? customComponents[fieldType]() : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, activeId }) => {
|
const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, activeId, groupBy }) => {
|
||||||
const { t } = useTranslation('task-list-table');
|
const { t } = useTranslation('task-list-table');
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const currentSession = useAuthService().getCurrentSession();
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
const { socket } = useSocket();
|
const { socket } = useSocket();
|
||||||
|
|
||||||
|
// Add drag state
|
||||||
|
const [dragActiveId, setDragActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Configure sensors for drag and drop
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 250,
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
const columnList = useAppSelector(state => state.taskReducer.columns);
|
const columnList = useAppSelector(state => state.taskReducer.columns);
|
||||||
const visibleColumns = columnList.filter(column => column.pinned);
|
const visibleColumns = columnList.filter(column => column.pinned);
|
||||||
@@ -1525,27 +1553,8 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
|||||||
// Use the tasks from the current group if available, otherwise fall back to taskList prop
|
// Use the tasks from the current group if available, otherwise fall back to taskList prop
|
||||||
const displayTasks = currentGroup?.tasks || taskList || [];
|
const displayTasks = currentGroup?.tasks || taskList || [];
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
// Remove the local handleDragEnd as it conflicts with the main DndContext
|
||||||
const { active, over } = event;
|
// All drag handling is now done at the TaskGroupWrapperOptimized level
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
|
|
||||||
const activeIndex = displayTasks.findIndex(task => task.id === active.id);
|
|
||||||
const overIndex = displayTasks.findIndex(task => task.id === over.id);
|
|
||||||
|
|
||||||
if (activeIndex !== -1 && overIndex !== -1) {
|
|
||||||
dispatch(
|
|
||||||
reorderTasks({
|
|
||||||
activeGroupId: tableId,
|
|
||||||
overGroupId: tableId,
|
|
||||||
fromIndex: activeIndex,
|
|
||||||
toIndex: overIndex,
|
|
||||||
task: displayTasks[activeIndex],
|
|
||||||
updatedSourceTasks: displayTasks,
|
|
||||||
updatedTargetTasks: displayTasks,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCustomColumnSettings = (columnKey: string) => {
|
const handleCustomColumnSettings = (columnKey: string) => {
|
||||||
if (!columnKey) return;
|
if (!columnKey) return;
|
||||||
@@ -1554,12 +1563,169 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
|||||||
dispatch(toggleCustomColumnModalOpen(true));
|
dispatch(toggleCustomColumnModalOpen(true));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Drag and drop handlers
|
||||||
|
const handleDragStart = (event: any) => {
|
||||||
|
setDragActiveId(event.active.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
setDragActiveId(null);
|
||||||
|
|
||||||
|
if (!over || !active || active.id === over.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTask = displayTasks.find(task => task.id === active.id);
|
||||||
|
if (!activeTask) {
|
||||||
|
console.error('Active task not found:', { activeId: active.id, displayTasks: displayTasks.map(t => ({ id: t.id, name: t.name })) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Found activeTask:', {
|
||||||
|
id: activeTask.id,
|
||||||
|
name: activeTask.name,
|
||||||
|
status_id: activeTask.status_id,
|
||||||
|
status: activeTask.status,
|
||||||
|
priority: activeTask.priority,
|
||||||
|
project_id: project?.id,
|
||||||
|
team_id: project?.team_id,
|
||||||
|
fullProject: project
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use the tableId directly as the group ID (it should be the group ID)
|
||||||
|
const currentGroupId = tableId;
|
||||||
|
|
||||||
|
console.log('Drag operation:', {
|
||||||
|
activeId: active.id,
|
||||||
|
overId: over.id,
|
||||||
|
tableId,
|
||||||
|
currentGroupId,
|
||||||
|
displayTasksLength: displayTasks.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if this is a reorder within the same group
|
||||||
|
const overTask = displayTasks.find(task => task.id === over.id);
|
||||||
|
if (overTask) {
|
||||||
|
// Reordering within the same group
|
||||||
|
const oldIndex = displayTasks.findIndex(task => task.id === active.id);
|
||||||
|
const newIndex = displayTasks.findIndex(task => task.id === over.id);
|
||||||
|
|
||||||
|
console.log('Reorder details:', { oldIndex, newIndex, activeTask: activeTask.name });
|
||||||
|
|
||||||
|
if (oldIndex !== newIndex && oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
// Get the actual sort_order values from the tasks
|
||||||
|
const fromSortOrder = activeTask.sort_order || oldIndex;
|
||||||
|
const overTaskAtNewIndex = displayTasks[newIndex];
|
||||||
|
const toSortOrder = overTaskAtNewIndex?.sort_order || newIndex;
|
||||||
|
|
||||||
|
console.log('Sort order details:', {
|
||||||
|
oldIndex,
|
||||||
|
newIndex,
|
||||||
|
fromSortOrder,
|
||||||
|
toSortOrder,
|
||||||
|
activeTaskSortOrder: activeTask.sort_order,
|
||||||
|
overTaskSortOrder: overTaskAtNewIndex?.sort_order
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create updated task list with reordered tasks
|
||||||
|
const updatedTasks = [...displayTasks];
|
||||||
|
const [movedTask] = updatedTasks.splice(oldIndex, 1);
|
||||||
|
updatedTasks.splice(newIndex, 0, movedTask);
|
||||||
|
|
||||||
|
console.log('Dispatching reorderTasks with:', {
|
||||||
|
activeGroupId: currentGroupId,
|
||||||
|
overGroupId: currentGroupId,
|
||||||
|
fromIndex: oldIndex,
|
||||||
|
toIndex: newIndex,
|
||||||
|
taskName: activeTask.name
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local state immediately for better UX
|
||||||
|
dispatch(reorderTasks({
|
||||||
|
activeGroupId: currentGroupId,
|
||||||
|
overGroupId: currentGroupId,
|
||||||
|
fromIndex: oldIndex,
|
||||||
|
toIndex: newIndex,
|
||||||
|
task: activeTask,
|
||||||
|
updatedSourceTasks: updatedTasks,
|
||||||
|
updatedTargetTasks: updatedTasks
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Send socket event for backend sync
|
||||||
|
if (socket && project?.id && active.id && activeTask.id) {
|
||||||
|
// Helper function to validate UUID or return null
|
||||||
|
const validateUUID = (value: string | undefined | null): string | null => {
|
||||||
|
if (!value || value.trim() === '') return null;
|
||||||
|
// Basic UUID format check (8-4-4-4-12 characters)
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
return uuidRegex.test(value) ? value : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
from_index: fromSortOrder,
|
||||||
|
to_index: toSortOrder,
|
||||||
|
project_id: project.id,
|
||||||
|
from_group: currentGroupId,
|
||||||
|
to_group: currentGroupId,
|
||||||
|
group_by: groupBy || 'status', // Use the groupBy prop
|
||||||
|
to_last_index: false,
|
||||||
|
task: {
|
||||||
|
id: activeTask.id, // Use activeTask.id instead of active.id to ensure it's valid
|
||||||
|
project_id: project.id,
|
||||||
|
status: validateUUID(activeTask.status_id || activeTask.status),
|
||||||
|
priority: validateUUID(activeTask.priority)
|
||||||
|
},
|
||||||
|
team_id: project.team_id || currentSession?.team_id || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate required fields before sending
|
||||||
|
if (!body.task.id) {
|
||||||
|
console.error('Cannot send socket event: task.id is missing', { activeTask, active });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Validated values:', {
|
||||||
|
from_index: body.from_index,
|
||||||
|
to_index: body.to_index,
|
||||||
|
status: body.task.status,
|
||||||
|
priority: body.task.priority,
|
||||||
|
team_id: body.team_id,
|
||||||
|
originalStatus: activeTask.status_id || activeTask.status,
|
||||||
|
originalPriority: activeTask.priority,
|
||||||
|
originalTeamId: project.team_id,
|
||||||
|
sessionTeamId: currentSession?.team_id,
|
||||||
|
finalTeamId: body.team_id
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Sending socket event:', body);
|
||||||
|
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
||||||
|
} else {
|
||||||
|
console.error('Cannot send socket event: missing required data', {
|
||||||
|
hasSocket: !!socket,
|
||||||
|
hasProjectId: !!project?.id,
|
||||||
|
hasActiveId: !!active.id,
|
||||||
|
hasActiveTaskId: !!activeTask.id,
|
||||||
|
activeTask,
|
||||||
|
active
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`border-x border-b ${customBorderColor}`}>
|
<div className={`border-x border-b ${customBorderColor}`}>
|
||||||
<SortableContext
|
<DndContext
|
||||||
items={(displayTasks?.map(t => t.id).filter(Boolean) || []) as string[]}
|
sensors={sensors}
|
||||||
strategy={verticalListSortingStrategy}
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
autoScroll={false} // Disable auto-scroll animations
|
||||||
>
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={(displayTasks?.filter(t => t?.id).map(t => t.id).filter(Boolean) || []) as string[]}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
<div className={`tasklist-container-${tableId} min-h-0 max-w-full overflow-x-auto`}>
|
<div className={`tasklist-container-${tableId} min-h-0 max-w-full overflow-x-auto`}>
|
||||||
<table className="rounded-2 w-full min-w-max border-collapse relative">
|
<table className="rounded-2 w-full min-w-max border-collapse relative">
|
||||||
<thead className="h-[42px]">
|
<thead className="h-[42px]">
|
||||||
@@ -1611,25 +1777,29 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{displayTasks && displayTasks.length > 0 ? (
|
{displayTasks && displayTasks.length > 0 ? (
|
||||||
displayTasks.map(task => {
|
displayTasks
|
||||||
const updatedTask = findTaskInGroups(task.id || '') || task;
|
.filter(task => task?.id) // Filter out tasks without valid IDs
|
||||||
|
.map(task => {
|
||||||
|
const updatedTask = findTaskInGroups(task.id || '') || task;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={updatedTask.id}>
|
<React.Fragment key={updatedTask.id}>
|
||||||
{renderTaskRow(updatedTask)}
|
{renderTaskRow(updatedTask)}
|
||||||
{updatedTask.show_sub_tasks && (
|
{updatedTask.show_sub_tasks && (
|
||||||
<>
|
<>
|
||||||
{updatedTask?.sub_tasks?.map(subtask => renderTaskRow(subtask, true))}
|
{updatedTask?.sub_tasks?.map(subtask =>
|
||||||
<tr>
|
subtask?.id ? renderTaskRow(subtask, true) : null
|
||||||
<td colSpan={visibleColumns.length + 1}>
|
)}
|
||||||
<AddTaskListRow groupId={tableId} parentTask={updatedTask.id} />
|
<tr key={`add-subtask-${updatedTask.id}`}>
|
||||||
</td>
|
<td colSpan={visibleColumns.length + 1}>
|
||||||
|
<AddTaskListRow groupId={tableId} parentTask={updatedTask.id} />
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={visibleColumns.length + 1} className="ps-2 py-2">
|
<td colSpan={visibleColumns.length + 1} className="ps-2 py-2">
|
||||||
@@ -1643,17 +1813,15 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
|
||||||
<DragOverlay
|
<DragOverlay
|
||||||
dropAnimation={{
|
dropAnimation={null} // Disable drop animation
|
||||||
duration: 200,
|
|
||||||
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{activeId && displayTasks?.length ? (
|
{dragActiveId ? (
|
||||||
<table className="w-full">
|
<div className="bg-white dark:bg-gray-800 shadow-lg rounded border p-2 opacity-90">
|
||||||
<tbody>{renderTaskRow(displayTasks.find(t => t.id === activeId))}</tbody>
|
<span className="text-sm font-medium">Moving task...</span>
|
||||||
</table>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
{/* Add task row is positioned outside of the scrollable area */}
|
{/* Add task row is positioned outside of the scrollable area */}
|
||||||
<div className={`border-t ${customBorderColor}`}>
|
<div className={`border-t ${customBorderColor}`}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Button, Card, Checkbox, Flex, Typography } from 'antd';
|
import { Button, Card, Checkbox, Flex, Typography } from 'antd';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||||
@@ -25,29 +25,37 @@ const OverviewReports = () => {
|
|||||||
trackMixpanelEvent(evt_reporting_overview);
|
trackMixpanelEvent(evt_reporting_overview);
|
||||||
}, [trackMixpanelEvent]);
|
}, [trackMixpanelEvent]);
|
||||||
|
|
||||||
const handleArchiveToggle = () => {
|
const handleArchiveToggle = useCallback(() => {
|
||||||
dispatch(toggleIncludeArchived());
|
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 (
|
return (
|
||||||
<Flex vertical gap={24}>
|
<Flex vertical gap={24}>
|
||||||
<CustomPageHeader
|
<CustomPageHeader
|
||||||
title={t('overviewTitle')}
|
title={t('overviewTitle')}
|
||||||
children={
|
children={headerChildren}
|
||||||
<Button type="text" onClick={handleArchiveToggle}>
|
|
||||||
<Checkbox checked={includeArchivedProjects} />
|
|
||||||
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OverviewStats />
|
<OverviewStats />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Flex vertical gap={12}>
|
<Flex vertical gap={12}>
|
||||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
{teamsText}
|
||||||
{t('teamsText')}
|
|
||||||
</Typography.Text>
|
|
||||||
<OverviewReportsTable />
|
<OverviewReportsTable />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,32 +1,151 @@
|
|||||||
import { ReactNode } from 'react';
|
import { Card, Flex, Typography, theme } from 'antd';
|
||||||
import { Card, Flex, Typography } from 'antd';
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
type InsightCardProps = {
|
interface InsightCardProps {
|
||||||
icon: ReactNode;
|
icon: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
children: ReactNode;
|
children: React.ReactNode;
|
||||||
loading?: boolean;
|
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 (
|
return (
|
||||||
<Card
|
<div
|
||||||
className="custom-insights-card"
|
className={`overview-stat-card ${isDarkMode ? 'dark-mode' : 'light-mode'}`}
|
||||||
style={{ width: '100%' }}
|
style={{
|
||||||
styles={{ body: { paddingInline: 16 } }}
|
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
|
||||||
loading={loading}
|
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">
|
<Card
|
||||||
{icon}
|
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}>
|
<Flex vertical gap={8} style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Typography.Text style={{ fontSize: 16 }}>{title}</Typography.Text>
|
<Typography.Text style={titleStyle}>
|
||||||
|
{title}
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
<>{children}</>
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '6px',
|
||||||
|
marginTop: '4px'
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
|
||||||
</Card>
|
{/* Decorative element */}
|
||||||
|
<div style={decorativeStyle} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
OverviewStatCard.displayName = 'OverviewStatCard';
|
||||||
|
|
||||||
export default OverviewStatCard;
|
export default OverviewStatCard;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Flex, Typography } from 'antd';
|
import { Flex, Typography, theme } from 'antd';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import OverviewStatCard from './overview-stat-card';
|
import OverviewStatCard from './overview-stat-card';
|
||||||
import { BankOutlined, FileOutlined, UsergroupAddOutlined } from '@ant-design/icons';
|
import { BankOutlined, FileOutlined, UsergroupAddOutlined } from '@ant-design/icons';
|
||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
@@ -12,11 +12,12 @@ const OverviewStats = () => {
|
|||||||
const [stats, setStats] = useState<IRPTOverviewStatistics>({});
|
const [stats, setStats] = useState<IRPTOverviewStatistics>({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { t } = useTranslation('reporting-overview');
|
const { t } = useTranslation('reporting-overview');
|
||||||
|
const { token } = theme.useToken();
|
||||||
const includeArchivedProjects = useAppSelector(
|
const includeArchivedProjects = useAppSelector(
|
||||||
state => state.reportingReducer.includeArchivedProjects
|
state => state.reportingReducer.includeArchivedProjects
|
||||||
);
|
);
|
||||||
|
|
||||||
const getOverviewStats = async () => {
|
const getOverviewStats = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { done, body } =
|
const { done, body } =
|
||||||
@@ -29,17 +30,17 @@ const OverviewStats = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [includeArchivedProjects]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getOverviewStats();
|
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)}`;
|
return `${count} ${count === 1 ? t(singularKey) : t(pluralKey)}`;
|
||||||
};
|
}, [t]);
|
||||||
|
|
||||||
const renderStatCard = (
|
const renderStatCard = useCallback((
|
||||||
icon: React.ReactNode,
|
icon: React.ReactNode,
|
||||||
mainCount: number = 0,
|
mainCount: number = 0,
|
||||||
mainKey: string,
|
mainKey: string,
|
||||||
@@ -52,81 +53,127 @@ const OverviewStats = () => {
|
|||||||
>
|
>
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
{stats.map((stat, index) => (
|
{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}
|
{stat.text}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
</OverviewStatCard>
|
</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 (
|
return (
|
||||||
<Flex gap={24}>
|
<Flex gap={24}>
|
||||||
{renderStatCard(
|
{renderStatCard(
|
||||||
<BankOutlined style={{ color: colors.skyBlue, fontSize: 42 }} />,
|
teamIcon,
|
||||||
stats?.teams?.count,
|
stats?.teams?.count,
|
||||||
'teamCount',
|
'teamCount',
|
||||||
[
|
teamStats
|
||||||
{
|
|
||||||
text: renderStatText(stats?.teams?.projects, 'projectCount', 'projectCountPlural'),
|
|
||||||
type: 'secondary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: renderStatText(stats?.teams?.members, 'memberCount', 'memberCountPlural'),
|
|
||||||
type: 'secondary',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{renderStatCard(
|
{renderStatCard(
|
||||||
<FileOutlined style={{ color: colors.limeGreen, fontSize: 42 }} />,
|
projectIcon,
|
||||||
stats?.projects?.count,
|
stats?.projects?.count,
|
||||||
'projectCount',
|
'projectCount',
|
||||||
[
|
projectStats
|
||||||
{
|
|
||||||
text: renderStatText(
|
|
||||||
stats?.projects?.active,
|
|
||||||
'activeProjectCount',
|
|
||||||
'activeProjectCountPlural'
|
|
||||||
),
|
|
||||||
type: 'secondary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: renderStatText(
|
|
||||||
stats?.projects?.overdue,
|
|
||||||
'overdueProjectCount',
|
|
||||||
'overdueProjectCountPlural'
|
|
||||||
),
|
|
||||||
type: 'danger',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{renderStatCard(
|
{renderStatCard(
|
||||||
<UsergroupAddOutlined style={{ color: colors.lightGray, fontSize: 42 }} />,
|
memberIcon,
|
||||||
stats?.members?.count,
|
stats?.members?.count,
|
||||||
'memberCount',
|
'memberCount',
|
||||||
[
|
memberStats
|
||||||
{
|
|
||||||
text: renderStatText(
|
|
||||||
stats?.members?.unassigned,
|
|
||||||
'unassignedMemberCount',
|
|
||||||
'unassignedMemberCountPlural'
|
|
||||||
),
|
|
||||||
type: 'secondary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: renderStatText(
|
|
||||||
stats?.members?.overdue,
|
|
||||||
'memberWithOverdueTaskCount',
|
|
||||||
'memberWithOverdueTaskCountPlural'
|
|
||||||
),
|
|
||||||
type: 'danger',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</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 { ConfigProvider, Table, TableColumnsType } from 'antd';
|
||||||
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
||||||
import CustomTableTitle from '../../../../components/CustomTableTitle';
|
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 OverviewTeamInfoDrawer from '@/components/reporting/drawers/overview-team-info/overview-team-info-drawer';
|
||||||
import { toggleOverViewTeamDrawer } from '@/features/reporting/reporting.slice';
|
import { toggleOverViewTeamDrawer } from '@/features/reporting/reporting.slice';
|
||||||
|
|
||||||
const OverviewReportsTable = () => {
|
const OverviewReportsTable = memo(() => {
|
||||||
const { t } = useTranslation('reporting-overview');
|
const { t } = useTranslation('reporting-overview');
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ const OverviewReportsTable = () => {
|
|||||||
const [teams, setTeams] = useState<IRPTTeam[]>([]);
|
const [teams, setTeams] = useState<IRPTTeam[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const getTeams = async () => {
|
const getTeams = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { done, body } = await reportingApiService.getOverviewTeams(includeArchivedProjects);
|
const { done, body } = await reportingApiService.getOverviewTeams(includeArchivedProjects);
|
||||||
@@ -34,18 +34,19 @@ const OverviewReportsTable = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [includeArchivedProjects]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getTeams();
|
getTeams();
|
||||||
}, [includeArchivedProjects]);
|
}, [getTeams]);
|
||||||
|
|
||||||
const handleDrawerOpen = (team: IRPTTeam) => {
|
const handleDrawerOpen = useCallback((team: IRPTTeam) => {
|
||||||
setSelectedTeam(team);
|
setSelectedTeam(team);
|
||||||
dispatch(toggleOverViewTeamDrawer());
|
dispatch(toggleOverViewTeamDrawer());
|
||||||
};
|
}, [dispatch]);
|
||||||
|
|
||||||
const columns: TableColumnsType = [
|
// Memoize table columns to prevent recreation on every render
|
||||||
|
const columns: TableColumnsType<IRPTTeam> = useMemo(() => [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
title: <CustomTableTitle title={t('nameColumn')} />,
|
title: <CustomTableTitle title={t('nameColumn')} />,
|
||||||
@@ -61,39 +62,45 @@ const OverviewReportsTable = () => {
|
|||||||
{
|
{
|
||||||
key: 'members',
|
key: 'members',
|
||||||
title: <CustomTableTitle title={t('membersColumn')} />,
|
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 (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider {...tableConfig}>
|
||||||
theme={{
|
|
||||||
components: {
|
|
||||||
Table: {
|
|
||||||
cellPaddingBlock: 8,
|
|
||||||
cellPaddingInline: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={teams}
|
dataSource={teams}
|
||||||
scroll={{ x: 'max-content' }}
|
scroll={{ x: 'max-content' }}
|
||||||
rowKey={record => record.id}
|
rowKey={record => record.id}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onRow={record => {
|
onRow={getRowProps}
|
||||||
return {
|
|
||||||
onClick: () => handleDrawerOpen(record as IRPTTeam),
|
|
||||||
style: { height: 48, cursor: 'pointer' },
|
|
||||||
className: 'group even:bg-[#4e4e4e10]',
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OverviewTeamInfoDrawer team={selectedTeam} />
|
<OverviewTeamInfoDrawer team={selectedTeam} />
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default memo(OverviewReportsTable);
|
OverviewReportsTable.displayName = 'OverviewReportsTable';
|
||||||
|
|
||||||
|
export default OverviewReportsTable;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Flex } from 'antd';
|
import { Flex } from 'antd';
|
||||||
|
import { useMemo, useCallback, memo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import ProjectStatusFilterDropdown from './project-status-filter-dropdown';
|
import ProjectStatusFilterDropdown from './project-status-filter-dropdown';
|
||||||
import ProjectHealthFilterDropdown from './project-health-filter-dropdown';
|
import ProjectHealthFilterDropdown from './project-health-filter-dropdown';
|
||||||
@@ -15,26 +16,39 @@ const ProjectsReportsFilters = () => {
|
|||||||
const { t } = useTranslation('reporting-projects-filters');
|
const { t } = useTranslation('reporting-projects-filters');
|
||||||
const { searchQuery } = useAppSelector(state => state.projectReportsReducer);
|
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 (
|
return (
|
||||||
<Flex gap={8} align="center" justify="space-between">
|
<Flex gap={8} align="center" justify="space-between">
|
||||||
<Flex gap={8} wrap={'wrap'}>
|
{filterDropdowns}
|
||||||
<ProjectStatusFilterDropdown />
|
{rightControls}
|
||||||
<ProjectHealthFilterDropdown />
|
|
||||||
<ProjectCategoriesFilterDropdown />
|
|
||||||
<ProjectManagersFilterDropdown />
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<Flex gap={12}>
|
|
||||||
<ProjectTableShowFieldsDropdown />
|
|
||||||
|
|
||||||
<CustomSearchbar
|
|
||||||
placeholderText={t('searchByNamePlaceholder')}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
setSearchQuery={text => dispatch(setSearchQuery(text))}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
</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 { Button, ConfigProvider, Flex, PaginationProps, Table, TableColumnsType } from 'antd';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
@@ -63,10 +63,11 @@ const ProjectsReportsTable = () => {
|
|||||||
|
|
||||||
const columnsVisibility = useAppSelector(state => state.projectReportsTableColumnsReducer);
|
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);
|
setSelectedProject(record);
|
||||||
dispatch(toggleProjectReportsDrawer());
|
dispatch(toggleProjectReportsDrawer());
|
||||||
};
|
}, [dispatch]);
|
||||||
|
|
||||||
const columns: TableColumnsType<IRPTProject> = useMemo(
|
const columns: TableColumnsType<IRPTProject> = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -231,7 +232,7 @@ const ProjectsReportsTable = () => {
|
|||||||
width: 200,
|
width: 200,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, order]
|
[t, order, handleDrawerOpen]
|
||||||
);
|
);
|
||||||
|
|
||||||
// filter columns based on the `hidden` state from Redux
|
// filter columns based on the `hidden` state from Redux
|
||||||
@@ -240,12 +241,13 @@ const ProjectsReportsTable = () => {
|
|||||||
[columns, columnsVisibility]
|
[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.order) dispatch(setOrder(sorter.order));
|
||||||
if (sorter.field) dispatch(setField(sorter.field));
|
if (sorter.field) dispatch(setField(sorter.field));
|
||||||
dispatch(setIndex(pagination.current));
|
dispatch(setIndex(pagination.current));
|
||||||
dispatch(setPageSize(pagination.pageSize));
|
dispatch(setPageSize(pagination.pageSize));
|
||||||
};
|
}, [dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading) dispatch(fetchProjectData());
|
if (!isLoading) dispatch(fetchProjectData());
|
||||||
@@ -268,7 +270,7 @@ const ProjectsReportsTable = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
dispatch(resetProjectReports());
|
dispatch(resetProjectReports());
|
||||||
};
|
};
|
||||||
}, []);
|
}, [dispatch]);
|
||||||
|
|
||||||
const tableRowProps = useMemo(
|
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 (
|
return (
|
||||||
<ConfigProvider {...tableConfig}>
|
<ConfigProvider {...tableConfig}>
|
||||||
<Table
|
<Table
|
||||||
columns={visibleColumns}
|
columns={visibleColumns}
|
||||||
dataSource={projectList}
|
dataSource={projectList}
|
||||||
pagination={{
|
pagination={paginationConfig}
|
||||||
showSizeChanger: true,
|
scroll={scrollConfig}
|
||||||
defaultPageSize: 10,
|
|
||||||
total: total,
|
|
||||||
current: index,
|
|
||||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
|
||||||
}}
|
|
||||||
scroll={{ x: 'max-content' }}
|
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
onChange={handleTableChange}
|
onChange={handleTableChange}
|
||||||
rowKey={record => record.id}
|
rowKey={getRowKey}
|
||||||
onRow={() => tableRowProps}
|
onRow={getRowProps}
|
||||||
/>
|
/>
|
||||||
{createPortal(<ProjectReportsDrawer selectedProject={selectedProject} />, document.body)}
|
{createPortal(<ProjectReportsDrawer selectedProject={selectedProject} />, document.body)}
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProjectsReportsTable;
|
export default memo(ProjectsReportsTable);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Button, Card, Checkbox, Dropdown, Flex, Space, Typography } from 'antd';
|
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 CustomPageHeader from '@/pages/reporting/page-header/custom-page-header';
|
||||||
import { DownOutlined } from '@ant-design/icons';
|
import { DownOutlined } from '@ant-design/icons';
|
||||||
import ProjectReportsTable from './projects-reports-table/projects-reports-table';
|
import ProjectReportsTable from './projects-reports-table/projects-reports-table';
|
||||||
@@ -20,40 +21,60 @@ const ProjectsReports = () => {
|
|||||||
|
|
||||||
const { total, archived } = useAppSelector(state => state.projectReportsReducer);
|
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) {
|
if (currentSession?.team_name) {
|
||||||
reportingExportApiService.exportProjects(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 (
|
return (
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
<CustomPageHeader
|
<CustomPageHeader
|
||||||
title={`${total === 1 ? `${total} ${t('projectCount')}` : `${total} ${t('projectCountPlural')}`} `}
|
title={pageTitle}
|
||||||
children={
|
children={headerChildren}
|
||||||
<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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card title={<ProjectsReportsFilters />}>
|
<Card title={cardTitle}>
|
||||||
<ProjectReportsTable />
|
<ProjectReportsTable />
|
||||||
</Card>
|
</Card>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProjectsReports;
|
export default memo(ProjectsReports);
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ const ReportingSider = () => {
|
|||||||
theme={{
|
theme={{
|
||||||
components: {
|
components: {
|
||||||
Menu: {
|
Menu: {
|
||||||
itemHoverBg: colors.transparent,
|
|
||||||
itemHoverColor: colors.skyBlue,
|
|
||||||
borderRadius: 12,
|
|
||||||
itemMarginBlock: 4,
|
|
||||||
subMenuItemBg: colors.transparent,
|
subMenuItemBg: colors.transparent,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,37 +1,364 @@
|
|||||||
import { setSelectOrDeselectAllProjects, setSelectOrDeselectProject } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
import { setSelectOrDeselectAllProjects, setSelectOrDeselectProject } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { CaretDownFilled } from '@ant-design/icons';
|
import { CaretDownFilled, SearchOutlined, ClearOutlined, DownOutlined, RightOutlined, FilterOutlined } from '@ant-design/icons';
|
||||||
import { Button, Checkbox, Divider, Dropdown, Input, theme } from 'antd';
|
import { Button, Checkbox, Divider, Dropdown, Input, theme, Typography, Badge, Collapse, Select, Space, Tooltip, Empty } from 'antd';
|
||||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 Projects: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [checkedList, setCheckedList] = useState<string[]>([]);
|
|
||||||
const [searchText, setSearchText] = useState('');
|
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 { t } = useTranslation('time-report');
|
||||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||||
const { projects, loadingProjects } = useAppSelector(state => state.timeReportsOverviewReducer);
|
const { projects, loadingProjects } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
|
|
||||||
// Filter items based on search text
|
// Theme-aware color utilities
|
||||||
const filteredItems = projects.filter(item =>
|
const getThemeAwareColor = useCallback((lightColor: string, darkColor: string) => {
|
||||||
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
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
|
// 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(setSelectOrDeselectProject({ id: key, selected: checked }));
|
||||||
};
|
}, [dispatch]);
|
||||||
|
|
||||||
// Handle "Select All" checkbox change
|
// Handle "Select All" checkbox change
|
||||||
const handleSelectAllChange = (e: CheckboxChangeEvent) => {
|
const handleSelectAllChange = useCallback((e: CheckboxChangeEvent) => {
|
||||||
const isChecked = e.target.checked;
|
const isChecked = e.target.checked;
|
||||||
setSelectAll(isChecked);
|
|
||||||
dispatch(setSelectOrDeselectAllProjects(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 (
|
return (
|
||||||
@@ -40,71 +367,189 @@ const Projects: React.FC = () => {
|
|||||||
menu={undefined}
|
menu={undefined}
|
||||||
placement="bottomLeft"
|
placement="bottomLeft"
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
|
open={dropdownVisible}
|
||||||
dropdownRender={() => (
|
dropdownRender={() => (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: token.colorBgContainer,
|
...dropdownStyles.dropdown,
|
||||||
borderRadius: token.borderRadius,
|
padding: '8px 0',
|
||||||
boxShadow: token.boxShadow,
|
maxHeight: '500px',
|
||||||
padding: '4px 0',
|
width: '400px',
|
||||||
maxHeight: '330px',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column'
|
flexDirection: 'column'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ padding: '8px', flexShrink: 0 }}>
|
{/* Header with search and controls */}
|
||||||
<Input
|
<div style={{ padding: '8px 12px', flexShrink: 0 }}>
|
||||||
onClick={e => e.stopPropagation()}
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
placeholder={t('searchByProject')}
|
{/* Search input */}
|
||||||
value={searchText}
|
<Input
|
||||||
onChange={e => setSearchText(e.target.value)}
|
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>
|
||||||
<div style={{ padding: '0 12px', flexShrink: 0 }}>
|
|
||||||
|
{/* Select All checkbox */}
|
||||||
|
<div style={{ padding: '8px 12px', flexShrink: 0 }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
onChange={handleSelectAllChange}
|
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>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
|
|
||||||
|
<Divider style={{ margin: '8px 0', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Projects list */}
|
||||||
<div style={{
|
<div style={{
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
flex: 1
|
flex: 1,
|
||||||
|
padding: '0 12px'
|
||||||
}}>
|
}}>
|
||||||
{filteredItems.map(item => (
|
{filteredProjects.length === 0 ? (
|
||||||
<div
|
<Empty
|
||||||
key={item.id}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
style={{
|
description={
|
||||||
padding: '8px 12px',
|
<Text style={{
|
||||||
cursor: 'pointer',
|
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary)
|
||||||
'&:hover': {
|
}}>
|
||||||
backgroundColor: token.colorBgTextHover
|
{searchText ? noProjectsText : noDataText}
|
||||||
}
|
</Text>
|
||||||
}}
|
}
|
||||||
>
|
style={{ margin: '20px 0' }}
|
||||||
<Checkbox
|
/>
|
||||||
onClick={e => e.stopPropagation()}
|
) : (
|
||||||
checked={item.selected}
|
groupedProjects.map(renderProjectGroup)
|
||||||
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
|
)}
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
onOpenChange={visible => {
|
onOpenChange={visible => {
|
||||||
setDropdownVisible(visible);
|
setDropdownVisible(visible);
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
setSearchText('');
|
setSearchText('');
|
||||||
|
setShowSelectedOnly(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button loading={loadingProjects}>
|
<Badge count={selectedCount} size="small" offset={[-5, 5]}>
|
||||||
{t('projects')} <CaretDownFilled />
|
<Button loading={loadingProjects}>
|
||||||
</Button>
|
<Space>
|
||||||
|
{t('projects')}
|
||||||
|
<CaretDownFilled />
|
||||||
|
</Space>
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -79,6 +79,83 @@
|
|||||||
border: 1px solid #d3d3d3;
|
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 */
|
/* reporting sidebar */
|
||||||
.custom-reporting-sider .ant-menu-item-selected {
|
.custom-reporting-sider .ant-menu-item-selected {
|
||||||
border-inline-end: 3px solid #1890ff !important;
|
border-inline-end: 3px solid #1890ff !important;
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export interface IBillingAccountInfo {
|
|||||||
unit_price?: number;
|
unit_price?: number;
|
||||||
unit_price_per_month?: number;
|
unit_price_per_month?: number;
|
||||||
usedPercentage?: number;
|
usedPercentage?: number;
|
||||||
|
used_percent?: number;
|
||||||
usedStorage?: number;
|
usedStorage?: number;
|
||||||
is_custom?: boolean;
|
is_custom?: boolean;
|
||||||
is_ltd_user?: boolean;
|
is_ltd_user?: boolean;
|
||||||
|
|||||||
@@ -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 { IProjectCategory } from '@/types/project/projectCategory.types';
|
||||||
import { IProjectStatus } from '@/types/project/projectStatus.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 {
|
export interface IProject {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -45,3 +50,102 @@ export enum IProjectFilter {
|
|||||||
Favourites = 'Favorites',
|
Favourites = 'Favorites',
|
||||||
Archived = 'Archived',
|
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;
|
filter: string | null;
|
||||||
categories: string | null;
|
categories: string | null;
|
||||||
statuses: 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 {
|
export interface ISelectableProject extends IProject {
|
||||||
selected?: boolean;
|
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 {
|
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