Compare commits

...

55 Commits

Author SHA1 Message Date
Chamika J
a0cf5099f8 Merge pull request #162 from Worklenz/imp/kanban-performance
Imp/kanban performance
2025-06-19 10:35:44 +05:30
Chamika J
301b58f0ba Merge pull request #161 from shancds/release/v2.0.3-kanban-handle-drag-over
Release/v2.0.3 kanban handle drag over
2025-06-19 08:31:43 +05:30
shancds
4c4a860c76 feat(board): enhance task and subtask management in board components
- Updated boardSlice to allow updating task assignees and names for both main tasks and subtasks.
- Improved BoardSubTaskCard to include context menu options for assigning tasks, deleting subtasks, and handling errors.
- Refactored BoardViewTaskCard to integrate dropdown menus for better task interaction and organization.
- Enhanced user experience by adding loading states and error handling for task actions.
2025-06-18 17:11:39 +05:30
shancds
193288013e refactor(priority-section): remove task name display from PrioritySection
- Eliminated the task name display from the PrioritySection component for a cleaner layout.
- Updated BoardViewTaskCard to include task name alongside the PrioritySection for improved organization.
2025-06-18 15:31:00 +05:30
shancds
39e8add103 feat(filters): enhance labels and members filter dropdowns
- Added useEffect to fetch labels when the component mounts or projectId changes in LabelsFilterDropdown.
- Improved members filter logic to only sync board members when the board task assignees are empty.
- Cleaned up redundant checks and optimized dependencies in the members filter dropdown.
2025-06-18 12:56:24 +05:30
shancds
0f82c9738b feat(project-view-board): implement task priority change handling
- Added a new function to handle task priority changes via socket events.
- Integrated priority change logic into the drag-and-drop functionality for improved task management.
- Cleaned up unused imports and improved code organization for better readability.
2025-06-18 12:18:58 +05:30
shancds
a4237a6f17 refactor(project-view-board): update collision detection strategy for drag-and-drop
- Replaced `closestCorners` with `collisionDetectionStrategy` to enhance drag-and-drop functionality.
- Aims to improve performance and accuracy during task movement on the project board.
2025-06-18 09:45:37 +05:30
Chamika J
20039a07ff Merge pull request #160 from Worklenz/imp/kanban-performance
Imp/kanban performance
2025-06-18 08:37:17 +05:30
Chamika J
dfc38a6829 Merge pull request #159 from shancds/release/v2.0.3-kanban-handle-drag-over
Release/v2.0.3 kanban handle drag over
2025-06-18 08:35:17 +05:30
shancds
0e0d1a5f11 refactor(project-view-board): clean up code and improve task handling logic
- Removed unnecessary conditional checks and whitespace for better readability.
- Streamlined task movement logic to enhance performance during drag-and-drop operations.
- Improved socket event emission for task sort order changes, ensuring more reliable updates.
- Cleaned up comments and organized code structure for clarity.
2025-06-17 16:46:36 +05:30
shancds
4dbaab060a Merge branch 'release/v2.0.3' of https://github.com/Worklenz/worklenz into release/v2.0.3-kanban-handle-drag-over 2025-06-17 09:54:07 +05:30
shancds
b8811ab5b6 refactor(board-section): optimize component rendering and enhance task card functionality
- Imported React to ensure proper usage of hooks.
- Wrapped `BoardSectionCardContainer` in `React.memo` for performance optimization.
- Integrated `useAuthService` to manage user session within `BoardViewTaskCard`.
- Replaced priority icon rendering with a dedicated `PrioritySection` component for cleaner code and improved readability.
- Cleaned up unused code and improved overall structure of task card rendering.
2025-06-16 17:13:36 +05:30
Chamika J
5248c26b76 Merge pull request #157 from Worklenz/feature/project-list-grouping
Feature/project list grouping
2025-06-16 10:05:54 +05:30
chamikaJ
eed0fb6eca chore(deps): update brace-expansion to version 2.0.2 in package-lock.json 2025-06-16 09:51:03 +05:30
Chamika J
2a9447b506 Merge pull request #156 from Worklenz/imp/kanban-performance
Imp/kanban performance
2025-06-16 08:58:49 +05:30
Chamika J
fb94028410 Merge pull request #155 from shancds/release/v2.0.3-kanban-handle-drag-over
Release/v2.0.3 kanban handle drag over
2025-06-16 08:46:14 +05:30
chamikaJ
25639afe1a refactor(reporting): optimize project reports components with memoization
- Refactored ProjectsReports, ProjectsReportsFilters, and ProjectsReportsTable components to utilize React.memo, useCallback, and useMemo for improved performance and reduced unnecessary re-renders.
- Memoized various handlers and configurations to enhance rendering efficiency and maintain responsiveness.
- Updated component exports to use memoization, ensuring optimal performance during re-renders.
2025-06-13 16:37:03 +05:30
chamikaJ
4426b5f3ef feat(reporting): enhance overview reports with memoization and dark mode support
- Refactored components in the reporting section to utilize React.memo, useCallback, and useMemo for improved performance and reduced unnecessary re-renders.
- Updated the OverviewStatCard to support dark mode styling and added enhanced hover effects.
- Improved the Avatars component by memoizing rendering logic and preventing event propagation.
- Enhanced OverviewReportsTable with memoized columns and row props for better performance.
- Applied consistent styling adjustments across various components to ensure a cohesive user experience.
2025-06-13 16:04:32 +05:30
chamikaJ
3cae2771de feat(projects): implement grouped project retrieval and UI enhancements
- Added a new endpoint for retrieving projects grouped by category, client, or status.
- Enhanced the ProjectsController with a method to handle grouped project queries.
- Updated the projects API router to include the new grouped endpoint.
- Improved the frontend to support displaying grouped projects with pagination and filtering options.
- Updated localization files for English, Spanish, and Portuguese to include new grouping options.
- Refactored project list components to accommodate the new grouped view and improved UI elements.
2025-06-13 15:46:03 +05:30
chamikaJ
81f55adb41 feat(projects): enhance project selection and grouping functionality
- Added grouping options for projects by category, team, and status in the project list.
- Implemented search functionality with a clear search option.
- Improved UI with expandable/collapsible project groups and selection summary.
- Updated localization files for English, Spanish, and Portuguese to include new grouping and UI strings.
- Enhanced project type definitions to support additional grouping properties.
2025-06-13 13:16:25 +05:30
chamikaJ
bd4c88833d Merge branch 'release/v2.0.3' of https://github.com/Worklenz/worklenz into feature/project-list-grouping 2025-06-13 13:02:57 +05:30
Chamika J
2374d7a357 Merge pull request #152 from OminduHirushka/upstream/feature/project-groupby
Projects - List / Group View
2025-06-13 13:02:30 +05:30
Chamika J
91730026fd Merge branch 'feature/project-list-grouping' into upstream/feature/project-groupby 2025-06-13 13:02:17 +05:30
shancds
9d10b23ba7 Merge branch 'release/v2.0.3' of https://github.com/Worklenz/worklenz into release/v2.0.3-kanban-handle-drag-over 2025-06-13 08:40:27 +05:30
chamiakJ
d0c231ee43 fix(account-storage): update billingInfo property name for progress percentage
- Changed the property name from `usedPercentage` to `used_percent` in the billing account info interface and updated the corresponding usage in the AccountStorage component to ensure consistency.
2025-06-12 14:28:05 +05:30
chamiakJ
58ce8e40c7 fix(migrations): correct progress_mode type casting in SQL migration
- Updated the SQL migration to ensure proper type casting for progress_mode when updating progress values based on project_id.
2025-06-12 14:11:04 +05:30
shancds
2aa4fe9673 feat(project-view-board): enhance drag-and-drop functionality and optimize task handling
- Added debounced task movement to prevent rapid updates during drag-and-drop operations.
- Implemented a custom collision detection strategy for improved task placement logic.
- Introduced new refs and state management for better handling of drag events and task cloning.
- Refactored drag event handlers to streamline task movement between groups and sections.
- Enhanced loading state management and cleanup for better user experience during task interactions.
2025-06-11 14:50:48 +05:30
chamikaJ
ccb50e3c62 feat(navbar): add HelpButton to the navbar and comment out TimerButton
- Temporarily commented out TimerButton for future adjustments.
2025-06-11 12:59:17 +05:30
chamikaJ
5ce9e66fea feat(timer-button): enhance error handling and improve timer updates
- Added error state management and logging for API calls and timer updates.
- Refactored timer update logic to handle invalid data and improve robustness.
- Updated dropdown rendering to display error messages and handle empty states more gracefully.
- Improved socket event handling with error logging for better debugging.
2025-06-11 12:55:53 +05:30
chamikaJ
6492a4672b Merge branch 'feature/recurring-tasks' of https://github.com/Worklenz/worklenz into release/v2.0.3 2025-06-11 12:41:04 +05:30
Chamika J
46acb26c42 Merge pull request #153 from shancds/fix/project-list-issues
feat(locales): add 'enterProjectKey' translation to project drawer fo…
2025-06-11 12:36:49 +05:30
shancds
c9aab73a2a Merge branch 'release/v2.0.3' of https://github.com/Worklenz/worklenz into fix/release/v2.0.3-update 2025-06-11 12:01:45 +05:30
shancds
13a202cca4 feat(locales): add 'enterProjectKey' translation to project drawer for en, es, and pt 2025-06-09 14:40:46 +05:30
chamikaJ
bdb9c9ca28 feat(project-subscribers): implement project subscriber management and loading state
- Added `getProjectSubscribers` method in `TasksControllerV2` to retrieve project subscribers with user details.
- Updated socket command to handle project subscription changes, ensuring no duplicate entries on conflict.
- Enhanced `ProjectViewHeader` to manage subscription loading state, providing user feedback during subscription updates.
- Implemented error handling and timeout for subscription requests to improve user experience.
2025-06-09 13:13:45 +05:30
chamikaJ
5ed5a86bad refactor(tasks-controller): enhance progress calculation logic for tasks without subtasks
- Updated progress calculation to consider project settings for time-based progress.
- Implemented a cap on progress to prevent exceeding 100%.
- Defaulted progress to 0% when time-based calculation is not enabled, improving accuracy in task status representation.
2025-06-09 13:12:24 +05:30
chamikaJ
520888988e feat(task-list): implement drag-and-drop functionality for task reordering
- Integrated drag-and-drop capabilities in the task list using `@dnd-kit` for improved user experience.
- Created a `DraggableRow` component to handle individual task dragging and dropping.
- Updated task list rendering to support dynamic reordering and socket integration for backend persistence.
- Enhanced task selection and hover effects for better visual feedback during drag operations.
- Refactored task list components to streamline rendering and improve performance.
2025-06-09 09:58:28 +05:30
chamiakJ
de28f87c62 refactor(task-drag-and-drop): remove unused drag-and-drop hook and simplify task group handling
- Deleted `useTaskDragAndDrop` hook to streamline drag-and-drop functionality.
- Updated `TaskGroupWrapperOptimized` to remove drag-and-drop context and simplify rendering.
- Refactored `TaskListTable` to integrate drag-and-drop directly, enhancing performance and maintainability.
- Adjusted task rendering logic to ensure proper handling of task states during drag operations.
2025-06-09 07:19:15 +05:30
Omindu Hirushka
e9e9bffd9a group by client / category 2025-06-06 13:23:23 +05:30
Chamika J
8f181c687b Merge pull request #150 from Worklenz/feature/recurring-tasks
Feature/recurring tasks
2025-06-06 12:09:03 +05:30
Chamika J
926c058d1e Merge pull request #149 from shancds/fix/home-task-list-status-update
Fix/home task list status update
2025-06-06 12:06:26 +05:30
shancds
1583221232 fix: enable automatic refetching for task queries in dropdown and date picker components 2025-06-06 11:45:22 +05:30
Omindu Hirushka
585a65be31 current updates 2025-06-06 09:47:42 +05:30
Tharindu Kosgahakumbura
2de9b7f6b7 Merge branch 'Worklenz:main' into fix/kanban-board-optimization 2025-06-06 09:10:32 +05:30
Chamika J
323b17185c Merge pull request #148 from kithmina1999/fix/postgres-password-auth
fix: change DB_PASSWORD to static value
2025-06-05 13:18:41 +05:30
kithmina1999
09f44a5685 fix: change DB_PASSWORD to static value for development
Using a static password simplifies development environment setup. The previous random password generation caused issues during local testing and debugging.
2025-06-05 10:40:06 +05:30
Omindu Hirushka
0e67434515 list/group view toggle button 2025-06-02 15:38:50 +05:30
Chamika J
f4ab7841fb Merge pull request #105 from MRNafisiA/main
increase the memory limit to prevent crashing during build time.
2025-05-30 10:15:47 +05:30
Chamika J
3de4f69a62 Merge pull request #142 from gdevenyi/patch-2
Generate random passwords in update-docker-env.sh
2025-05-30 09:42:46 +05:30
Gabriel A. Devenyi
102be2c24a Generate random passwords in update-docker-env.sh 2025-05-29 15:56:56 -04:00
Omindu Hirushka
378dc22bb0 setting up 2025-05-29 11:10:22 +05:30
Chamika J
3a39b25e64 Merge pull request #144 from kithmina1999/docs/add-video-guides
Update README.md to include video guides for local and remote deployment
2025-05-28 13:01:16 +05:30
kithmina1999
32248f8424 Update README.md to include video guides for local and remote deployment
- Added a section for a video guide on local Docker deployment.
- Included a video guide for deploying Worklenz to a remote server.
2025-05-28 09:32:32 +05:30
Chamika J
7e431d645a Merge pull request #134 from Worklenz/chamikaJ-patch-1
Update README.md
2025-05-21 08:28:32 +05:30
Chamika J
cef4bffd69 Update README.md
updated logo URL
2025-05-21 08:28:09 +05:30
MRNafisiA
75391641fd increase the memory limit to prevent crashing during build time. 2025-05-02 15:53:48 +03:30
81 changed files with 9583 additions and 6934 deletions

View File

@@ -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
View File

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

View File

@@ -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")

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -756,4 +756,186 @@ export default class ProjectsController extends WorklenzControllerBase {
} }
@HandleExceptions()
public static async getGrouped(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
// Use qualified field name for projects to avoid ambiguity
const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, ["projects.name"]);
const groupBy = req.query.groupBy as string || "category";
const filterByMember = !req.user?.owner && !req.user?.is_admin ?
` AND is_member_of_project(projects.id, '${req.user?.id}', $1) ` : "";
const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : "";
const isArchived = req.query.filter === "2"
? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`
: ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`;
const categories = this.getFilterByCategoryWhereClosure(req.query.categories as string);
const statuses = this.getFilterByStatusWhereClosure(req.query.statuses as string);
// Determine grouping field and join based on groupBy parameter
let groupField = "";
let groupName = "";
let groupColor = "";
let groupJoin = "";
let groupByFields = "";
let groupOrderBy = "";
switch (groupBy) {
case "client":
groupField = "COALESCE(projects.client_id::text, 'no-client')";
groupName = "COALESCE(clients.name, 'No Client')";
groupColor = "'#688'";
groupJoin = "LEFT JOIN clients ON projects.client_id = clients.id";
groupByFields = "projects.client_id, clients.name";
groupOrderBy = "COALESCE(clients.name, 'No Client')";
break;
case "status":
groupField = "COALESCE(projects.status_id::text, 'no-status')";
groupName = "COALESCE(sys_project_statuses.name, 'No Status')";
groupColor = "COALESCE(sys_project_statuses.color_code, '#888')";
groupJoin = "LEFT JOIN sys_project_statuses ON projects.status_id = sys_project_statuses.id";
groupByFields = "projects.status_id, sys_project_statuses.name, sys_project_statuses.color_code";
groupOrderBy = "COALESCE(sys_project_statuses.name, 'No Status')";
break;
case "category":
default:
groupField = "COALESCE(projects.category_id::text, 'uncategorized')";
groupName = "COALESCE(project_categories.name, 'Uncategorized')";
groupColor = "COALESCE(project_categories.color_code, '#888')";
groupJoin = "LEFT JOIN project_categories ON projects.category_id = project_categories.id";
groupByFields = "projects.category_id, project_categories.name, project_categories.color_code";
groupOrderBy = "COALESCE(project_categories.name, 'Uncategorized')";
}
// Ensure sortField is properly qualified for the inner project query
let qualifiedSortField = sortField;
if (Array.isArray(sortField)) {
qualifiedSortField = sortField[0]; // Take the first field if it's an array
}
// Replace "projects." with "p2." for the inner query
const innerSortField = qualifiedSortField.replace("projects.", "p2.");
const q = `
SELECT ROW_TO_JSON(rec) AS groups
FROM (
SELECT COUNT(DISTINCT ${groupField}) AS total_groups,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(group_data))), '[]'::JSON)
FROM (
SELECT ${groupField} AS group_key,
${groupName} AS group_name,
${groupColor} AS group_color,
COUNT(*) AS project_count,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(project_data))), '[]'::JSON)
FROM (
SELECT p2.id,
p2.name,
(SELECT sys_project_statuses.name FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status,
(SELECT sys_project_statuses.color_code FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_color,
(SELECT sys_project_statuses.icon FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_icon,
EXISTS(SELECT user_id
FROM favorite_projects
WHERE user_id = '${req.user?.id}'
AND project_id = p2.id) AS favorite,
EXISTS(SELECT user_id
FROM archived_projects
WHERE user_id = '${req.user?.id}'
AND project_id = p2.id) AS archived,
p2.color_code,
p2.start_date,
p2.end_date,
p2.category_id,
(SELECT COUNT(*)
FROM tasks
WHERE archived IS FALSE
AND project_id = p2.id) AS all_tasks_count,
(SELECT COUNT(*)
FROM tasks
WHERE archived IS FALSE
AND project_id = p2.id
AND status_id IN (SELECT task_statuses.id
FROM task_statuses
WHERE task_statuses.project_id = p2.id
AND task_statuses.category_id IN
(SELECT sys_task_status_categories.id FROM sys_task_status_categories WHERE sys_task_status_categories.is_done IS TRUE))) AS completed_tasks_count,
(SELECT COUNT(*)
FROM project_members
WHERE project_members.project_id = p2.id) AS members_count,
(SELECT get_project_members(p2.id)) AS names,
(SELECT clients.name FROM clients WHERE clients.id = p2.client_id) AS client_name,
(SELECT users.name FROM users WHERE users.id = p2.owner_id) AS project_owner,
(SELECT project_categories.name FROM project_categories WHERE project_categories.id = p2.category_id) AS category_name,
(SELECT project_categories.color_code
FROM project_categories
WHERE project_categories.id = p2.category_id) AS category_color,
((SELECT project_members.team_member_id as team_member_id
FROM project_members
WHERE project_members.project_id = p2.id
AND project_members.project_access_level_id = (SELECT project_access_levels.id FROM project_access_levels WHERE project_access_levels.key = 'PROJECT_MANAGER'))) AS project_manager_team_member_id,
(SELECT project_members.default_view
FROM project_members
WHERE project_members.project_id = p2.id
AND project_members.team_member_id = '${req.user?.team_member_id}') AS team_member_default_view,
(SELECT CASE
WHEN ((SELECT MAX(tasks.updated_at)
FROM tasks
WHERE tasks.archived IS FALSE
AND tasks.project_id = p2.id) >
p2.updated_at)
THEN (SELECT MAX(tasks.updated_at)
FROM tasks
WHERE tasks.archived IS FALSE
AND tasks.project_id = p2.id)
ELSE p2.updated_at END) AS updated_at
FROM projects p2
${groupJoin.replace("projects.", "p2.")}
WHERE p2.team_id = $1
AND ${groupField.replace("projects.", "p2.")} = ${groupField}
${categories.replace("projects.", "p2.")}
${statuses.replace("projects.", "p2.")}
${isArchived.replace("projects.", "p2.")}
${isFavorites.replace("projects.", "p2.")}
${filterByMember.replace("projects.", "p2.")}
${searchQuery.replace("projects.", "p2.")}
ORDER BY ${innerSortField} ${sortOrder}
) project_data
) AS projects
FROM projects
${groupJoin}
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
GROUP BY ${groupByFields}
ORDER BY ${groupOrderBy}
LIMIT $2 OFFSET $3
) group_data
) AS data
FROM projects
${groupJoin}
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
) rec;
`;
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
const [data] = result.rows;
// Process the grouped data
for (const group of data?.groups.data || []) {
for (const project of group.projects || []) {
project.progress = project.all_tasks_count > 0
? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0;
project.updated_at_string = moment(project.updated_at).fromNow();
project.names = this.createTagList(project?.names);
project.names.map((a: any) => a.color_code = getColor(a.name));
if (project.project_manager_team_member_id) {
project.project_manager = {
id: project.project_manager_team_member_id
};
}
}
}
return res.status(200).send(new ServerResponse(true, data?.groups || { total_groups: 0, data: [] }));
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ projectsApiRouter.get("/update-exist-sort-order", safeControllerFunction(Project
projectsApiRouter.post("/", teamOwnerOrAdminValidator, projectsBodyValidator, safeControllerFunction(ProjectsController.create)); projectsApiRouter.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));

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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"
}
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
}
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
}
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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}`);

View File

@@ -76,6 +76,8 @@ import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/g
import homePageApiService from '@/api/home-page/home-page.api.service'; import 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({
@@ -114,6 +116,8 @@ export const store = configureStore({
boardReducer: boardReducer, boardReducer: boardReducer,
projectDrawerReducer: projectDrawerReducer, projectDrawerReducer: projectDrawerReducer,
projectViewReducer: projectViewReducer,
// Project Lookups // Project Lookups
projectCategoriesReducer: projectCategoriesReducer, projectCategoriesReducer: projectCategoriesReducer,
projectStatusesReducer: projectStatusesReducer, projectStatusesReducer: projectStatusesReducer,

View File

@@ -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>}
/> />

View File

@@ -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' }}>

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,13 +23,13 @@ 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);

View File

@@ -1,132 +0,0 @@
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAuthService } from '@/hooks/useAuth';
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
import { ColumnsType } from 'antd/es/table';
import { ColumnFilterItem } from 'antd/es/table/interface';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { NavigateFunction } from 'react-router-dom';
import Avatars from '../avatars/avatars';
import { ActionButtons } from './project-list-table/project-list-actions/project-list-actions';
import { CategoryCell } from './project-list-table/project-list-category/project-list-category';
import { ProgressListProgress } from './project-list-table/project-list-progress/progress-list-progress';
import { ProjectListUpdatedAt } from './project-list-table/project-list-updated-at/project-list-updated';
import { ProjectNameCell } from './project-list-table/project-name/project-name-cell';
import { useAppSelector } from '@/hooks/useAppSelector';
import { ProjectRateCell } from './project-list-table/project-list-favorite/project-rate-cell';
const createFilters = (items: { id: string; name: string }[]) =>
items.map(item => ({ text: item.name, value: item.id })) as ColumnFilterItem[];
interface ITableColumnsProps {
navigate: NavigateFunction;
filteredInfo: any;
}
const TableColumns = ({
navigate,
filteredInfo,
}: ITableColumnsProps): ColumnsType<IProjectViewModel> => {
const { t } = useTranslation('all-project-list');
const dispatch = useAppDispatch();
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
const { filteredCategories, filteredStatuses } = useAppSelector(
state => state.projectsReducer
);
const columns = useMemo(
() => [
{
title: '',
dataIndex: 'favorite',
key: 'favorite',
render: (text: string, record: IProjectViewModel) => (
<ProjectRateCell key={record.id} t={t} record={record} />
),
},
{
title: t('name'),
dataIndex: 'name',
key: 'name',
sorter: true,
showSorterTooltip: false,
defaultSortOrder: 'ascend',
render: (text: string, record: IProjectViewModel) => (
<ProjectNameCell navigate={navigate} key={record.id} t={t} record={record} />
),
},
{
title: t('client'),
dataIndex: 'client_name',
key: 'client_name',
sorter: true,
showSorterTooltip: false,
},
{
title: t('category'),
dataIndex: 'category',
key: 'category_id',
filters: createFilters(
projectCategories.map(category => ({ id: category.id || '', name: category.name || '' }))
),
filteredValue: filteredInfo.category_id || filteredCategories || [],
filterMultiple: true,
render: (text: string, record: IProjectViewModel) => (
<CategoryCell key={record.id} t={t} record={record} />
),
sorter: true,
},
{
title: t('status'),
dataIndex: 'status',
key: 'status_id',
filters: createFilters(
projectStatuses.map(status => ({ id: status.id || '', name: status.name || '' }))
),
filteredValue: filteredInfo.status_id || [],
filterMultiple: true,
sorter: true,
},
{
title: t('tasksProgress'),
dataIndex: 'tasksProgress',
key: 'tasksProgress',
render: (_: string, record: IProjectViewModel) => <ProgressListProgress record={record} />,
},
{
title: t('updated_at'),
dataIndex: 'updated_at',
key: 'updated_at',
sorter: true,
showSorterTooltip: false,
render: (_: string, record: IProjectViewModel) => <ProjectListUpdatedAt record={record} />,
},
{
title: t('members'),
dataIndex: 'names',
key: 'members',
render: (members: InlineMember[]) => <Avatars members={members} />,
},
{
title: '',
key: 'button',
dataIndex: '',
render: (record: IProjectViewModel) => (
<ActionButtons
t={t}
record={record}
dispatch={dispatch}
isOwnerOrAdmin={isOwnerOrAdmin}
/>
),
},
],
[t, projectCategories, projectStatuses, filteredInfo, filteredCategories, filteredStatuses]
);
return columns as ColumnsType<IProjectViewModel>;
};
export default TableColumns;

View File

@@ -0,0 +1,563 @@
import React, { useMemo } from 'react';
import {
Card,
Col,
Empty,
Row,
Skeleton,
Typography,
Progress,
Tooltip,
Badge,
Space,
Avatar,
theme,
Divider
} from 'antd';
import {
ClockCircleOutlined,
TeamOutlined,
CheckCircleOutlined,
ProjectOutlined,
UserOutlined,
SettingOutlined,
InboxOutlined,
MoreOutlined
} from '@ant-design/icons';
import { ProjectGroupListProps } from '@/types/project/project.types';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { themeWiseColor } from '@/utils/themeWiseColor';
import {
fetchProjectData,
setProjectId,
toggleProjectDrawer
} from '@/features/project/project-drawer.slice';
import {
toggleArchiveProject,
toggleArchiveProjectForAll
} from '@/features/projects/projectsSlice';
import { useAuthService } from '@/hooks/useAuth';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import {
evt_projects_settings_click,
evt_projects_archive,
evt_projects_archive_all
} from '@/shared/worklenz-analytics-events';
import logger from '@/utils/errorLogger';
const { Title, Text } = Typography;
const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
groups,
navigate,
onProjectSelect,
loading,
t
}) => {
const { token } = theme.useToken();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const { trackMixpanelEvent } = useMixpanelTracking();
// Theme-aware color utilities
const getThemeAwareColor = (lightColor: string, darkColor: string) => {
return themeWiseColor(lightColor, darkColor, themeMode);
};
// Enhanced color processing for better contrast
const processColor = (color: string | undefined, fallback?: string) => {
if (!color) return fallback || token.colorPrimary;
if (color.startsWith('#')) {
if (themeMode === 'dark') {
const hex = color.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
if (brightness < 100) {
const factor = 1.5;
const newR = Math.min(255, Math.floor(r * factor));
const newG = Math.min(255, Math.floor(g * factor));
const newB = Math.min(255, Math.floor(b * factor));
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
}
} else {
const hex = color.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
if (brightness > 200) {
const factor = 0.7;
const newR = Math.floor(r * factor);
const newG = Math.floor(g * factor);
const newB = Math.floor(b * factor);
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
}
}
}
return color;
};
// Action handlers
const handleSettingsClick = (e: React.MouseEvent, projectId: string) => {
e.stopPropagation();
trackMixpanelEvent(evt_projects_settings_click);
dispatch(setProjectId(projectId));
dispatch(fetchProjectData(projectId));
dispatch(toggleProjectDrawer());
};
const handleArchiveClick = async (e: React.MouseEvent, projectId: string, isArchived: boolean) => {
e.stopPropagation();
try {
if (isOwnerOrAdmin) {
trackMixpanelEvent(evt_projects_archive_all);
await dispatch(toggleArchiveProjectForAll(projectId));
} else {
trackMixpanelEvent(evt_projects_archive);
await dispatch(toggleArchiveProject(projectId));
}
} catch (error) {
logger.error('Failed to archive project:', error);
}
};
// Memoized styles for better performance
const styles = useMemo(() => ({
container: {
padding: '0',
background: 'transparent',
},
groupSection: {
marginBottom: '24px',
background: 'transparent',
},
groupHeader: {
background: getThemeAwareColor(
`linear-gradient(135deg, ${token.colorFillAlter} 0%, ${token.colorFillTertiary} 100%)`,
`linear-gradient(135deg, ${token.colorFillQuaternary} 0%, ${token.colorFillSecondary} 100%)`
),
borderRadius: token.borderRadius,
padding: '12px 16px',
marginBottom: '12px',
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
boxShadow: getThemeAwareColor(
'0 1px 4px rgba(0, 0, 0, 0.06)',
'0 1px 4px rgba(0, 0, 0, 0.15)'
),
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
},
groupTitle: {
margin: 0,
color: getThemeAwareColor(token.colorText, token.colorTextBase),
fontSize: '16px',
fontWeight: 600,
letterSpacing: '-0.01em',
},
groupMeta: {
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
fontSize: '12px',
marginTop: '2px',
},
projectCard: {
height: '100%',
borderRadius: token.borderRadius,
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
boxShadow: getThemeAwareColor(
'0 1px 4px rgba(0, 0, 0, 0.04)',
'0 1px 4px rgba(0, 0, 0, 0.12)'
),
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
cursor: 'pointer',
overflow: 'hidden',
background: getThemeAwareColor(token.colorBgContainer, token.colorBgElevated),
},
projectCardHover: {
transform: 'translateY(-2px)',
boxShadow: getThemeAwareColor(
'0 4px 12px rgba(0, 0, 0, 0.08)',
'0 4px 12px rgba(0, 0, 0, 0.20)'
),
borderColor: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
},
statusBar: {
height: '3px',
background: 'linear-gradient(90deg, transparent 0%, currentColor 100%)',
borderRadius: '0 0 2px 2px',
},
projectContent: {
padding: '12px',
height: '100%',
display: 'flex',
flexDirection: 'column' as const,
minHeight: '200px', // Ensure minimum height for consistent card sizes
},
projectTitle: {
margin: '0 0 6px 0',
color: getThemeAwareColor(token.colorText, token.colorTextBase),
fontSize: '14px',
fontWeight: 600,
lineHeight: 1.3,
},
clientName: {
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
fontSize: '12px',
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '4px',
},
progressSection: {
marginBottom: '10px',
// Remove flex: 1 to prevent it from taking all available space
},
progressLabel: {
fontSize: '10px',
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
marginBottom: '4px',
fontWeight: 500,
textTransform: 'uppercase' as const,
letterSpacing: '0.3px',
},
metaGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '8px',
marginTop: 'auto', // This pushes the meta section to the bottom
paddingTop: '10px',
borderTop: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
flexShrink: 0, // Prevent the meta section from shrinking
},
metaItem: {
display: 'flex',
flexDirection: 'row' as const,
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderRadius: token.borderRadiusSM,
background: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
transition: 'all 0.2s ease',
},
metaContent: {
display: 'flex',
flexDirection: 'column' as const,
gap: '1px',
flex: 1,
},
metaIcon: {
fontSize: '12px',
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
},
metaValue: {
fontSize: '11px',
fontWeight: 600,
color: getThemeAwareColor(token.colorText, token.colorTextBase),
lineHeight: 1,
},
metaLabel: {
fontSize: '9px',
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
lineHeight: 1,
textTransform: 'uppercase' as const,
letterSpacing: '0.2px',
},
actionButtons: {
position: 'absolute' as const,
top: '8px',
right: '8px',
display: 'flex',
gap: '4px',
opacity: 0,
transition: 'opacity 0.2s ease',
},
actionButton: {
width: '24px',
height: '24px',
borderRadius: '4px',
border: 'none',
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
transition: 'all 0.2s ease',
backdropFilter: 'blur(4px)',
'&:hover': {
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
transform: 'scale(1.1)',
}
},
emptyState: {
padding: '60px 20px',
textAlign: 'center' as const,
background: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
borderRadius: token.borderRadiusLG,
border: `2px dashed ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
},
loadingContainer: {
padding: '40px 20px',
}
}), [token, themeMode, getThemeAwareColor]);
// Early return for loading state
if (loading) {
return (
<div style={styles.loadingContainer}>
<Skeleton active paragraph={{ rows: 6 }} />
</div>
);
}
// Early return for empty state
if (groups.length === 0) {
return (
<div style={styles.emptyState}>
<Empty
image={<ProjectOutlined style={{ fontSize: '48px', color: token.colorTextTertiary }} />}
description={
<div>
<Text style={{ fontSize: '16px', color: token.colorTextSecondary }}>
{t('noProjects')}
</Text>
<br />
<Text style={{ fontSize: '14px', color: token.colorTextTertiary }}>
Create your first project to get started
</Text>
</div>
}
/>
</div>
);
}
const renderProjectCard = (project: any) => {
const projectColor = processColor(project.color_code, token.colorPrimary);
const statusColor = processColor(project.status_color, token.colorPrimary);
const progress = project.progress || 0;
const completedTasks = project.completed_tasks_count || 0;
const totalTasks = project.all_tasks_count || 0;
const membersCount = project.members_count || 0;
return (
<Col key={project.id} xs={24} sm={12} md={8} lg={6} xl={4}>
<Card
style={{ ...styles.projectCard, position: 'relative' }}
onMouseEnter={(e) => {
Object.assign(e.currentTarget.style, styles.projectCardHover);
const actionButtons = e.currentTarget.querySelector('.action-buttons') as HTMLElement;
if (actionButtons) {
actionButtons.style.opacity = '1';
}
}}
onMouseLeave={(e) => {
Object.assign(e.currentTarget.style, styles.projectCard);
const actionButtons = e.currentTarget.querySelector('.action-buttons') as HTMLElement;
if (actionButtons) {
actionButtons.style.opacity = '0';
}
}}
onClick={() => onProjectSelect(project.id || '')}
bodyStyle={{ padding: 0 }}
>
{/* Action buttons */}
<div className="action-buttons" style={styles.actionButtons}>
<Tooltip title={t('setting')}>
<button
style={styles.actionButton}
onClick={(e) => handleSettingsClick(e, project.id)}
onMouseEnter={(e) => {
Object.assign(e.currentTarget.style, {
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
transform: 'scale(1.1)',
});
}}
onMouseLeave={(e) => {
Object.assign(e.currentTarget.style, {
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
transform: 'scale(1)',
});
}}
>
<SettingOutlined />
</button>
</Tooltip>
<Tooltip title={project.archived ? t('unarchive') : t('archive')}>
<button
style={styles.actionButton}
onClick={(e) => handleArchiveClick(e, project.id, project.archived)}
onMouseEnter={(e) => {
Object.assign(e.currentTarget.style, {
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
transform: 'scale(1.1)',
});
}}
onMouseLeave={(e) => {
Object.assign(e.currentTarget.style, {
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
transform: 'scale(1)',
});
}}
>
<InboxOutlined />
</button>
</Tooltip>
</div>
{/* Project color indicator bar */}
<div
style={{
...styles.statusBar,
color: projectColor,
}}
/>
<div style={styles.projectContent}>
{/* Project title */}
<Title level={5} ellipsis={{ rows: 2, tooltip: project.name }} style={styles.projectTitle}>
{project.name}
</Title>
{/* Client name */}
{project.client_name && (
<div style={styles.clientName}>
<UserOutlined />
<Text ellipsis style={{ color: 'inherit' }}>
{project.client_name}
</Text>
</div>
)}
{/* Progress section */}
<div style={styles.progressSection}>
<div style={styles.progressLabel}>
Progress
</div>
<Progress
percent={progress}
size="small"
strokeColor={{
'0%': projectColor,
'100%': statusColor,
}}
trailColor={getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)}
strokeWidth={4}
showInfo={false}
/>
<Text style={{
fontSize: '10px',
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
marginTop: '2px',
display: 'block'
}}>
{progress}%
</Text>
</div>
{/* Meta information grid */}
<div style={styles.metaGrid}>
<Tooltip title="Tasks completed">
<div style={styles.metaItem}>
<CheckCircleOutlined style={styles.metaIcon} />
<div style={styles.metaContent}>
<span style={styles.metaValue}>{completedTasks}/{totalTasks}</span>
<span style={styles.metaLabel}>Tasks</span>
</div>
</div>
</Tooltip>
<Tooltip title="Team members">
<div style={styles.metaItem}>
<TeamOutlined style={styles.metaIcon} />
<div style={styles.metaContent}>
<span style={styles.metaValue}>{membersCount}</span>
<span style={styles.metaLabel}>Members</span>
</div>
</div>
</Tooltip>
</div>
</div>
</Card>
</Col>
);
};
return (
<div style={styles.container}>
{groups.map((group, groupIndex) => (
<div key={group.groupKey} style={styles.groupSection}>
{/* Enhanced group header */}
<div style={styles.groupHeader}>
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
<Space align="center">
{group.groupColor && (
<div style={{
width: '16px',
height: '16px',
borderRadius: '50%',
backgroundColor: processColor(group.groupColor),
flexShrink: 0,
border: `2px solid ${getThemeAwareColor('rgba(255,255,255,0.8)', 'rgba(0,0,0,0.3)')}`
}} />
)}
<div>
<Title level={4} style={styles.groupTitle}>
{group.groupName}
</Title>
<div style={styles.groupMeta}>
{group.projects.length} {group.projects.length === 1 ? 'project' : 'projects'}
</div>
</div>
</Space>
<Badge
count={group.projects.length}
style={{
backgroundColor: processColor(group.groupColor, token.colorPrimary),
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
fontWeight: 600,
fontSize: '12px',
minWidth: '24px',
height: '24px',
lineHeight: '22px',
borderRadius: '12px',
border: `2px solid ${getThemeAwareColor(token.colorBgContainer, token.colorBgElevated)}`
}}
/>
</Space>
</div>
{/* Projects grid */}
<Row gutter={[16, 16]}>
{group.projects.map(renderProjectCard)}
</Row>
{/* Add spacing between groups except for the last one */}
{groupIndex < groups.length - 1 && (
<Divider style={{
margin: '32px 0 0 0',
borderColor: getThemeAwareColor(token.colorBorderSecondary, token.colorBorder),
opacity: 0.5
}} />
)}
</div>
))}
</div>
);
};
export default ProjectGroupList;

View File

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

View File

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

View File

@@ -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;
setSelectedMembers(prev =>
prev.some(mention => mention.team_member_id === member.value)
? prev
: [...prev, { team_member_id: member.value, name: member.label }]
);
setCommentValue(prev => { // Find the member ID from the members list using the name
const parts = prev.split('@'); const selectedMember = members.find(m => m.name === member.value);
const lastPart = parts[parts.length - 1]; if (!selectedMember) return;
const mentionText = member.label;
// Keep only the part before the @ and add the new mention // Add to selected members if not already present
return prev.slice(0, prev.length - lastPart.length) + mentionText; setSelectedMembers(prev =>
}); prev.some(mention => mention.team_member_id === selectedMember.id)
}, []); ? prev
: [...prev, { team_member_id: selectedMember.id!, name: selectedMember.name! }]
);
}, [members]);
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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,12 +5,17 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { IProjectCategory } from '@/types/project/projectCategory.types'; import { 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;

View File

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

View File

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

View File

@@ -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,7 +271,7 @@ 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}
/> />

View File

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

View File

@@ -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')}

View File

@@ -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);
// Memoize all tasks including subtasks for virtualization 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();
// 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>
); );
}; };

View File

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

View File

@@ -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}
/> />
), ),
}, },

View File

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

View File

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

View File

@@ -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 />
&nbsp;
<Typography.Text>{t('assignToMe')}</Typography.Text>
</span>
),
key: '1',
onClick: () => handleAssignToMe(),
disabled: updatingAssignToMe,
},
// {
// label: (
// <span>
// <InboxOutlined />
// &nbsp;
// <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> &nbsp;
</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>
); );
}; };

View File

@@ -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,38 +236,7 @@ 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;
@@ -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>
); );
}; };

View File

@@ -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();
@@ -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(() => {
@@ -88,13 +107,13 @@ const ProjectViewBoard = () => {
// 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,6 +121,63 @@ 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;
@@ -113,79 +189,109 @@ const ProjectViewBoard = () => {
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) {
@@ -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,6 +512,23 @@ const ProjectViewBoard = () => {
originalSourceGroupIdRef.current = null; // Reset the ref originalSourceGroupIdRef.current = null; // Reset the ref
}; };
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(() => { 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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}`}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
}, },
}, },

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
import { IProjectViewModel } from './projectViewModel.types';
export interface IProjectGroup {
group_key: string;
group_name: string;
group_color?: string;
project_count: number;
projects: IProjectViewModel[];
}
export interface IGroupedProjectsViewModel {
total_groups: number;
data: IProjectGroup[];
}

View File

@@ -1,5 +1,10 @@
import { IProjectCategory } from '@/types/project/projectCategory.types'; import { 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;
}

View File

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

View File

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

View File

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