Merge branch 'main' of https://github.com/Worklenz/worklenz into test/row-kanban-board-v1.2.0

This commit is contained in:
shancds
2025-07-15 09:00:38 +05:30
63 changed files with 2190 additions and 753 deletions

411
README.md
View File

@@ -6,6 +6,24 @@
Worklenz
</h1>
<p align="center">
<a href="https://github.com/Worklenz/worklenz/blob/main/LICENSE">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
</a>
<a href="https://github.com/Worklenz/worklenz/releases">
<img src="https://img.shields.io/github/v/release/Worklenz/worklenz" alt="Release">
</a>
<a href="https://github.com/Worklenz/worklenz/stargazers">
<img src="https://img.shields.io/github/stars/Worklenz/worklenz" alt="Stars">
</a>
<a href="https://github.com/Worklenz/worklenz/network/members">
<img src="https://img.shields.io/github/forks/Worklenz/worklenz" alt="Forks">
</a>
<a href="https://github.com/Worklenz/worklenz/issues">
<img src="https://img.shields.io/github/issues/Worklenz/worklenz" alt="Issues">
</a>
</p>
<p align="center">
<a href="https://worklenz.com/task-management/">Task Management</a> |
<a href="https://worklenz.com/time-tracking/">Time Tracking</a> |
@@ -27,6 +45,24 @@
Worklenz is a project management tool designed to help organizations improve their efficiency. It provides a
comprehensive solution for managing projects, tasks, and collaboration within teams.
## Table of Contents
- [Features](#features)
- [Tech Stack](#tech-stack)
- [Getting Started](#getting-started)
- [Quick Start (Docker)](#-quick-start-docker---recommended)
- [Manual Installation](#-manual-installation-for-development)
- [Deployment](#deployment)
- [Local Development](#local-development-with-docker)
- [Remote Server Deployment](#remote-server-deployment)
- [Configuration](#configuration)
- [MinIO Integration](#minio-integration)
- [Security](#security)
- [Analytics](#analytics)
- [Screenshots](#screenshots)
- [Contributing](#contributing)
- [License](#license)
## Features
- **Project Planning**: Create and organize projects, assign tasks to team members.
@@ -50,41 +86,80 @@ This repository contains the frontend and backend code for Worklenz.
## Getting Started
These instructions will help you set up and run the Worklenz project on your local machine for development and testing purposes.
Choose your preferred setup method below. Docker is recommended for quick setup and testing.
### Prerequisites
### 🚀 Quick Start (Docker - Recommended)
- Node.js (version 18 or higher)
- PostgreSQL database
- An S3-compatible storage service (like MinIO) or Azure Blob Storage
The fastest way to get Worklenz running locally with all dependencies included.
### Option 1: Manual Installation
**Prerequisites:**
- Docker and Docker Compose installed on your system
- Git
1. Clone the repository
**Steps:**
1. Clone the repository:
```bash
git clone https://github.com/Worklenz/worklenz.git
cd worklenz
```
2. Set up environment variables
- Copy the example environment files
```bash
cp worklenz-backend/.env.template worklenz-backend/.env
```
- Update the environment variables with your configuration
3. Install dependencies
2. Start the Docker containers:
```bash
# Install backend dependencies
docker-compose up -d
```
3. Access the application:
- **Frontend**: http://localhost:5000
- **Backend API**: http://localhost:3000
- **MinIO Console**: http://localhost:9001 (login: minioadmin/minioadmin)
4. To stop the services:
```bash
docker-compose down
```
**Alternative startup methods:**
- **Windows**: Run `start.bat`
- **Linux/macOS**: Run `./start.sh`
**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).
### 🛠️ Manual Installation (For Development)
For developers who want to run the services individually or customize the setup.
**Prerequisites:**
- Node.js (version 18 or higher)
- PostgreSQL (version 15 or higher)
- An S3-compatible storage service (like MinIO) or Azure Blob Storage
**Steps:**
1. Clone the repository:
```bash
git clone https://github.com/Worklenz/worklenz.git
cd worklenz
```
2. Set up environment variables:
```bash
cp worklenz-backend/.env.template worklenz-backend/.env
# Update the environment variables with your configuration
```
3. Install dependencies:
```bash
# Backend dependencies
cd worklenz-backend
npm install
# Install frontend dependencies
# Frontend dependencies
cd ../worklenz-frontend
npm install
```
4. Set up the database
4. Set up the database:
```bash
# Create a PostgreSQL database named worklenz_db
cd worklenz-backend
@@ -100,49 +175,47 @@ psql -U your_username -d worklenz_db -f database/sql/2_dml.sql
psql -U your_username -d worklenz_db -f database/sql/5_database_user.sql
```
5. Start the development servers
5. Start the development servers:
```bash
# In one terminal, start the backend
# Terminal 1: Start the backend
cd worklenz-backend
npm run dev
# In another terminal, start the frontend
# Terminal 2: Start the frontend
cd worklenz-frontend
npm run dev
```
6. Access the application at http://localhost:5000
### Option 2: Docker Setup
## Deployment
The project includes a fully configured Docker setup with:
- Frontend React application
- Backend server
- PostgreSQL database
- MinIO for S3-compatible storage
For local development, follow the [Quick Start (Docker)](#-quick-start-docker---recommended) section above.
1. Clone the repository:
```bash
git clone https://github.com/Worklenz/worklenz.git
cd worklenz
```
### Remote Server Deployment
2. Start the Docker containers (choose one option):
When deploying to a remote server:
**Using Docker Compose directly**
```bash
docker-compose up -d
```
1. Set up the environment files with your server's hostname:
```bash
# For HTTP/WS
./update-docker-env.sh your-server-hostname
3. The application will be available at:
- Frontend: http://localhost:5000
- Backend API: http://localhost:3000
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin)
# For HTTPS/WSS
./update-docker-env.sh your-server-hostname true
```
4. To stop the services:
```bash
docker-compose down
```
2. Pull and run the latest Docker images:
```bash
docker-compose pull
docker-compose up -d
```
3. Access the application through your server's hostname:
- Frontend: http://your-server-hostname:5000
- 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).
## Configuration
@@ -157,16 +230,46 @@ Worklenz requires several environment variables to be configured for proper oper
Please refer to the `.env.example` files for a full list of required variables.
### MinIO Integration
The Docker setup uses environment variables to configure the services:
- **Frontend:**
- `VITE_API_URL`: URL of the backend API (default: http://backend:3000 for container networking)
- `VITE_SOCKET_URL`: WebSocket URL for real-time communication (default: ws://backend:3000)
- **Backend:**
- Database connection parameters
- Storage configuration
- Other backend settings
For custom configuration, edit the `.env` file or the `update-docker-env.sh` script.
## 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.
### Working with MinIO
MinIO provides an S3-compatible API, so any code that works with S3 will work with MinIO by simply changing the endpoint URL. The backend has been configured to use MinIO by default, with no additional configuration required.
- **MinIO Console**: http://localhost:9001
- Username: minioadmin
- Password: minioadmin
- **Default Bucket**: worklenz-bucket (created automatically when the containers start)
### Backend Storage Configuration
The backend is pre-configured to use MinIO with the following settings:
```javascript
// S3 credentials with MinIO defaults
export const REGION = process.env.AWS_REGION || "us-east-1";
export const BUCKET = process.env.AWS_BUCKET || "worklenz-bucket";
export const S3_URL = process.env.S3_URL || "http://minio:9000/worklenz-bucket";
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "minioadmin";
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "minioadmin";
```
### Security Considerations
For production deployments:
@@ -177,20 +280,12 @@ For production deployments:
4. Enable HTTPS for all public endpoints
5. Review and update dependencies regularly
## Contributing
We welcome contributions from the community! If you'd like to contribute, please follow our [contributing guidelines](CONTRIBUTING.md).
## Security
If you believe you have found a security vulnerability in Worklenz, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports.
Email [info@worklenz.com](mailto:info@worklenz.com) to disclose any security vulnerabilities.
## License
This project is licensed under the [MIT License](LICENSE).
## Analytics
Worklenz uses Google Analytics to understand how the application is being used. This helps us improve the application and make better decisions about future development.
@@ -260,215 +355,13 @@ If you've previously opted in and want to opt-out:
</a>
</p>
### Contributing
## Contributing
We welcome contributions from the community! If you'd like to contribute, please follow
our [contributing guidelines](CONTRIBUTING.md).
We welcome contributions from the community! If you'd like to contribute, please follow our [contributing guidelines](CONTRIBUTING.md).
### License
## License
Worklenz is open source and released under the [GNU Affero General Public License Version 3 (AGPLv3)](LICENSE).
By contributing to Worklenz, you agree that your contributions will be licensed under its AGPL.
# Worklenz React
This repository contains the React version of Worklenz with a Docker setup for easy development and deployment.
## Getting Started with Docker
The project includes a fully configured Docker setup with:
- Frontend React application
- Backend server
- PostgreSQL database
- MinIO for S3-compatible storage
### Prerequisites
- Docker and Docker Compose installed on your system
- Git
### Quick Start
1. Clone the repository:
```bash
git clone https://github.com/Worklenz/worklenz.git
cd worklenz
```
2. Start the Docker containers (choose one option):
**Option 1: Using the provided scripts (easiest)**
- On Windows:
```
start.bat
```
- On Linux/macOS:
```bash
./start.sh
```
**Option 2: Using Docker Compose directly**
```bash
docker-compose up -d
```
3. The application will be available at:
- Frontend: http://localhost:5000
- Backend API: http://localhost:3000
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin)
4. To stop the services (choose one option):
**Option 1: Using the provided scripts**
- On Windows:
```
stop.bat
```
- On Linux/macOS:
```bash
./stop.sh
```
**Option 2: Using Docker Compose directly**
```bash
docker-compose down
```
## 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.
### Working with MinIO
MinIO provides an S3-compatible API, so any code that works with S3 will work with MinIO by simply changing the endpoint URL. The backend has been configured to use MinIO by default, with no additional configuration required.
- **MinIO Console**: http://localhost:9001
- Username: minioadmin
- Password: minioadmin
- **Default Bucket**: worklenz-bucket (created automatically when the containers start)
### Backend Storage Configuration
The backend is pre-configured to use MinIO with the following settings:
```javascript
// S3 credentials with MinIO defaults
export const REGION = process.env.AWS_REGION || "us-east-1";
export const BUCKET = process.env.AWS_BUCKET || "worklenz-bucket";
export const S3_URL = process.env.S3_URL || "http://minio:9000/worklenz-bucket";
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "minioadmin";
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "minioadmin";
```
The S3 client is initialized with special MinIO configuration:
```javascript
const s3Client = new S3Client({
region: REGION,
credentials: {
accessKeyId: S3_ACCESS_KEY_ID || "",
secretAccessKey: S3_SECRET_ACCESS_KEY || "",
},
endpoint: getEndpointFromUrl(), // Extracts endpoint from S3_URL
forcePathStyle: true, // Required for MinIO
});
```
### Environment Configuration
The project uses the following environment file structure:
- **Frontend**:
- `worklenz-frontend/.env.development` - Development environment variables
- `worklenz-frontend/.env.production` - Production build variables
- **Backend**:
- `worklenz-backend/.env` - Backend environment variables
### Setting Up Environment Files
The Docker environment script will create or overwrite all environment files:
```bash
# For HTTP/WS
./update-docker-env.sh your-hostname
# For HTTPS/WSS
./update-docker-env.sh your-hostname true
```
This script generates properly configured environment files for both development and production environments.
## Docker Deployment
### Local Development with Docker
1. Set up the environment files:
```bash
# For HTTP/WS
./update-docker-env.sh
# For HTTPS/WSS
./update-docker-env.sh localhost true
```
2. Run the application using Docker Compose:
```bash
docker-compose up -d
```
3. Access the application:
- Frontend: http://localhost:5000
- 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
When deploying to a remote server:
1. Set up the environment files with your server's hostname:
```bash
# For HTTP/WS
./update-docker-env.sh your-server-hostname
# For HTTPS/WSS
./update-docker-env.sh your-server-hostname true
```
This ensures that the frontend correctly connects to the backend API.
2. Pull and run the latest Docker images:
```bash
docker-compose pull
docker-compose up -d
```
3. Access the application through your server's hostname:
- Frontend: http://your-server-hostname:5000
- 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
The Docker setup uses environment variables to configure the services:
- Frontend:
- `VITE_API_URL`: URL of the backend API (default: http://backend:3000 for container networking)
- `VITE_SOCKET_URL`: WebSocket URL for real-time communication (default: ws://backend:3000)
- Backend:
- Database connection parameters
- Storage configuration
- Other backend settings
For custom configuration, edit the `.env` file or the `update-docker-env.sh` script.

View File

@@ -4,7 +4,7 @@ Getting started with development is a breeze! Follow these steps and you'll be c
## Requirements
- Node.js version v16 or newer - [Node.js](https://nodejs.org/en/download/)
- Node.js version v20 or newer - [Node.js](https://nodejs.org/en/download/)
- PostgreSQL version v15 or newer - [PostgreSQL](https://www.postgresql.org/download/)
- S3-compatible storage (like MinIO) for file storage
@@ -38,7 +38,7 @@ Getting started with development is a breeze! Follow these steps and you'll be c
npm start
```
4. Navigate to [http://localhost:5173](http://localhost:5173)
4. Navigate to [http://localhost:5173](http://localhost:5173) (development server)
### Backend installation
@@ -126,7 +126,7 @@ For an easier setup, you can use Docker and Docker Compose:
```
3. Access the application:
- Frontend: http://localhost:5000
- Frontend: http://localhost:5000 (Docker production build)
- Backend API: http://localhost:3000
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin)

View File

@@ -20,9 +20,6 @@ coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components

View File

@@ -0,0 +1,143 @@
-- Fix window function error in task sort optimized functions
-- Error: window functions are not allowed in UPDATE
-- Replace the optimized sort functions to avoid CTE usage in UPDATE statements
CREATE OR REPLACE FUNCTION handle_task_list_sort_between_groups_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_offset INT := 0;
_affected_rows INT;
BEGIN
-- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE
IF (_to_index = -1)
THEN
_to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0);
END IF;
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets
IF _to_index > _from_index
THEN
LOOP
UPDATE tasks
SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order < _to_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
UPDATE tasks SET sort_order = _to_index - 1 WHERE id = _task_id AND project_id = _project_id;
END IF;
IF _to_index < _from_index
THEN
_offset := 0;
LOOP
UPDATE tasks
SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order > _to_index
AND sort_order < _from_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
UPDATE tasks SET sort_order = _to_index + 1 WHERE id = _task_id AND project_id = _project_id;
END IF;
END
$$;
-- Replace the second optimized sort function
CREATE OR REPLACE FUNCTION handle_task_list_sort_inside_group_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_offset INT := 0;
_affected_rows INT;
BEGIN
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE
IF _to_index > _from_index
THEN
LOOP
UPDATE tasks
SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order <= _to_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
END IF;
IF _to_index < _from_index
THEN
_offset := 0;
LOOP
UPDATE tasks
SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order >= _to_index
AND sort_order < _from_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
END IF;
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id;
END
$$;
-- Add simple bulk update function as alternative
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_update_record RECORD;
BEGIN
-- Simple approach: update each task's sort_order from the provided array
FOR _update_record IN
SELECT
(item->>'task_id')::uuid as task_id,
(item->>'sort_order')::int as sort_order,
(item->>'status_id')::uuid as status_id,
(item->>'priority_id')::uuid as priority_id,
(item->>'phase_id')::uuid as phase_id
FROM json_array_elements(_updates) as item
LOOP
UPDATE tasks
SET
sort_order = _update_record.sort_order,
status_id = COALESCE(_update_record.status_id, status_id),
priority_id = COALESCE(_update_record.priority_id, priority_id)
WHERE id = _update_record.task_id;
-- Handle phase updates separately since it's in a different table
IF _update_record.phase_id IS NOT NULL THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_update_record.task_id, _update_record.phase_id)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
END IF;
END LOOP;
END
$$;

View File

@@ -5498,6 +5498,7 @@ DECLARE
_iterator NUMERIC := 0;
_status_id TEXT;
_project_id UUID;
_base_sort_order NUMERIC;
BEGIN
-- Get the project_id from the first status to ensure we update all statuses in the same project
SELECT project_id INTO _project_id
@@ -5513,17 +5514,28 @@ BEGIN
_iterator := _iterator + 1;
END LOOP;
-- Ensure any remaining statuses in the project (not in the provided list) get sequential sort_order
-- This handles edge cases where not all statuses are provided
UPDATE task_statuses
SET sort_order = (
SELECT COUNT(*)
FROM task_statuses ts2
WHERE ts2.project_id = _project_id
AND ts2.id = ANY(SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID)
) + ROW_NUMBER() OVER (ORDER BY sort_order) - 1
WHERE project_id = _project_id
AND id NOT IN (SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID);
-- Get the base sort order for remaining statuses (simple count approach)
SELECT COUNT(*) INTO _base_sort_order
FROM task_statuses ts2
WHERE ts2.project_id = _project_id
AND ts2.id = ANY(SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID);
-- Update remaining statuses with simple sequential numbering
-- Reset iterator to start from base_sort_order
_iterator := _base_sort_order;
-- Use a cursor approach to avoid window functions
FOR _status_id IN
SELECT id::TEXT FROM task_statuses
WHERE project_id = _project_id
AND id NOT IN (SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID)
ORDER BY sort_order
LOOP
UPDATE task_statuses
SET sort_order = _iterator
WHERE id = _status_id::UUID;
_iterator := _iterator + 1;
END LOOP;
RETURN;
END
@@ -6412,7 +6424,7 @@ DECLARE
_offset INT := 0;
_affected_rows INT;
BEGIN
-- PERFORMANCE OPTIMIZATION: Use CTE for better query planning
-- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE
IF (_to_index = -1)
THEN
_to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0);
@@ -6422,18 +6434,15 @@ BEGIN
IF _to_index > _from_index
THEN
LOOP
WITH batch_update AS (
UPDATE tasks
SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order < _to_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size
RETURNING 1
)
SELECT COUNT(*) INTO _affected_rows FROM batch_update;
UPDATE tasks
SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order < _to_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
@@ -6445,18 +6454,15 @@ BEGIN
THEN
_offset := 0;
LOOP
WITH batch_update AS (
UPDATE tasks
SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order > _to_index
AND sort_order < _from_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size
RETURNING 1
)
SELECT COUNT(*) INTO _affected_rows FROM batch_update;
UPDATE tasks
SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order > _to_index
AND sort_order < _from_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
@@ -6475,22 +6481,19 @@ DECLARE
_offset INT := 0;
_affected_rows INT;
BEGIN
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE
IF _to_index > _from_index
THEN
LOOP
WITH batch_update AS (
UPDATE tasks
SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order <= _to_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size
RETURNING 1
)
SELECT COUNT(*) INTO _affected_rows FROM batch_update;
UPDATE tasks
SET sort_order = sort_order - 1
WHERE project_id = _project_id
AND sort_order > _from_index
AND sort_order <= _to_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
@@ -6500,18 +6503,15 @@ BEGIN
THEN
_offset := 0;
LOOP
WITH batch_update AS (
UPDATE tasks
SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order >= _to_index
AND sort_order < _from_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size
RETURNING 1
)
SELECT COUNT(*) INTO _affected_rows FROM batch_update;
UPDATE tasks
SET sort_order = sort_order + 1
WHERE project_id = _project_id
AND sort_order >= _to_index
AND sort_order < _from_index
AND sort_order > _offset
AND sort_order <= _offset + _batch_size;
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
EXIT WHEN _affected_rows = 0;
_offset := _offset + _batch_size;
END LOOP;
@@ -6520,3 +6520,38 @@ BEGIN
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id;
END
$$;
-- Simple function to update task sort orders in bulk
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json) RETURNS void
LANGUAGE plpgsql
AS
$$
DECLARE
_update_record RECORD;
BEGIN
-- Simple approach: update each task's sort_order from the provided array
FOR _update_record IN
SELECT
(item->>'task_id')::uuid as task_id,
(item->>'sort_order')::int as sort_order,
(item->>'status_id')::uuid as status_id,
(item->>'priority_id')::uuid as priority_id,
(item->>'phase_id')::uuid as phase_id
FROM json_array_elements(_updates) as item
LOOP
UPDATE tasks
SET
sort_order = _update_record.sort_order,
status_id = COALESCE(_update_record.status_id, status_id),
priority_id = COALESCE(_update_record.priority_id, priority_id)
WHERE id = _update_record.task_id;
-- Handle phase updates separately since it's in a different table
IF _update_record.phase_id IS NOT NULL THEN
INSERT INTO task_phase (task_id, phase_id)
VALUES (_update_record.task_id, _update_record.phase_id)
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
END IF;
END LOOP;
END
$$;

View File

@@ -1,28 +0,0 @@
module.exports = {
brotli_js: {
options: {
mode: "brotli",
brotli: {
mode: 1
}
},
expand: true,
cwd: "build/public",
src: ["**/*.js"],
dest: "build/public",
extDot: "last",
ext: ".js.br"
},
gzip_js: {
options: {
mode: "gzip"
},
files: [{
expand: true,
cwd: "build/public",
src: ["**/*.js"],
dest: "build/public",
ext: ".js.gz"
}]
}
};

View File

@@ -4,7 +4,7 @@
"private": true,
"engines": {
"npm": ">=8.11.0",
"node": ">=16.13.0",
"node": ">=20.0.0",
"yarn": "WARNING: Please use npm package manager instead of yarn"
},
"main": "build/bin/www",
@@ -68,7 +68,6 @@
"express-rate-limit": "^6.8.0",
"express-session": "^1.17.3",
"express-validator": "^6.15.0",
"grunt-cli": "^1.5.0",
"helmet": "^6.2.0",
"hpp": "^0.2.3",
"http-errors": "^2.0.0",

View File

@@ -137,6 +137,10 @@ export default class HomePageController extends WorklenzControllerBase {
WHERE category_id NOT IN (SELECT id
FROM sys_task_status_categories
WHERE is_done IS FALSE))
AND NOT EXISTS(SELECT project_id
FROM archived_projects
WHERE project_id = p.id
AND user_id = $2)
${groupByClosure}
ORDER BY t.end_date ASC`;
@@ -158,9 +162,13 @@ export default class HomePageController extends WorklenzControllerBase {
WHERE category_id NOT IN (SELECT id
FROM sys_task_status_categories
WHERE is_done IS FALSE))
AND NOT EXISTS(SELECT project_id
FROM archived_projects
WHERE project_id = p.id
AND user_id = $3)
${groupByClosure}`;
const result = await db.query(q, [teamId, userId]);
const result = await db.query(q, [teamId, userId, userId]);
const [row] = result.rows;
return row;
}

View File

@@ -16,19 +16,23 @@ export default class TaskPhasesController extends WorklenzControllerBase {
if (!req.query.id)
return res.status(400).send(new ServerResponse(false, null, "Invalid request"));
// Use custom name if provided, otherwise use default naming pattern
const phaseName = req.body.name?.trim() ||
`Untitled Phase (${(await db.query("SELECT COUNT(*) FROM project_phases WHERE project_id = $1", [req.query.id])).rows[0].count + 1})`;
const q = `
INSERT INTO project_phases (name, color_code, project_id, sort_index)
VALUES (
CONCAT('Untitled Phase (', (SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1, ')'),
$1,
$2,
(SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1)
$3,
(SELECT COUNT(*) FROM project_phases WHERE project_id = $3) + 1)
RETURNING id, name, color_code, sort_index;
`;
req.body.color_code = this.DEFAULT_PHASE_COLOR;
const result = await db.query(q, [req.body.color_code, req.query.id]);
const result = await db.query(q, [phaseName, req.body.color_code, req.query.id]);
const [data] = result.rows;
data.color_code = getColor(data.name) + TASK_STATUS_COLOR_ALPHA;

View File

@@ -1174,9 +1174,39 @@ export default class TasksControllerV2 extends TasksControllerBase {
}
});
// Calculate progress stats for priority and phase grouping
if (groupBy === GroupBy.PRIORITY || groupBy === GroupBy.PHASE) {
Object.values(groupedResponse).forEach((group: any) => {
if (group.tasks && group.tasks.length > 0) {
const todoCount = group.tasks.filter((task: any) => {
// For tasks, we need to check their original status category
const originalTask = tasks.find(t => t.id === task.id);
return originalTask?.status_category?.is_todo;
}).length;
const doingCount = group.tasks.filter((task: any) => {
const originalTask = tasks.find(t => t.id === task.id);
return originalTask?.status_category?.is_doing;
}).length;
const doneCount = group.tasks.filter((task: any) => {
const originalTask = tasks.find(t => t.id === task.id);
return originalTask?.status_category?.is_done;
}).length;
const total = group.tasks.length;
// Calculate progress percentages
group.todo_progress = total > 0 ? +((todoCount / total) * 100).toFixed(0) : 0;
group.doing_progress = total > 0 ? +((doingCount / total) * 100).toFixed(0) : 0;
group.done_progress = total > 0 ? +((doneCount / total) * 100).toFixed(0) : 0;
}
});
}
// Create unmapped group if there are tasks without proper phase assignment
if (unmappedTasks.length > 0 && groupBy === GroupBy.PHASE) {
groupedResponse[UNMAPPED.toLowerCase()] = {
const unmappedGroup = {
id: UNMAPPED,
title: UNMAPPED,
groupType: groupBy,
@@ -1189,7 +1219,36 @@ export default class TasksControllerV2 extends TasksControllerBase {
start_date: null,
end_date: null,
sort_index: 999, // Put unmapped group at the end
todo_progress: 0,
doing_progress: 0,
done_progress: 0,
};
// Calculate progress stats for unmapped group
if (unmappedTasks.length > 0) {
const todoCount = unmappedTasks.filter((task: any) => {
const originalTask = tasks.find(t => t.id === task.id);
return originalTask?.status_category?.is_todo;
}).length;
const doingCount = unmappedTasks.filter((task: any) => {
const originalTask = tasks.find(t => t.id === task.id);
return originalTask?.status_category?.is_doing;
}).length;
const doneCount = unmappedTasks.filter((task: any) => {
const originalTask = tasks.find(t => t.id === task.id);
return originalTask?.status_category?.is_done;
}).length;
const total = unmappedTasks.length;
unmappedGroup.todo_progress = total > 0 ? +((todoCount / total) * 100).toFixed(0) : 0;
unmappedGroup.doing_progress = total > 0 ? +((doingCount / total) * 100).toFixed(0) : 0;
unmappedGroup.done_progress = total > 0 ? +((doneCount / total) * 100).toFixed(0) : 0;
}
groupedResponse[UNMAPPED.toLowerCase()] = unmappedGroup;
}
// Sort tasks within each group by order

View File

@@ -24,6 +24,14 @@ interface ChangeRequest {
priority: string;
};
team_id: string;
// New simplified approach
task_updates?: Array<{
task_id: string;
sort_order: number;
status_id?: string;
priority_id?: string;
phase_id?: string;
}>;
}
interface Config {
@@ -64,38 +72,72 @@ function updateUnmappedStatus(config: Config) {
export async function on_task_sort_order_change(_io: Server, socket: Socket, data: ChangeRequest) {
try {
const q = `SELECT handle_task_list_sort_order_change($1);`;
const config: Config = {
from_index: data.from_index,
to_index: data.to_index,
task_id: data.task.id,
from_group: data.from_group,
to_group: data.to_group,
project_id: data.project_id,
group_by: data.group_by,
to_last_index: Boolean(data.to_last_index)
};
if ((config.group_by === GroupBy.STATUS) && config.to_group) {
const canContinue = await TasksControllerV2.checkForCompletedDependencies(config.task_id, config?.to_group);
if (!canContinue) {
return socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
completed_deps: canContinue
});
// New simplified approach - use bulk updates if provided
if (data.task_updates && data.task_updates.length > 0) {
// Check dependencies for status changes
if (data.group_by === GroupBy.STATUS && data.to_group) {
const canContinue = await TasksControllerV2.checkForCompletedDependencies(data.task.id, data.to_group);
if (!canContinue) {
return socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
completed_deps: canContinue
});
}
}
notifyStatusChange(socket, config);
// Use the simple bulk update function
const q = `SELECT update_task_sort_orders_bulk($1);`;
await db.query(q, [JSON.stringify(data.task_updates)]);
await emitSortOrderChange(data, socket);
// Handle notifications and logging
if (data.group_by === GroupBy.STATUS && data.to_group) {
notifyStatusChange(socket, {
task_id: data.task.id,
to_group: data.to_group,
from_group: data.from_group,
from_index: data.from_index,
to_index: data.to_index,
project_id: data.project_id,
group_by: data.group_by,
to_last_index: data.to_last_index
});
}
} else {
// Fallback to old complex method
const q = `SELECT handle_task_list_sort_order_change($1);`;
const config: Config = {
from_index: data.from_index,
to_index: data.to_index,
task_id: data.task.id,
from_group: data.from_group,
to_group: data.to_group,
project_id: data.project_id,
group_by: data.group_by,
to_last_index: Boolean(data.to_last_index)
};
if ((config.group_by === GroupBy.STATUS) && config.to_group) {
const canContinue = await TasksControllerV2.checkForCompletedDependencies(config.task_id, config?.to_group);
if (!canContinue) {
return socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
completed_deps: canContinue
});
}
notifyStatusChange(socket, config);
}
if (config.group_by === GroupBy.PHASE) {
updateUnmappedStatus(config);
}
await db.query(q, [JSON.stringify(config)]);
await emitSortOrderChange(data, socket);
}
if (config.group_by === GroupBy.PHASE) {
updateUnmappedStatus(config);
}
await db.query(q, [JSON.stringify(config)]);
await emitSortOrderChange(data, socket);
if (config.group_by === GroupBy.STATUS) {
// Common post-processing logic for both approaches
if (data.group_by === GroupBy.STATUS) {
const userId = getLoggedInUserIdFromSocket(socket);
const isAlreadyAssigned = await TasksControllerV2.checkUserAssignedToTask(data.task.id, userId as string, data.team_id);
@@ -104,7 +146,7 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat
}
}
if (config.group_by === GroupBy.PHASE) {
if (data.group_by === GroupBy.PHASE) {
void logPhaseChange({
task_id: data.task.id,
socket,
@@ -113,7 +155,7 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat
});
}
if (config.group_by === GroupBy.STATUS) {
if (data.group_by === GroupBy.STATUS) {
void logStatusChange({
task_id: data.task.id,
socket,
@@ -122,7 +164,7 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat
});
}
if (config.group_by === GroupBy.PRIORITY) {
if (data.group_by === GroupBy.PRIORITY) {
void logPriorityChange({
task_id: data.task.id,
socket,
@@ -131,7 +173,7 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat
});
}
void notifyProjectUpdates(socket, config.task_id);
void notifyProjectUpdates(socket, data.task.id);
return;
} catch (error) {
log_error(error);

View File

@@ -2,8 +2,8 @@
<html lang="en">
<head>
<title>Worklenz 2.1.0 Release</title>
<meta name="subject" content="Worklenz 2.1.0 Release" />
<title>Worklenz 2.1.1 Release</title>
<meta name="subject" content="Worklenz 2.1.1 Release" />
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta content="width=device-width,initial-scale=1" name="viewport">
<style>
@@ -75,17 +75,6 @@
font-size: 16px;
}
.lang-badge {
display: inline-block;
background: #e6f7ff;
color: #1890ff;
border-radius: 8px;
padding: 3px 10px;
font-size: 14px;
margin-right: 8px;
margin-bottom: 4px;
}
.main-btn {
background: #1890ff;
border: none;
@@ -183,40 +172,42 @@
<tr>
<td>
<div class="card">
<h3>🚀 New Tasks List & Kanban Board</h3>
<h3>🆕 Manage Statuses Easily</h3>
<ul class="feature-list">
<li>Performance optimized for faster loading</li>
<li>Redesigned UI for clarity and speed</li>
<li>Advanced filters for easier task management</li>
<li>Add, rename, delete, sort, and change category of statuses with a new popup.</li>
<li>Group by status and click the "Manage Status" button next to the group by option in the task filter.</li>
</ul>
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/gifs/WL20250708/task-list-v2.gif"
alt="New Task List">
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/gifs/WL20250708/kanban-v2.gif"
alt="New Kanban Board">
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/gifs/WL20250711/manage-status-modal.png" alt="Manage Status Modal">
</div>
<div class="card">
<h3>📁 Group View in Projects List</h3>
<h3>🆕 Manage Phases Easily</h3>
<ul class="feature-list">
<li>Toggle between list and group view</li>
<li>Group projects by client or category</li>
<li>Improved navigation and organization</li>
<li>Group by phase and click the "Manage Status" button next to the group by option in the task filter.</li>
<li>Rename, add, delete, change color, and sort phases with a new popup.</li>
</ul>
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/gifs/WL20250708/project-list-group-view.gif"
alt="Project List Group View">
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/gifs/WL20250711/manage-phases-model.png" alt="Manage Phases Modal">
</div>
<div class="card">
<h3>🌐 New Language Support</h3>
<span class="lang-badge">Deutsch (DE)</span>
<span class="lang-badge">Shqip (ALB)</span>
<p style="margin-top: 10px;">Worklenz is now available in German and Albanian!</p>
<h3>📊 Task Progress Bar in Groups</h3>
<ul class="feature-list">
<li>When grouped by priority or phase, see the progress of tasks with a task progress bar in To Do, Doing, Done categories.</li>
</ul>
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/gifs/WL20250711/task-group-progress.png" alt="Task Group Progress Bar">
</div>
<div class="card">
<h3>🛠 Bug Fixes & UI Improvements</h3>
<h3>🖱 Right Click Context Menu</h3>
<ul class="feature-list">
<li>General bug fixes</li>
<li>UI/UX enhancements for a smoother experience</li>
<li>Performance improvements across the platform</li>
<li>Quick actions available via right click context menu in the task list.</li>
</ul>
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/gifs/WL20250711/task-list-context-menu.png" alt="Task List Context Menu">
</div>
<div class="card">
<h3>✨ UI Enhancements</h3>
<ul class="feature-list">
<li>Added borders to task rows for better clarity.</li>
<li>Various bug fixes and UI improvements across the platform.</li>
</ul>
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/gifs/WL20250711/task-row-borders.png" alt="Task Row Borders">
</div>
<div style="text-align: center;">
<a href="https://app.worklenz.com/auth" target="_blank" class="main-btn">See what's new</a>

View File

@@ -1,16 +1,20 @@
{
"configurePhases": "Konfiguro Fazat",
"configure": "Konfiguro",
"phaseLabel": "Etiketa e Fazës",
"enterPhaseName": "Shkruani emrin e fazës",
"enterPhaseName": "Shkruaj emrin e fazës",
"addOption": "Shto Opsion",
"phaseOptions": "Opsionet e Fazës",
"dragToReorderPhases": "Zvarrit fazat për t'i rirenditur. Çdo fazë mund të ketë një ngjyrë të ndryshme.",
"enterNewPhaseName": "Shkruani emrin e fazës së re...",
"optionsText": "Opsione",
"dragToReorderPhases": "Tërhiq fazat për t'i rirenditur. Çdo fazë mund të ketë një ngjyrë të ndryshme.",
"enterNewPhaseName": "Shkruaj emrin e fazës së re...",
"addPhase": "Shto Fazë",
"noPhasesFound": "Nuk u gjetën faza",
"no": "Asnjë",
"found": "u gjet",
"deletePhase": "Fshi Fazën",
"deletePhaseConfirm": "Jeni sigurt që doni të fshini këtë fazë? Ky veprim nuk mund të zhbëhet.",
"rename": "Riemëro",
"deletePhaseConfirm": "Jeni i sigurt që doni të fshini këtë fazë? Ky veprim nuk mund të zhbëhet.",
"rename": "Riemërto",
"delete": "Fshi",
"create": "Krijo",
"cancel": "Anulo",

View File

@@ -1,30 +1,31 @@
{
"importTasks": "Importo detyra",
"importTask": "Importo detyrë",
"importTasks": "Importo detyrat",
"importTask": "Importo detyrën",
"createTask": "Krijo detyrë",
"settings": "Cilësimet",
"subscribe": "Abonohu",
"unsubscribe": "Çabonohu",
"deleteProject": "Fshi projektin",
"deleteProject": "Fshij projektin",
"startDate": "Data e fillimit",
"endDate": "Data e mbarimit",
"endDate": "Data e përfundimit",
"projectSettings": "Cilësimet e projektit",
"projectSummary": "Përmbledhja e projektit",
"receiveProjectSummary": "Merrni një përmbledhje të projektit çdo mbrëmje.",
"receiveProjectSummary": "Merr një përmbledhje të projektit çdo mbrëmje.",
"refreshProject": "Rifresko projektin",
"saveAsTemplate": "Ruaj si model",
"saveAsTemplate": "Ruaj si shabllon",
"invite": "Fto",
"share": "Ndaj",
"subscribeTooltip": "Abonohu tek njoftimet e projektit",
"subscribeTooltip": "Abonohu njoftimet e projektit",
"unsubscribeTooltip": "Çabonohu nga njoftimet e projektit",
"refreshTooltip": "Rifresko të dhënat e projektit",
"settingsTooltip": "Hap cilësimet e projektit",
"saveAsTemplateTooltip": "Ruaj këtë projekt si model",
"inviteTooltip": "Fto anëtarë ekipit në këtë projekt",
"saveAsTemplateTooltip": "Ruaj këtë projekt si shabllon",
"inviteTooltip": "Fto anëtarët e ekipit në këtë projekt",
"createTaskTooltip": "Krijo një detyrë të re",
"importTaskTooltip": "Importo detyrë nga modeli",
"navigateBackTooltip": "Kthehu tek lista e projekteve",
"importTaskTooltip": "Importo detyrë nga shablloni",
"navigateBackTooltip": "Kthehu listën e projekteve",
"projectStatusTooltip": "Statusi i projektit",
"projectDatesInfo": "Informacion për kohëzgjatjen e projektit",
"projectCategoryTooltip": "Kategoria e projektit"
"projectDatesInfo": "Informacioni i afateve të projektit",
"projectCategoryTooltip": "Kategoria e projektit",
"defaultTaskName": "Detyrë Pa Emër"
}

View File

@@ -68,9 +68,10 @@
"clearing": "Po pastron...",
"cancel": "Anulo",
"search": "Kërko",
"groupedBy": "I grupuar sipas",
"manageStatuses": "Menaxho statuset",
"managePhases": "Menaxho fazat",
"groupedBy": "Grupuar sipas",
"manage": "Menaxho",
"manageStatuses": "Menaxho Statuset",
"managePhases": "Menaxho Fazat",
"dragToReorderStatuses": "Statuset janë të organizuara sipas kategorive. Tërhiq për të rirenditur brenda kategorive. Kliko 'Shto status' për të krijuar statuse të reja në çdo kategori.",
"enterNewStatusName": "Shkruani emrin e statusit të ri...",
"addStatus": "Shto status",

View File

@@ -1,21 +1,39 @@
{
"noTasksInGroup": "Nuk ka detyra në këtë grup",
"noTasksInGroupDescription": "Shtoni një detyrë për të filluar",
"addFirstTask": "Shtoni detyrën tuaj të parë",
"noTasksInGroupDescription": "Shto një detyrë për të filluar",
"addFirstTask": "Shto detyrën e parë",
"openTask": "Hap",
"subtask": "nën-detyrë",
"subtasks": "nën-detyra",
"subtask": "nëndetyrë",
"subtasks": "nëndetyra",
"comment": "koment",
"comments": "komente",
"attachment": "bashkëngjitje",
"attachments": "bashkëngjitje",
"enterSubtaskName": "Shkruani emrin e nën-detyrës...",
"enterSubtaskName": "Shkruani emrin e nëndetyrës...",
"add": "Shto",
"cancel": "Anulo",
"renameGroup": "Riemërto Grupin",
"renameStatus": "Riemërto Statusin",
"renamePhase": "Riemërto Fazën",
"changeCategory": "Ndrysho Kategorinë",
"clickToEditGroupName": "Kliko për të ndryshuar emrin e grupit",
"enterGroupName": "Shkruani emrin e grupit"
"clickToEditGroupName": "Kliko për të redaktuar emrin e grupit",
"enterGroupName": "Shkruani emrin e grupit",
"todo": "Për t'u Bërë",
"inProgress": "Në Progres",
"done": "E Kryer",
"defaultTaskName": "Detyrë Pa Emër",
"indicators": {
"tooltips": {
"subtasks": "{{count}} nëndetyrë",
"subtasks_plural": "{{count}} nëndetyra",
"comments": "{{count}} koment",
"comments_plural": "{{count}} komente",
"attachments": "{{count}} bashkëngjitje",
"attachments_plural": "{{count}} bashkëngjitje",
"subscribers": "Detyra ka abonues",
"dependencies": "Detyra ka varësi",
"recurring": "Detyrë e përsëritur"
}
}
}

View File

@@ -1,13 +1,17 @@
{
"configurePhases": "Phasen konfigurieren",
"phaseLabel": "Phasenbezeichnung",
"enterPhaseName": "Phasennamen eingeben",
"configure": "Konfigurieren",
"phaseLabel": "Phasen-Label",
"enterPhaseName": "Phasenname eingeben",
"addOption": "Option hinzufügen",
"phaseOptions": "Phasenoptionen",
"optionsText": "Optionen",
"dragToReorderPhases": "Ziehen Sie Phasen, um sie neu zu ordnen. Jede Phase kann eine andere Farbe haben.",
"enterNewPhaseName": "Neuen Phasennamen eingeben...",
"addPhase": "Phase hinzufügen",
"noPhasesFound": "Keine Phasen gefunden",
"no": "Keine",
"found": "gefunden",
"deletePhase": "Phase löschen",
"deletePhaseConfirm": "Sind Sie sicher, dass Sie diese Phase löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"rename": "Umbenennen",

View File

@@ -4,27 +4,28 @@
"createTask": "Aufgabe erstellen",
"settings": "Einstellungen",
"subscribe": "Abonnieren",
"unsubscribe": "Abonnement beenden",
"unsubscribe": "Abmelden",
"deleteProject": "Projekt löschen",
"startDate": "Startdatum",
"endDate": "Enddatum",
"projectSettings": "Projekteinstellungen",
"projectSummary": "Projektzusammenfassung",
"receiveProjectSummary": "Erhalten Sie jeden Abend eine Projektzusammenfassung.",
"receiveProjectSummary": "Jeden Abend eine Projektzusammenfassung erhalten.",
"refreshProject": "Projekt aktualisieren",
"saveAsTemplate": "Als Vorlage speichern",
"invite": "Einladen",
"share": "Teilen",
"subscribeTooltip": "Projektbenachrichtigungen abonnieren",
"unsubscribeTooltip": "Projektbenachrichtigungen beenden",
"unsubscribeTooltip": "Projektbenachrichtigungen abmelden",
"refreshTooltip": "Projektdaten aktualisieren",
"settingsTooltip": "Projekteinstellungen öffnen",
"saveAsTemplateTooltip": "Dieses Projekt als Vorlage speichern",
"inviteTooltip": "Teammitglieder zu diesem Projekt einladen",
"createTaskTooltip": "Neue Aufgabe erstellen",
"createTaskTooltip": "Eine neue Aufgabe erstellen",
"importTaskTooltip": "Aufgabe aus Vorlage importieren",
"navigateBackTooltip": "Zurück zur Projektliste",
"projectStatusTooltip": "Projektstatus",
"projectDatesInfo": "Informationen zum Projektzeitraum",
"projectCategoryTooltip": "Projektkategorie"
"projectDatesInfo": "Projekt-Zeitleisten-Informationen",
"projectCategoryTooltip": "Projektkategorie",
"defaultTaskName": "Unbenannte Aufgabe"
}

View File

@@ -69,6 +69,7 @@
"cancel": "Abbrechen",
"search": "Suchen",
"groupedBy": "Gruppiert nach",
"manage": "Verwalten",
"manageStatuses": "Status verwalten",
"managePhases": "Phasen verwalten",
"dragToReorderStatuses": "Status sind nach Kategorien organisiert. Ziehen Sie, um innerhalb von Kategorien neu zu ordnen. Klicken Sie auf 'Status hinzufügen', um neue Status in jeder Kategorie zu erstellen.",

View File

@@ -9,7 +9,7 @@
"comments": "Kommentare",
"attachment": "Anhang",
"attachments": "Anhänge",
"enterSubtaskName": "Unteraufgabenname eingeben...",
"enterSubtaskName": "Geben Sie den Namen der Unteraufgabe ein...",
"add": "Hinzufügen",
"cancel": "Abbrechen",
"renameGroup": "Gruppe umbenennen",
@@ -17,5 +17,23 @@
"renamePhase": "Phase umbenennen",
"changeCategory": "Kategorie ändern",
"clickToEditGroupName": "Klicken Sie, um den Gruppennamen zu bearbeiten",
"enterGroupName": "Gruppennamen eingeben"
"enterGroupName": "Geben Sie den Gruppennamen ein",
"todo": "Zu erledigen",
"inProgress": "In Bearbeitung",
"done": "Erledigt",
"defaultTaskName": "Unbenannte Aufgabe",
"indicators": {
"tooltips": {
"subtasks": "{{count}} Unteraufgabe",
"subtasks_plural": "{{count}} Unteraufgaben",
"comments": "{{count}} Kommentar",
"comments_plural": "{{count}} Kommentare",
"attachments": "{{count}} Anhang",
"attachments_plural": "{{count}} Anhänge",
"subscribers": "Aufgabe hat Abonnenten",
"dependencies": "Aufgabe hat Abhängigkeiten",
"recurring": "Wiederkehrende Aufgabe"
}
}
}

View File

@@ -1,13 +1,17 @@
{
"configurePhases": "Configure Phases",
"configure": "Configure",
"phaseLabel": "Phase Label",
"enterPhaseName": "Enter phase name",
"addOption": "Add Option",
"phaseOptions": "Phase Options",
"optionsText": "Options",
"dragToReorderPhases": "Drag phases to reorder them. Each phase can have a different color.",
"enterNewPhaseName": "Enter new phase name...",
"addPhase": "Add Phase",
"noPhasesFound": "No phases found",
"no": "No",
"found": "found",
"deletePhase": "Delete Phase",
"deletePhaseConfirm": "Are you sure you want to delete this phase? This action cannot be undone.",
"rename": "Rename",

View File

@@ -26,5 +26,6 @@
"navigateBackTooltip": "Go back to projects list",
"projectStatusTooltip": "Project status",
"projectDatesInfo": "Project timeline information",
"projectCategoryTooltip": "Project category"
"projectCategoryTooltip": "Project category",
"defaultTaskName": "Untitled Task"
}

View File

@@ -69,6 +69,7 @@
"cancel": "Cancel",
"search": "Search",
"groupedBy": "Grouped by",
"manage": "Manage",
"manageStatuses": "Manage Statuses",
"managePhases": "Manage Phases",
"dragToReorderStatuses": "Statuses are organized by categories. Drag to reorder within categories. Click 'Add Status' to create new statuses in each category.",

View File

@@ -18,6 +18,10 @@
"changeCategory": "Change Category",
"clickToEditGroupName": "Click to edit group name",
"enterGroupName": "Enter group name",
"todo": "To Do",
"inProgress": "Doing",
"done": "Done",
"defaultTaskName": "Untitled Task",
"indicators": {
"tooltips": {

View File

@@ -1,13 +1,17 @@
{
"configurePhases": "Configurar fases",
"phaseLabel": "Etiqueta de fase",
"enterPhaseName": "Introducir nombre de la fase",
"addOption": "Agregar opción",
"phaseOptions": "Opciones de fase",
"configurePhases": "Configurar Fases",
"configure": "Configurar",
"phaseLabel": "Etiqueta de Fase",
"enterPhaseName": "Ingresa el nombre de la fase",
"addOption": "Agregar Opción",
"phaseOptions": "Opciones de Fase",
"optionsText": "Opciones",
"dragToReorderPhases": "Arrastra las fases para reordenarlas. Cada fase puede tener un color diferente.",
"enterNewPhaseName": "Introducir nuevo nombre de fase...",
"addPhase": "Añadir Fase",
"enterNewPhaseName": "Ingresa el nombre de la nueva fase...",
"addPhase": "Agregar Fase",
"noPhasesFound": "No se encontraron fases",
"no": "No",
"found": "encontrado",
"deletePhase": "Eliminar Fase",
"deletePhaseConfirm": "¿Estás seguro de que quieres eliminar esta fase? Esta acción no se puede deshacer.",
"rename": "Renombrar",

View File

@@ -4,19 +4,19 @@
"createTask": "Crear tarea",
"settings": "Configuración",
"subscribe": "Suscribirse",
"unsubscribe": "Cancelar suscripción",
"unsubscribe": "Desuscribirse",
"deleteProject": "Eliminar proyecto",
"startDate": "Fecha de inicio",
"endDate": "Fecha de finalización",
"endDate": "Fecha de fin",
"projectSettings": "Configuración del proyecto",
"projectSummary": "Resumen del proyecto",
"receiveProjectSummary": "Recibe un resumen del proyecto cada noche.",
"receiveProjectSummary": "Recibir un resumen del proyecto cada noche.",
"refreshProject": "Actualizar proyecto",
"saveAsTemplate": "Guardar como plantilla",
"invite": "Invitar",
"share": "Compartir",
"subscribeTooltip": "Suscribirse a notificaciones del proyecto",
"unsubscribeTooltip": "Cancelar suscripción a notificaciones del proyecto",
"subscribeTooltip": "Suscribirse a las notificaciones del proyecto",
"unsubscribeTooltip": "Desuscribirse de las notificaciones del proyecto",
"refreshTooltip": "Actualizar datos del proyecto",
"settingsTooltip": "Abrir configuración del proyecto",
"saveAsTemplateTooltip": "Guardar este proyecto como plantilla",
@@ -25,6 +25,7 @@
"importTaskTooltip": "Importar tarea desde plantilla",
"navigateBackTooltip": "Volver a la lista de proyectos",
"projectStatusTooltip": "Estado del proyecto",
"projectDatesInfo": "Información de cronograma del proyecto",
"projectCategoryTooltip": "Categoría del proyecto"
"projectDatesInfo": "Información de la cronología del proyecto",
"projectCategoryTooltip": "Categoría del proyecto",
"defaultTaskName": "Tarea Sin Título"
}

View File

@@ -69,8 +69,9 @@
"cancel": "Cancelar",
"search": "Buscar",
"groupedBy": "Agrupado por",
"manageStatuses": "Gestionar estados",
"managePhases": "Gestionar fases",
"manage": "Gestionar",
"manageStatuses": "Gestionar Estados",
"managePhases": "Gestionar Fases",
"dragToReorderStatuses": "Los estados están organizados por categorías. Arrastra para reordenar dentro de las categorías. Haz clic en 'Agregar estado' para crear nuevos estados en cada categoría.",
"enterNewStatusName": "Ingrese el nombre del nuevo estado...",
"addStatus": "Agregar estado",

View File

@@ -17,5 +17,23 @@
"renamePhase": "Renombrar Fase",
"changeCategory": "Cambiar Categoría",
"clickToEditGroupName": "Haz clic para editar el nombre del grupo",
"enterGroupName": "Ingresa el nombre del grupo"
"enterGroupName": "Ingresa el nombre del grupo",
"todo": "Por Hacer",
"inProgress": "En Progreso",
"done": "Hecho",
"defaultTaskName": "Tarea Sin Título",
"indicators": {
"tooltips": {
"subtasks": "{{count}} subtarea",
"subtasks_plural": "{{count}} subtareas",
"comments": "{{count}} comentario",
"comments_plural": "{{count}} comentarios",
"attachments": "{{count}} adjunto",
"attachments_plural": "{{count}} adjuntos",
"subscribers": "La tarea tiene suscriptores",
"dependencies": "La tarea tiene dependencias",
"recurring": "Tarea recurrente"
}
}
}

View File

@@ -1,13 +1,17 @@
{
"configurePhases": "Configurar fases",
"phaseLabel": "Etiqueta de fase",
"configurePhases": "Configurar Fases",
"configure": "Configurar",
"phaseLabel": "Rótulo da Fase",
"enterPhaseName": "Digite o nome da fase",
"addOption": "Adicionar Opção",
"phaseOptions": "Opções de Fase",
"optionsText": "Opções",
"dragToReorderPhases": "Arraste as fases para reordená-las. Cada fase pode ter uma cor diferente.",
"enterNewPhaseName": "Digite o novo nome da fase...",
"enterNewPhaseName": "Digite o nome da nova fase...",
"addPhase": "Adicionar Fase",
"noPhasesFound": "Nenhuma fase encontrada",
"no": "Nenhuma",
"found": "encontrada",
"deletePhase": "Excluir Fase",
"deletePhaseConfirm": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.",
"rename": "Renomear",

View File

@@ -7,10 +7,10 @@
"unsubscribe": "Cancelar inscrição",
"deleteProject": "Excluir projeto",
"startDate": "Data de início",
"endDate": "Data de término",
"endDate": "Data de fim",
"projectSettings": "Configurações do projeto",
"projectSummary": "Resumo do projeto",
"receiveProjectSummary": "Receba um resumo do projeto todas as noites.",
"receiveProjectSummary": "Receber um resumo do projeto todas as noites.",
"refreshProject": "Atualizar projeto",
"saveAsTemplate": "Salvar como modelo",
"invite": "Convidar",
@@ -22,9 +22,10 @@
"saveAsTemplateTooltip": "Salvar este projeto como modelo",
"inviteTooltip": "Convidar membros da equipe para este projeto",
"createTaskTooltip": "Criar uma nova tarefa",
"importTaskTooltip": "Importar tarefa de modelo",
"navigateBackTooltip": "Voltar para lista de projetos",
"importTaskTooltip": "Importar tarefa do modelo",
"navigateBackTooltip": "Voltar para a lista de projetos",
"projectStatusTooltip": "Status do projeto",
"projectDatesInfo": "Informações do cronograma do projeto",
"projectCategoryTooltip": "Categoria do projeto"
"projectDatesInfo": "Informações da linha do tempo do projeto",
"projectCategoryTooltip": "Categoria do projeto",
"defaultTaskName": "Tarefa Sem Título"
}

View File

@@ -69,8 +69,9 @@
"cancel": "Cancelar",
"search": "Pesquisar",
"groupedBy": "Agrupado por",
"manageStatuses": "Gerenciar status",
"managePhases": "Gerenciar fases",
"manage": "Gerenciar",
"manageStatuses": "Gerenciar Status",
"managePhases": "Gerenciar Fases",
"dragToReorderStatuses": "Os status estão organizados por categorias. Arraste para reordenar dentro das categorias. Clique em 'Adicionar status' para criar novos status em cada categoria.",
"enterNewStatusName": "Digite o nome do novo status...",
"addStatus": "Adicionar status",

View File

@@ -17,5 +17,23 @@
"renamePhase": "Renomear Fase",
"changeCategory": "Alterar Categoria",
"clickToEditGroupName": "Clique para editar o nome do grupo",
"enterGroupName": "Digite o nome do grupo"
"enterGroupName": "Digite o nome do grupo",
"todo": "A Fazer",
"inProgress": "Em Andamento",
"done": "Concluído",
"defaultTaskName": "Tarefa Sem Título",
"indicators": {
"tooltips": {
"subtasks": "{{count}} subtarefa",
"subtasks_plural": "{{count}} subtarefas",
"comments": "{{count}} comentário",
"comments_plural": "{{count}} comentários",
"attachments": "{{count}} anexo",
"attachments_plural": "{{count}} anexos",
"subscribers": "Tarefa tem assinantes",
"dependencies": "Tarefa tem dependências",
"recurring": "Tarefa recorrente"
}
}
}

View File

@@ -1,20 +1,24 @@
{
"configurePhases": "配置阶段",
"phaseLabel": "阶段标签",
"enterPhaseName": "输入阶段名称",
"addOption": "添加选项",
"phaseOptions": "阶段选项",
"dragToReorderPhases": "拖拽阶段以重新排序。每个阶段可以有不同的颜色。",
"enterNewPhaseName": "输入新阶段名称...",
"addPhase": "添加阶段",
"noPhasesFound": "未找到阶段",
"deletePhase": "删除阶段",
"deletePhaseConfirm": "您确定要删除此阶段吗?此操作无法撤销。",
"rename": "重命名",
"delete": "删除",
"create": "创建",
"cancel": "取消",
"selectColor": "选择颜色",
"managePhases": "管理阶段",
"close": "关闭"
"configurePhases": "配置阶段",
"configure": "配置",
"phaseLabel": "阶段标签",
"enterPhaseName": "输入阶段名称",
"addOption": "添加选项",
"phaseOptions": "阶段选项",
"optionsText": "选项",
"dragToReorderPhases": "拖拽阶段来重新排序。每个阶段可以有不同的颜色。",
"enterNewPhaseName": "输入新阶段名称...",
"addPhase": "添加阶段",
"noPhasesFound": "未找到阶段",
"no": "没有",
"found": "找到",
"deletePhase": "删除阶段",
"deletePhaseConfirm": "您确定要删除此阶段吗?此操作无法撤消。",
"rename": "重命名",
"delete": "删除",
"create": "创建",
"cancel": "取消",
"selectColor": "选择颜色",
"managePhases": "管理阶段",
"close": "关闭"
}

View File

@@ -10,7 +10,7 @@
"endDate": "结束日期",
"projectSettings": "项目设置",
"projectSummary": "项目摘要",
"receiveProjectSummary": "每接收项目摘要。",
"receiveProjectSummary": "每天晚上接收项目摘要。",
"refreshProject": "刷新项目",
"saveAsTemplate": "保存为模板",
"invite": "邀请",
@@ -25,6 +25,7 @@
"importTaskTooltip": "从模板导入任务",
"navigateBackTooltip": "返回项目列表",
"projectStatusTooltip": "项目状态",
"projectDatesInfo": "项目时间安排信息",
"projectCategoryTooltip": "项目类别"
"projectDatesInfo": "项目时间线信息",
"projectCategoryTooltip": "项目类别",
"defaultTaskName": "无标题任务"
}

View File

@@ -62,7 +62,8 @@
"clearing": "清除中...",
"cancel": "取消",
"search": "搜索",
"groupedBy": "分组依据",
"groupedBy": "分组方式",
"manage": "管理",
"manageStatuses": "管理状态",
"managePhases": "管理阶段",
"dragToReorderStatuses": "拖拽状态以重新排序。每个状态可以有不同的类别。",

View File

@@ -18,6 +18,10 @@
"changeCategory": "更改类别",
"clickToEditGroupName": "点击编辑组名称",
"enterGroupName": "输入组名称",
"todo": "待办",
"inProgress": "进行中",
"done": "已完成",
"defaultTaskName": "无标题任务",
"indicators": {
"tooltips": {

View File

@@ -1,12 +1,12 @@
import apiClient from '@/api/api-client';
import { API_BASE_URL } from '@/shared/constants';
import { IServerResponse } from '@/types/common.types';
import apiClient from '@api/api-client';
import { API_BASE_URL } from '@/shared/constants';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { toQueryString } from '@/utils/toQueryString';
const rootUrl = `${API_BASE_URL}/task-phases`;
interface UpdateSortOrderBody {
export interface UpdateSortOrderBody {
from_index: number;
to_index: number;
phases: ITaskPhase[];
@@ -14,9 +14,10 @@ interface UpdateSortOrderBody {
}
export const phasesApiService = {
addPhaseOption: async (projectId: string) => {
addPhaseOption: async (projectId: string, name?: string) => {
const q = toQueryString({ id: projectId, current_project_id: projectId });
const response = await apiClient.post<IServerResponse<ITaskPhase>>(`${rootUrl}${q}`);
const body = name ? { name } : {};
const response = await apiClient.post<IServerResponse<ITaskPhase>>(`${rootUrl}${q}`, body);
return response.data;
},

View File

@@ -139,7 +139,7 @@ const EnhancedKanbanCreateSection: React.FC = () => {
}
if (groupBy === IGroupBy.PHASE) {
try {
const response = await phasesApiService.addPhaseOption(projectId);
const response = await phasesApiService.addPhaseOption(projectId, name);
if (response.done && response.body) {
dispatch(fetchEnhancedKanbanGroups(projectId));
}

View File

@@ -73,8 +73,17 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = ({
if (tasks.length === 0) {
return (
<div className="virtualized-empty-state" style={{ height }}>
<div className="empty-message">No tasks in this group</div>
<div className="virtualized-empty-state" style={{ height, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="empty-message" style={{
padding: '32px 24px',
color: '#8c8c8c',
fontSize: '14px',
backgroundColor: '#fafafa',
borderRadius: '8px',
border: '1px solid #f0f0f0'
}}>
No tasks in this group
</div>
</div>
);
}

View File

@@ -173,7 +173,7 @@ const KanbanGroup: React.FC<TaskGroupProps> = ({
.kanban-group-empty {
text-align: center;
color: #bfbfbf;
padding: 32px 0;
padding: 48px 16px;
}
.kanban-group-add-task {
padding: 12px;

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface GroupProgressBarProps {
todoProgress: number;
doingProgress: number;
doneProgress: number;
groupType: string;
}
const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
todoProgress,
doingProgress,
doneProgress,
groupType
}) => {
const { t } = useTranslation('task-management');
// Only show for priority and phase grouping
if (groupType !== 'priority' && groupType !== 'phase') {
return null;
}
const total = todoProgress + doingProgress + doneProgress;
// Don't show if no progress values exist
if (total === 0) {
return null;
}
return (
<div className="flex items-center gap-2">
{/* Compact progress text */}
<span className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap font-medium">
{doneProgress}% {t('done')}
</span>
{/* Compact progress bar */}
<div className="w-20 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 overflow-hidden">
<div className="h-full flex">
{/* Todo section - light green */}
{todoProgress > 0 && (
<div
className="bg-green-200 dark:bg-green-800 transition-all duration-300"
style={{ width: `${(todoProgress / total) * 100}%` }}
title={`${t('todo')}: ${todoProgress}%`}
/>
)}
{/* Doing section - medium green */}
{doingProgress > 0 && (
<div
className="bg-green-400 dark:bg-green-600 transition-all duration-300"
style={{ width: `${(doingProgress / total) * 100}%` }}
title={`${t('inProgress')}: ${doingProgress}%`}
/>
)}
{/* Done section - dark green */}
{doneProgress > 0 && (
<div
className="bg-green-600 dark:bg-green-400 transition-all duration-300"
style={{ width: `${(doneProgress / total) * 100}%` }}
title={`${t('done')}: ${doneProgress}%`}
/>
)}
</div>
</div>
{/* Small legend dots with better spacing */}
<div className="flex items-center gap-1">
{todoProgress > 0 && (
<div
className="w-1.5 h-1.5 bg-green-200 dark:bg-green-800 rounded-full"
title={`${t('todo')}: ${todoProgress}%`}
/>
)}
{doingProgress > 0 && (
<div
className="w-1.5 h-1.5 bg-green-400 dark:bg-green-600 rounded-full"
title={`${t('inProgress')}: ${doingProgress}%`}
/>
)}
{doneProgress > 0 && (
<div
className="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full"
title={`${t('done')}: ${doneProgress}%`}
/>
)}
</div>
</div>
);
};
export default GroupProgressBar;

View File

@@ -3,12 +3,13 @@ import { useDroppable } from '@dnd-kit/core';
// @ts-ignore: Heroicons module types
import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd';
import GroupProgressBar from './GroupProgressBar';
import { useTranslation } from 'react-i18next';
import { getContrastColor } from '@/utils/colorUtils';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { selectSelectedTaskIds, selectTask, deselectTask } from '@/features/task-management/selection.slice';
import { selectGroups, fetchTasksV3 } from '@/features/task-management/task-management.slice';
import { selectGroups, fetchTasksV3, selectAllTasksArray } from '@/features/task-management/task-management.slice';
import { selectCurrentGrouping } from '@/features/task-management/grouping.slice';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
@@ -27,6 +28,10 @@ interface TaskGroupHeaderProps {
name: string;
count: number;
color?: string; // Color for the group indicator
todo_progress?: number;
doing_progress?: number;
done_progress?: number;
groupType?: string;
};
isCollapsed: boolean;
onToggle: () => void;
@@ -38,13 +43,14 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
const dispatch = useAppDispatch();
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
const groups = useAppSelector(selectGroups);
const allTasks = useAppSelector(selectAllTasksArray);
const currentGrouping = useAppSelector(selectCurrentGrouping);
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
const { statusCategories, status: statusList } = useAppSelector(state => state.taskStatusReducer);
const { trackMixpanelEvent } = useMixpanelTracking();
const { isOwnerOrAdmin } = useAuthService();
const [dropdownVisible, setDropdownVisible] = useState(false);
const [categoryModalVisible, setCategoryModalVisible] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [isChangingCategory, setIsChangingCategory] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
@@ -62,6 +68,74 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
return currentGroup?.taskIds || [];
}, [currentGroup]);
// Calculate group progress values dynamically
const groupProgressValues = useMemo(() => {
if (!currentGroup || !allTasks.length) {
return { todoProgress: 0, doingProgress: 0, doneProgress: 0 };
}
const tasksInCurrentGroup = currentGroup.taskIds
.map(taskId => allTasks.find(task => task.id === taskId))
.filter(task => task !== undefined);
if (tasksInCurrentGroup.length === 0) {
return { todoProgress: 0, doingProgress: 0, doneProgress: 0 };
}
// If we're grouping by status, show progress based on task completion
if (currentGrouping === 'status') {
// For status grouping, calculate based on task progress values
const progressStats = tasksInCurrentGroup.reduce((acc, task) => {
const progress = task.progress || 0;
if (progress === 0) {
acc.todo += 1;
} else if (progress === 100) {
acc.done += 1;
} else {
acc.doing += 1;
}
return acc;
}, { todo: 0, doing: 0, done: 0 });
const totalTasks = tasksInCurrentGroup.length;
return {
todoProgress: totalTasks > 0 ? Math.round((progressStats.todo / totalTasks) * 100) : 0,
doingProgress: totalTasks > 0 ? Math.round((progressStats.doing / totalTasks) * 100) : 0,
doneProgress: totalTasks > 0 ? Math.round((progressStats.done / totalTasks) * 100) : 0,
};
} else {
// For priority/phase grouping, show progress based on status distribution
// Use a simplified approach based on status names and common patterns
const statusCounts = tasksInCurrentGroup.reduce((acc, task) => {
// Find the status by ID first
const statusInfo = statusList.find(s => s.id === task.status);
const statusName = statusInfo?.name?.toLowerCase() || task.status?.toLowerCase() || '';
// Categorize based on common status name patterns
if (statusName.includes('todo') || statusName.includes('to do') || statusName.includes('pending') || statusName.includes('open') || statusName.includes('backlog')) {
acc.todo += 1;
} else if (statusName.includes('doing') || statusName.includes('progress') || statusName.includes('active') || statusName.includes('working') || statusName.includes('development')) {
acc.doing += 1;
} else if (statusName.includes('done') || statusName.includes('completed') || statusName.includes('finished') || statusName.includes('closed') || statusName.includes('resolved')) {
acc.done += 1;
} else {
// Default unknown statuses to "doing" (in progress)
acc.doing += 1;
}
return acc;
}, { todo: 0, doing: 0, done: 0 });
const totalTasks = tasksInCurrentGroup.length;
return {
todoProgress: totalTasks > 0 ? Math.round((statusCounts.todo / totalTasks) * 100) : 0,
doingProgress: totalTasks > 0 ? Math.round((statusCounts.doing / totalTasks) * 100) : 0,
doneProgress: totalTasks > 0 ? Math.round((statusCounts.done / totalTasks) * 100) : 0,
};
}
}, [currentGroup, allTasks, statusList, currentGrouping]);
// Calculate selection state for this group
const { isAllSelected, isPartiallySelected } = useMemo(() => {
if (tasksInGroup.length === 0) {
@@ -94,7 +168,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Handle inline name editing
const handleNameSave = useCallback(async () => {
if (!editingName.trim() || editingName.trim() === group.name || isRenaming) return;
// If no changes or already renaming, just exit editing mode
if (!editingName.trim() || editingName.trim() === group.name || isRenaming) {
setIsEditingName(false);
setEditingName(group.name);
return;
}
setIsRenaming(true);
try {
@@ -122,12 +201,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Refresh task list to get updated group names
dispatch(fetchTasksV3(projectId));
setIsEditingName(false);
} catch (error) {
logger.error('Error renaming group:', error);
setEditingName(group.name);
} finally {
setIsEditingName(false);
setIsRenaming(false);
}
}, [editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming]);
@@ -150,9 +229,8 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
}, [group.name, handleNameSave]);
const handleNameBlur = useCallback(() => {
setIsEditingName(false);
setEditingName(group.name);
}, [group.name]);
handleNameSave();
}, [handleNameSave]);
// Handle dropdown menu actions
const handleRenameGroup = useCallback(() => {
@@ -161,10 +239,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
setEditingName(group.name);
}, [group.name]);
const handleChangeCategory = useCallback(() => {
setDropdownVisible(false);
setCategoryModalVisible(true);
}, []);
// Handle category change
const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => {
@@ -182,7 +257,6 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Refresh status list and tasks
dispatch(fetchStatuses(projectId));
dispatch(fetchTasksV3(projectId));
setCategoryModalVisible(false);
} catch (error) {
logger.error('Error changing category:', error);
@@ -209,19 +283,30 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Only show "Change Category" when grouped by status
if (currentGrouping === 'status') {
items.push({
key: 'changeCategory',
icon: <ArrowPathIcon className="h-4 w-4" />,
label: t('changeCategory'),
const categorySubMenuItems = statusCategories.map((category) => ({
key: `category-${category.id}`,
label: (
<div className="flex items-center gap-2">
<Badge color={category.color_code} />
<span>{category.name}</span>
</div>
),
onClick: (e: any) => {
e?.domEvent?.stopPropagation();
handleChangeCategory();
handleCategoryChange(category.id || '', e?.domEvent);
},
});
}));
items.push({
key: 'changeCategory',
icon: <ArrowPathIcon className="h-4 w-4" />,
label: t('changeCategory'),
children: categorySubMenuItems,
} as any);
}
return items;
}, [currentGrouping, handleRenameGroup, handleChangeCategory, isOwnerOrAdmin]);
}, [currentGrouping, handleRenameGroup, handleCategoryChange, isOwnerOrAdmin, statusCategories, t]);
// Make the group header droppable
const { isOver, setNodeRef } = useDroppable({
@@ -232,75 +317,146 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
},
});
return (
<div
ref={setNodeRef}
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
}`}
style={{
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
color: headerTextColor,
position: 'sticky',
top: 0,
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
height: '36px',
minHeight: '36px',
maxHeight: '36px'
}}
onClick={onToggle}
>
{/* Drag Handle Space - ultra minimal width */}
<div style={{ width: '20px' }} className="flex items-center justify-center">
{/* Chevron button */}
<button
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
style={{ backgroundColor: 'transparent', color: headerTextColor }}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
<div
className="transition-transform duration-300 ease-out"
style={{
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
transformOrigin: 'center'
return (
<div className="relative flex items-center">
<div
ref={setNodeRef}
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
}`}
style={{
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
color: headerTextColor,
position: 'sticky',
top: 0,
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
height: '36px',
minHeight: '36px',
maxHeight: '36px'
}}
onClick={onToggle}
>
{/* Drag Handle Space - ultra minimal width */}
<div style={{ width: '20px' }} className="flex items-center justify-center">
{/* Chevron button */}
<button
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
style={{ backgroundColor: 'transparent', color: headerTextColor }}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
</div>
</button>
</div>
<div
className="transition-transform duration-300 ease-out"
style={{
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
transformOrigin: 'center'
}}
>
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
</div>
</button>
</div>
{/* Select All Checkbox Space - ultra minimal width */}
<div style={{ width: '28px' }} className="flex items-center justify-center">
<Checkbox
checked={isAllSelected}
indeterminate={isPartiallySelected}
onChange={handleSelectAllChange}
onClick={(e) => e.stopPropagation()}
style={{
color: headerTextColor,
}}
/>
</div>
{/* Select All Checkbox Space - ultra minimal width */}
<div style={{ width: '28px' }} className="flex items-center justify-center">
<Checkbox
checked={isAllSelected}
indeterminate={isPartiallySelected}
onChange={handleSelectAllChange}
onClick={(e) => e.stopPropagation()}
style={{
color: headerTextColor,
}}
/>
</div>
{/* Group indicator and name - no gap at all */}
{/* Group indicator and name - no gap at all */}
<div className="flex items-center flex-1 ml-1">
{/* Group name and count */}
<div className="flex items-center">
<span
className="text-sm font-semibold pr-2"
style={{ color: headerTextColor }}
>
{group.name}
</span>
{isEditingName ? (
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={handleNameKeyDown}
onBlur={handleNameBlur}
autoFocus
size="small"
className="text-sm font-semibold"
style={{
width: 'auto',
minWidth: '100px',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
color: headerTextColor,
border: '1px solid rgba(255, 255, 255, 0.3)'
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="text-sm font-semibold pr-2 cursor-pointer hover:underline"
style={{ color: headerTextColor }}
onClick={handleNameClick}
>
{group.name}
</span>
)}
<span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}>
({group.count})
</span>
</div>
</div>
{/* Three-dot menu - only show for status and phase grouping */}
{menuItems.length > 0 && (currentGrouping === 'status' || currentGrouping === 'phase') && (
<div className="flex items-center ml-2">
<Dropdown
menu={{ items: menuItems }}
trigger={['click']}
open={dropdownVisible}
onOpenChange={setDropdownVisible}
placement="bottomRight"
overlayStyle={{ zIndex: 1000 }}
>
<button
className="p-1 rounded-sm hover:bg-black hover:bg-opacity-10 transition-colors duration-200"
style={{ color: headerTextColor }}
onClick={(e) => {
e.stopPropagation();
setDropdownVisible(!dropdownVisible);
}}
>
<EllipsisHorizontalIcon className="h-4 w-4" />
</button>
</Dropdown>
</div>
)}
</div>
{/* Progress Bar - sticky to the right edge during horizontal scroll */}
{(currentGrouping === 'priority' || currentGrouping === 'phase') &&
(groupProgressValues.todoProgress || groupProgressValues.doingProgress || groupProgressValues.doneProgress) && (
<div
className="flex items-center bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm px-3 py-1.5 ml-auto"
style={{
position: 'sticky',
right: '16px',
zIndex: 35, // Higher than header
minWidth: '160px',
height: '30px'
}}
>
<GroupProgressBar
todoProgress={groupProgressValues.todoProgress}
doingProgress={groupProgressValues.doingProgress}
doneProgress={groupProgressValues.doneProgress}
groupType={group.groupType || currentGrouping || ''}
/>
</div>
)}
</div>
);
};

View File

@@ -54,6 +54,7 @@ import {
setCustomColumnModalAttributes,
toggleCustomColumnModalOpen,
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
// Components
import TaskRowWithSubtasks from './TaskRowWithSubtasks';
@@ -64,6 +65,7 @@ import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-t
import AddTaskRow from './components/AddTaskRow';
import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomColumnComponents';
import TaskListSkeleton from './components/TaskListSkeleton';
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
// Hooks and utilities
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
@@ -212,6 +214,7 @@ const TaskListV2Section: React.FC = () => {
if (urlProjectId) {
dispatch(fetchTasksV3(urlProjectId));
dispatch(fetchTaskListColumns(urlProjectId));
dispatch(fetchPhasesByProjectId(urlProjectId));
}
}, [dispatch, urlProjectId]);
@@ -452,6 +455,10 @@ const TaskListV2Section: React.FC = () => {
name: group.title,
count: group.actualCount,
color: group.color,
todo_progress: group.todo_progress,
doing_progress: group.doing_progress,
done_progress: group.done_progress,
groupType: group.groupType,
}}
isCollapsed={isGroupCollapsed}
onToggle={() => handleGroupCollapse(group.id)}
@@ -459,7 +466,7 @@ const TaskListV2Section: React.FC = () => {
/>
{isGroupEmpty && !isGroupCollapsed && (
<div className="relative w-full">
<div className="flex items-center min-w-max px-1 py-3">
<div className="flex items-center min-w-max px-1 py-6">
{visibleColumns.map((column, index) => {
const emptyColumnStyle = {
width: column.width,
@@ -478,7 +485,7 @@ const TaskListV2Section: React.FC = () => {
})}
</div>
<div className="absolute left-4 top-1/2 transform -translate-y-1/2 flex items-center">
<div className="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 px-3 py-1.5 rounded-md border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 px-4 py-3 rounded-md border border-gray-200 dark:border-gray-700 shadow-sm">
{t('noTasksInGroup')}
</div>
</div>
@@ -760,6 +767,9 @@ const TaskListV2Section: React.FC = () => {
{/* Custom Column Modal */}
{createPortal(<CustomColumnModal />, document.body, 'custom-column-modal')}
{/* Convert To Subtask Drawer */}
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-drawer')}
</div>
</DndContext>
);

View File

@@ -0,0 +1,529 @@
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { SocketEvents } from '@/shared/socket-events';
import logger from '@/utils/errorLogger';
import { Task } from '@/types/task-management.types';
import { tasksApiService } from '@/api/tasks/tasks.api.service';
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
import { IBulkAssignRequest } from '@/types/tasks/bulk-action-bar.types';
import {
deleteTask,
fetchTasksV3,
IGroupBy,
toggleTaskExpansion,
updateTaskAssignees,
} from '@/features/task-management/task-management.slice';
import { deselectAll, selectTasks } from '@/features/projects/bulkActions/bulkActionSlice';
import { setConvertToSubtaskDrawerOpen } from '@/features/tasks/tasks.slice';
import { useTranslation } from 'react-i18next';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import {
evt_project_task_list_context_menu_archive,
evt_project_task_list_context_menu_assign_me,
evt_project_task_list_context_menu_delete,
} from '@/shared/worklenz-analytics-events';
import {
DeleteOutlined,
DoubleRightOutlined,
InboxOutlined,
RetweetOutlined,
UserAddOutlined,
LoadingOutlined,
} from '@ant-design/icons';
interface TaskContextMenuProps {
task: Task;
projectId: string;
position: { x: number; y: number };
onClose: () => void;
}
const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
task,
projectId,
position,
onClose,
}) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('task-list-table');
const { socket, connected } = useSocket();
const currentSession = useAuthService().getCurrentSession();
const { trackMixpanelEvent } = useMixpanelTracking();
const { groups: taskGroups } = useAppSelector(state => state.taskManagement);
const statusList = useAppSelector(state => state.taskStatusReducer.status);
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
const currentGrouping = useAppSelector(state => state.grouping.currentGrouping);
const archived = useAppSelector(state => state.taskReducer.archived);
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
const handleAssignToMe = useCallback(async () => {
if (!projectId || !task.id || !currentSession?.team_member_id) return;
try {
setUpdatingAssignToMe(true);
// Immediate UI update - add current user to assignees
const currentUser = {
id: currentSession.team_member_id,
name: currentSession.name || '',
email: currentSession.email || '',
avatar_url: currentSession.avatar_url || '',
team_member_id: currentSession.team_member_id,
};
const updatedAssignees = task.assignees || [];
const updatedAssigneeNames = task.assignee_names || [];
// Check if current user is already assigned
const isAlreadyAssigned = updatedAssignees.includes(currentSession.team_member_id);
if (!isAlreadyAssigned) {
// Add current user to assignees for immediate UI feedback
const newAssignees = [...updatedAssignees, currentSession.team_member_id];
const newAssigneeNames = [...updatedAssigneeNames, currentUser];
// Update Redux store immediately for instant UI feedback
dispatch(
updateTaskAssignees({
taskId: task.id,
assigneeIds: newAssignees,
assigneeNames: newAssigneeNames,
})
);
}
const body: IBulkAssignRequest = {
tasks: [task.id],
project_id: projectId,
};
const res = await taskListBulkActionsApiService.assignToMe(body);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_context_menu_assign_me);
// Socket event will handle syncing with other users
}
} catch (error) {
logger.error('Error assigning to me:', error);
// Revert the optimistic update on error
dispatch(
updateTaskAssignees({
taskId: task.id,
assigneeIds: task.assignees || [],
assigneeNames: task.assignee_names || [],
})
);
} finally {
setUpdatingAssignToMe(false);
onClose();
}
}, [projectId, task.id, task.assignees, task.assignee_names, currentSession, dispatch, onClose, trackMixpanelEvent]);
const handleArchive = useCallback(async () => {
if (!projectId || !task.id) return;
try {
const res = await taskListBulkActionsApiService.archiveTasks(
{
tasks: [task.id],
project_id: projectId,
},
false
);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_context_menu_archive);
dispatch(deleteTask(task.id));
dispatch(deselectAll());
if (task.parent_task_id) {
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
}
}
} catch (error) {
logger.error('Error archiving task:', error);
} finally {
onClose();
}
}, [projectId, task.id, task.parent_task_id, dispatch, socket, onClose, trackMixpanelEvent]);
const handleDelete = useCallback(async () => {
if (!projectId || !task.id) return;
try {
const res = await taskListBulkActionsApiService.deleteTasks({ tasks: [task.id] }, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
dispatch(deleteTask(task.id));
dispatch(deselectAll());
if (task.parent_task_id) {
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
}
}
} catch (error) {
logger.error('Error deleting task:', error);
} finally {
onClose();
}
}, [projectId, task.id, task.parent_task_id, dispatch, socket, onClose, trackMixpanelEvent]);
const handleStatusMoveTo = useCallback(
async (targetId: string) => {
if (!projectId || !task.id || !targetId) return;
try {
socket?.emit(
SocketEvents.TASK_STATUS_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
status_id: targetId,
parent_task: task.parent_task_id || null,
team_id: currentSession?.team_id,
})
);
} catch (error) {
logger.error('Error moving status:', error);
} finally {
onClose();
}
},
[projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose]
);
const handlePriorityMoveTo = useCallback(
async (targetId: string) => {
if (!projectId || !task.id || !targetId) return;
try {
socket?.emit(
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
priority_id: targetId,
parent_task: task.parent_task_id || null,
team_id: currentSession?.team_id,
})
);
} catch (error) {
logger.error('Error moving priority:', error);
} finally {
onClose();
}
},
[projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose]
);
const handlePhaseMoveTo = useCallback(
async (targetId: string) => {
if (!projectId || !task.id || !targetId) return;
try {
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
task_id: task.id,
phase_id: targetId,
parent_task: task.parent_task_id || null,
team_id: currentSession?.team_id,
});
} catch (error) {
logger.error('Error moving phase:', error);
} finally {
onClose();
}
},
[projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose]
);
const getMoveToOptions = useCallback(() => {
let options: { key: string; label: React.ReactNode; onClick: () => void }[] = [];
if (currentGrouping === IGroupBy.STATUS) {
options = statusList.filter(status => status.id).map(status => ({
key: status.id!,
label: (
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: status.color_code }}
></span>
<span>{status.name}</span>
</div>
),
onClick: () => handleStatusMoveTo(status.id!),
}));
} else if (currentGrouping === IGroupBy.PRIORITY) {
options = priorityList.filter(priority => priority.id).map(priority => ({
key: priority.id!,
label: (
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: priority.color_code }}
></span>
<span>{priority.name}</span>
</div>
),
onClick: () => handlePriorityMoveTo(priority.id!),
}));
} else if (currentGrouping === IGroupBy.PHASE) {
options = phaseList.filter(phase => phase.id).map(phase => ({
key: phase.id!,
label: (
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: phase.color_code }}
></span>
<span>{phase.name}</span>
</div>
),
onClick: () => handlePhaseMoveTo(phase.id!),
}));
}
return options;
}, [
currentGrouping,
statusList,
priorityList,
phaseList,
handleStatusMoveTo,
handlePriorityMoveTo,
handlePhaseMoveTo,
]);
const handleConvertToTask = useCallback(async () => {
if (!task?.id || !projectId) return;
try {
const res = await tasksApiService.convertToTask(task.id as string, projectId as string);
if (res.done) {
dispatch(deselectAll());
dispatch(fetchTasksV3(projectId));
}
} catch (error) {
logger.error('Error converting to task', error);
} finally {
onClose();
}
}, [task?.id, projectId, dispatch, onClose]);
const menuItems = useMemo(() => {
const items = [
{
key: 'assignToMe',
label: (
<button
onClick={handleAssignToMe}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
disabled={updatingAssignToMe}
>
{updatingAssignToMe ? (
<LoadingOutlined className="text-gray-500 dark:text-gray-400" />
) : (
<UserAddOutlined className="text-gray-500 dark:text-gray-400" />
)}
<span>{t('contextMenu.assignToMe')}</span>
</button>
),
},
];
// Add Move To submenu if there are options
const moveToOptions = getMoveToOptions();
if (moveToOptions.length > 0) {
items.push({
key: 'moveTo',
label: (
<div className="relative group">
<button className="flex items-center justify-between gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left">
<div className="flex items-center gap-2">
<RetweetOutlined className="text-gray-500 dark:text-gray-400" />
<span>{t('contextMenu.moveTo')}</span>
</div>
<svg
className="w-4 h-4 text-gray-500 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
></path>
</svg>
</button>
<ul className="absolute left-full top-0 mt-0 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-20 hidden group-hover:block">
{moveToOptions.map(option => (
<li key={option.key}>
<button
onClick={option.onClick}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
>
{option.label}
</button>
</li>
))}
</ul>
</div>
),
});
}
// Add Archive/Unarchive for parent tasks only
if (!task?.parent_task_id) {
items.push({
key: 'archive',
label: (
<button
onClick={handleArchive}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
>
<InboxOutlined className="text-gray-500 dark:text-gray-400" />
<span>{archived ? t('contextMenu.unarchive') : t('contextMenu.archive')}</span>
</button>
),
});
}
// Add Convert to Sub Task for parent tasks with no subtasks
if (task?.sub_tasks_count === 0 && !task?.parent_task_id) {
items.push({
key: 'convertToSubTask',
label: (
<button
onClick={() => {
// Convert task to the format expected by bulkActionSlice
const projectTask = {
id: task.id,
name: task.title || task.name || '',
task_key: task.task_key,
status: task.status,
status_id: task.status,
priority: task.priority,
phase_id: task.phase,
phase_name: task.phase,
description: task.description,
start_date: task.startDate,
end_date: task.dueDate,
total_hours: task.timeTracking?.estimated || 0,
total_minutes: task.timeTracking?.logged || 0,
progress: task.progress,
sub_tasks_count: task.sub_tasks_count || 0,
assignees: task.assignees?.map((assigneeId: string) => ({
id: assigneeId,
name: '',
email: '',
avatar_url: '',
team_member_id: assigneeId,
project_member_id: assigneeId,
})) || [],
labels: task.labels || [],
manual_progress: false,
created_at: task.createdAt,
updated_at: task.updatedAt,
sort_order: task.order,
};
// Select the task in bulk action reducer
dispatch(selectTasks([projectTask]));
// Open the drawer
dispatch(setConvertToSubtaskDrawerOpen(true));
}}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
>
<DoubleRightOutlined className="text-gray-500 dark:text-gray-400" />
<span>{t('contextMenu.convertToSubTask')}</span>
</button>
),
});
}
// Add Convert to Task for subtasks
if (task?.parent_task_id) {
items.push({
key: 'convertToTask',
label: (
<button
onClick={handleConvertToTask}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
>
<DoubleRightOutlined className="text-gray-500 dark:text-gray-400" />
<span>{t('contextMenu.convertToTask')}</span>
</button>
),
});
}
// Add Delete
items.push({
key: 'delete',
label: (
<button
onClick={handleDelete}
className="flex items-center gap-2 px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/20 w-full text-left"
>
<DeleteOutlined className="text-red-500 dark:text-red-400" />
<span>{t('contextMenu.delete')}</span>
</button>
),
});
return items;
}, [
task,
projectId,
updatingAssignToMe,
archived,
handleAssignToMe,
handleArchive,
handleDelete,
handleConvertToTask,
getMoveToOptions,
dispatch,
t,
]);
return (
<div
ref={menuRef}
className="fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg py-1 min-w-48"
style={{
top: position.y,
left: position.x,
zIndex: 9999,
}}
>
<ul className="list-none p-0 m-0">
{menuItems.map(item => (
<li key={item.key} className="relative group">
{item.label}
</li>
))}
</ul>
</div>
);
};
export default TaskContextMenu;

View File

@@ -2,6 +2,7 @@ import React, { memo, useCallback, useState, useRef, useEffect } from 'react';
import { RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons';
import { Input, Tooltip } from 'antd';
import type { InputRef } from 'antd';
import { createPortal } from 'react-dom';
import { Task } from '@/types/task-management.types';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleTaskExpansion, fetchSubTasks } from '@/features/task-management/task-management.slice';
@@ -10,6 +11,7 @@ import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useTranslation } from 'react-i18next';
import { getTaskDisplayName } from './TaskRowColumns';
import TaskContextMenu from './TaskContextMenu';
interface TitleColumnProps {
width: string;
@@ -42,6 +44,10 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
const inputRef = useRef<InputRef>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
// Context menu state
const [contextMenuVisible, setContextMenuVisible] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
// Handle task expansion toggle
const handleToggleExpansion = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
@@ -71,6 +77,24 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
onEditTaskName(false);
}, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, onEditTaskName]);
// Handle context menu
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Use clientX and clientY directly for fixed positioning
setContextMenuPosition({
x: e.clientX,
y: e.clientY
});
setContextMenuVisible(true);
}, []);
// Handle context menu close
const handleContextMenuClose = useCallback(() => {
setContextMenuVisible(false);
}, []);
// Handle click outside for task name editing
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -169,6 +193,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
e.preventDefault();
onEditTaskName(true);
}}
onContextMenu={handleContextMenu}
title={taskDisplayName}
>
{taskDisplayName}
@@ -251,6 +276,17 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
</button>
</>
)}
{/* Context Menu */}
{contextMenuVisible && createPortal(
<TaskContextMenu
task={task}
projectId={projectId}
position={contextMenuPosition}
onClose={handleContextMenuClose}
/>,
document.body
)}
</div>
);
});

View File

@@ -20,6 +20,112 @@
border-top: 1px solid #303030;
}
/* Dark mode confirmation modal styling */
.dark .ant-modal-confirm .ant-modal-content,
[data-theme="dark"] .ant-modal-confirm .ant-modal-content {
background-color: #1f1f1f !important;
border: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-header,
[data-theme="dark"] .ant-modal-confirm .ant-modal-header {
background-color: #1f1f1f !important;
border-bottom: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-body,
[data-theme="dark"] .ant-modal-confirm .ant-modal-body {
background-color: #1f1f1f !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-modal-footer,
[data-theme="dark"] .ant-modal-confirm .ant-modal-footer {
background-color: #1f1f1f !important;
border-top: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-confirm-title,
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-title {
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-modal-confirm-content,
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-content {
color: #8c8c8c !important;
}
.dark .ant-modal-confirm .ant-btn-default,
[data-theme="dark"] .ant-modal-confirm .ant-btn-default {
background-color: #141414 !important;
border-color: #303030 !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-btn-default:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-default:hover {
background-color: #262626 !important;
border-color: #40a9ff !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-btn-primary,
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary {
background-color: #1890ff !important;
border-color: #1890ff !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-primary:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary:hover {
background-color: #40a9ff !important;
border-color: #40a9ff !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-dangerous,
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous {
background-color: #ff4d4f !important;
border-color: #ff4d4f !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-dangerous:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous:hover {
background-color: #ff7875 !important;
border-color: #ff7875 !important;
color: #ffffff !important;
}
/* Light mode confirmation modal styling (ensure consistency) */
.ant-modal-confirm .ant-modal-content {
background-color: #ffffff;
border: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-header {
background-color: #ffffff;
border-bottom: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-body {
background-color: #ffffff;
color: #262626;
}
.ant-modal-confirm .ant-modal-footer {
background-color: #ffffff;
border-top: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-confirm-title {
color: #262626;
}
.ant-modal-confirm .ant-modal-confirm-content {
color: #595959;
}
.dark-modal .ant-form-item-label > label {
color: #d9d9d9;
}

View File

@@ -18,6 +18,7 @@ import {
deletePhaseOption,
updatePhaseColor,
} from '@/features/projects/singleProject/phase/phases.slice';
import { updatePhaseLabel } from '@/features/project/project.slice';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { Modal as AntModal } from 'antd';
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
@@ -307,7 +308,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
if (!newPhaseName.trim() || !finalProjectId) return;
try {
await dispatch(addPhaseOption({ projectId: finalProjectId }));
await dispatch(addPhaseOption({ projectId: finalProjectId, name: newPhaseName.trim() }));
await dispatch(fetchPhasesByProjectId(finalProjectId));
await refreshTasks();
setNewPhaseName('');
@@ -408,6 +409,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
).unwrap();
if (res.done) {
dispatch(updatePhaseLabel(phaseName));
setInitialPhaseName(phaseName);
await refreshTasks();
}
@@ -428,7 +430,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
<Title level={4} className={`m-0 font-semibold ${
isDarkMode ? 'text-gray-100' : 'text-gray-800'
}`}>
{t('configurePhases')}
{t('configure')} {phaseName || project?.phase_label || t('phasesText')}
</Title>
}
open={open}
@@ -495,7 +497,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
<Text className={`text-xs font-medium ${
isDarkMode ? 'text-gray-300' : 'text-blue-700'
}`}>
🎨 Drag phases to reorder them. Click on a phase name to rename it. Each phase can have a custom color.
🎨 Drag {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} to reorder them. Click on a {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} name to rename it. Each {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} can have a custom color.
</Text>
</div>
@@ -558,7 +560,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
<Text className={`text-xs font-medium ${
isDarkMode ? 'text-gray-300' : 'text-gray-700'
}`}>
{t('phaseOptions')}
{phaseName || project?.phase_label || t('phasesText')} {t('optionsText')}
</Text>
<Button
type="primary"
@@ -601,7 +603,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
isDarkMode ? 'text-gray-400' : 'text-gray-500'
}`}>
<Text className="text-sm font-medium">
{t('noPhasesFound')}
{t('no')} {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} {t('found')}
</Text>
<br />
<Button

View File

@@ -20,6 +20,112 @@
border-top: 1px solid #303030;
}
/* Dark mode confirmation modal styling */
.dark .ant-modal-confirm .ant-modal-content,
[data-theme="dark"] .ant-modal-confirm .ant-modal-content {
background-color: #1f1f1f !important;
border: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-header,
[data-theme="dark"] .ant-modal-confirm .ant-modal-header {
background-color: #1f1f1f !important;
border-bottom: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-body,
[data-theme="dark"] .ant-modal-confirm .ant-modal-body {
background-color: #1f1f1f !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-modal-footer,
[data-theme="dark"] .ant-modal-confirm .ant-modal-footer {
background-color: #1f1f1f !important;
border-top: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-confirm-title,
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-title {
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-modal-confirm-content,
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-content {
color: #8c8c8c !important;
}
.dark .ant-modal-confirm .ant-btn-default,
[data-theme="dark"] .ant-modal-confirm .ant-btn-default {
background-color: #141414 !important;
border-color: #303030 !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-btn-default:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-default:hover {
background-color: #262626 !important;
border-color: #40a9ff !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-btn-primary,
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary {
background-color: #1890ff !important;
border-color: #1890ff !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-primary:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary:hover {
background-color: #40a9ff !important;
border-color: #40a9ff !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-dangerous,
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous {
background-color: #ff4d4f !important;
border-color: #ff4d4f !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-dangerous:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous:hover {
background-color: #ff7875 !important;
border-color: #ff7875 !important;
color: #ffffff !important;
}
/* Light mode confirmation modal styling (ensure consistency) */
.ant-modal-confirm .ant-modal-content {
background-color: #ffffff;
border: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-header {
background-color: #ffffff;
border-bottom: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-body {
background-color: #ffffff;
color: #262626;
}
.ant-modal-confirm .ant-modal-footer {
background-color: #ffffff;
border-top: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-confirm-title {
color: #262626;
}
.ant-modal-confirm .ant-modal-confirm-content {
color: #595959;
}
.dark-modal .ant-form-item-label > label {
color: #d9d9d9;
}

View File

@@ -15,7 +15,6 @@ import { IKanbanTaskStatus } from '@/types/tasks/taskStatus.types';
import { Modal as AntModal } from 'antd';
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
import './ManageStatusModal.css';
const { Title, Text } = Typography;
@@ -594,7 +593,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
// Refresh from server to ensure consistency
dispatch(fetchStatuses(finalProjectId));
dispatch(fetchTasksV3(finalProjectId));
dispatch(fetchTaskGroups(finalProjectId));
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
} catch (error) {
console.error('Error changing status category:', error);
@@ -736,7 +734,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
statusApiService.updateStatusOrder(requestBody, finalProjectId).then(() => {
// Refresh task lists after status order change
dispatch(fetchTasksV3(finalProjectId));
dispatch(fetchTaskGroups(finalProjectId));
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
}).catch(error => {
console.error('Error updating status order:', error);
@@ -767,7 +764,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
if (res.done) {
dispatch(fetchStatuses(finalProjectId));
dispatch(fetchTasksV3(finalProjectId));
dispatch(fetchTaskGroups(finalProjectId));
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
}
} catch (error) {
@@ -791,7 +787,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
await statusApiService.updateNameOfStatus(id, body, finalProjectId);
dispatch(fetchStatuses(finalProjectId));
dispatch(fetchTasksV3(finalProjectId));
dispatch(fetchTaskGroups(finalProjectId));
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
} catch (error) {
console.error('Error renaming status:', error);
@@ -813,7 +808,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
await statusApiService.deleteStatus(id, finalProjectId, replacingStatusId);
dispatch(fetchStatuses(finalProjectId));
dispatch(fetchTasksV3(finalProjectId));
dispatch(fetchTaskGroups(finalProjectId));
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
} catch (error) {
console.error('Error deleting status:', error);

View File

@@ -369,6 +369,7 @@ const FilterDropdown: React.FC<{
dispatch?: any;
onManageStatus?: () => void;
onManagePhase?: () => void;
projectPhaseLabel?: string; // Add this prop
}> = ({
section,
onSelectionChange,
@@ -380,6 +381,7 @@ const FilterDropdown: React.FC<{
dispatch,
onManageStatus,
onManagePhase,
projectPhaseLabel, // Add this prop
}) => {
const { t } = useTranslation('task-list-filters');
// Add permission checks for groupBy section
@@ -495,7 +497,7 @@ const FilterDropdown: React.FC<{
isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'
}`}
>
{t('managePhases')}
{t('manage')} {projectPhaseLabel || t('phasesText')}
</button>
)}
{section.selectedValues[0] === 'status' && (
@@ -994,6 +996,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark');
const { projectId } = useAppSelector(state => state.projectReducer);
const { projectView } = useTabSearchParam();
const projectPhaseLabel = useAppSelector(state => state.projectReducer.project?.phase_label);
// Theme-aware class names - memoize to prevent unnecessary re-renders
// Using greyish colors for both dark and light modes
@@ -1298,6 +1301,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
dispatch={dispatch}
onManageStatus={() => setShowManageStatusModal(true)}
onManagePhase={() => setShowManagePhaseModal(true)}
projectPhaseLabel={projectPhaseLabel}
/>
))
) : (

View File

@@ -312,7 +312,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(
{groupTasks.length === 0 ? (
<div className="task-group-empty">
<div className="task-table-fixed-columns">
<div style={{ width: '380px', padding: '20px 12px' }}>
<div style={{ width: '380px', padding: '32px 12px' }}>
<div className="text-center text-gray-500">
<Text type="secondary">No tasks in this group</Text>
<br />
@@ -487,7 +487,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(
.task-group-empty {
display: flex;
height: 80px;
height: 120px;
align-items: center;
background: var(--task-bg-primary, white);
transition: background-color 0.3s ease;

View File

@@ -35,8 +35,6 @@ const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
(phaseId: string, phaseName: string) => {
if (!task.id || !phaseId || !connected) return;
console.log('🎯 Phase change initiated:', { taskId: task.id, phaseId, phaseName });
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
task_id: task.id,
phase_id: phaseId,
@@ -51,8 +49,6 @@ const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
const handlePhaseClear = useCallback(() => {
if (!task.id || !connected) return;
console.log('🎯 Phase clear initiated:', { taskId: task.id });
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
task_id: task.id,
phase_id: null,

View File

@@ -33,7 +33,7 @@ export const GROUP_BY_OPTIONS: IGroupByOption[] = [
{ label: 'Phase', value: IGroupBy.PHASE },
];
const LOCALSTORAGE_GROUP_KEY = 'worklenz.enhanced-kanban.group_by';
const LOCALSTORAGE_GROUP_KEY = 'worklenz.kanban.group_by';
export const getCurrentGroup = (): IGroupBy => {
const key = localStorage.getItem(LOCALSTORAGE_GROUP_KEY);

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { useSelectedProject } from '../../../../hooks/useSelectedProject';
import { useAppSelector } from '../../../../hooks/useAppSelector';
import { Flex } from 'antd';
import ConfigPhaseButton from './ConfigPhaseButton';
@@ -10,19 +9,13 @@ const PhaseHeader = () => {
// localization
const { t } = useTranslation('task-list-filters');
// get selected project for useSelectedProject hook
const selectedProject = useSelectedProject();
// get phase data from redux
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
//get phases details from phases slice
const phase = phaseList.find(el => el.projectId === selectedProject?.projectId);
// get project data from redux
const { project } = useAppSelector(state => state.projectReducer);
return (
<Flex align="center" justify="space-between">
{phase?.phase || t('phasesText')}
<ConfigPhaseButton color={colors.darkGray} />
{project?.phase_label || t('phasesText')}
<ConfigPhaseButton />
</Flex>
);
};

View File

@@ -16,9 +16,9 @@ const initialState: PhaseState = {
export const addPhaseOption = createAsyncThunk(
'phase/addPhaseOption',
async ({ projectId }: { projectId: string }, { rejectWithValue }) => {
async ({ projectId, name }: { projectId: string; name?: string }, { rejectWithValue }) => {
try {
const response = await phasesApiService.addPhaseOption(projectId);
const response = await phasesApiService.addPhaseOption(projectId, name);
return response;
} catch (error) {
return rejectWithValue(error);

View File

@@ -17,8 +17,36 @@ interface LocalGroupingState {
collapsedGroups: string[];
}
// Local storage constants
const LOCALSTORAGE_GROUP_KEY = 'worklenz.tasklist.group_by';
// Utility functions for local storage
const loadGroupingFromLocalStorage = (): GroupingType | null => {
try {
const stored = localStorage.getItem(LOCALSTORAGE_GROUP_KEY);
if (stored && ['status', 'priority', 'phase'].includes(stored)) {
return stored as GroupingType;
}
} catch (error) {
console.warn('Failed to load grouping from localStorage:', error);
}
return 'status'; // Default to 'status' instead of null
};
const saveGroupingToLocalStorage = (grouping: GroupingType | null): void => {
try {
if (grouping) {
localStorage.setItem(LOCALSTORAGE_GROUP_KEY, grouping);
} else {
localStorage.removeItem(LOCALSTORAGE_GROUP_KEY);
}
} catch (error) {
console.warn('Failed to save grouping to localStorage:', error);
}
};
const initialState: LocalGroupingState = {
currentGrouping: null,
currentGrouping: loadGroupingFromLocalStorage(),
customPhases: ['Planning', 'Development', 'Testing', 'Deployment'],
groupOrder: {
status: ['todo', 'doing', 'done'],
@@ -35,6 +63,7 @@ const groupingSlice = createSlice({
reducers: {
setCurrentGrouping: (state, action: PayloadAction<GroupingType | null>) => {
state.currentGrouping = action.payload;
saveGroupingToLocalStorage(action.payload);
},
addCustomPhase: (state, action: PayloadAction<string>) => {

View File

@@ -123,11 +123,11 @@ const TasksList: React.FC = React.memo(() => {
<span>{t('tasks.name')}</span>
</Flex>
),
width: '150px',
width: '40%',
render: (_, record) => (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Tooltip title={record.name}>
<Typography.Text ellipsis={{ tooltip: true }} style={{ maxWidth: 150 }}>
<Typography.Text style={{ flex: 1, marginRight: 8 }}>
{record.name}
</Typography.Text>
</Tooltip>
@@ -155,15 +155,14 @@ const TasksList: React.FC = React.memo(() => {
{
key: 'project',
title: t('tasks.project'),
width: '120px',
width: '25%',
render: (_, record) => {
return (
<Tooltip title={record.project_name}>
<Typography.Paragraph
style={{ margin: 0, paddingInlineEnd: 6, maxWidth: 120 }}
ellipsis={{ tooltip: true }}
style={{ margin: 0, paddingInlineEnd: 6 }}
>
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
<Badge color={record.project_color || 'blue'} style={{ marginInlineEnd: 4 }} />
{record.project_name}
</Typography.Paragraph>
</Tooltip>
@@ -173,7 +172,7 @@ const TasksList: React.FC = React.memo(() => {
{
key: 'status',
title: t('tasks.status'),
width: '180px',
width: '20%',
render: (_, record) => (
<HomeTasksStatusDropdown task={record} teamId={record.team_id || ''} />
),
@@ -181,7 +180,7 @@ const TasksList: React.FC = React.memo(() => {
{
key: 'dueDate',
title: t('tasks.dueDate'),
width: '180px',
width: '15%',
dataIndex: 'end_date',
render: (_, record) => <HomeTasksDatePicker record={record} />,
},

View File

@@ -106,13 +106,8 @@ const BoardCreateSectionCard = () => {
}
if (groupBy === IGroupBy.PHASE && projectId) {
const body = {
name: sectionName,
project_id: projectId,
};
try {
const response = await phasesApiService.addPhaseOption(projectId);
const response = await phasesApiService.addPhaseOption(projectId, sectionName);
if (response.done && response.body) {
dispatch(fetchBoardTaskGroups(projectId));
}

View File

@@ -212,7 +212,7 @@ const ProjectViewHeader = memo(() => {
setCreatingTask(true);
const body: Partial<ITaskCreateRequest> = {
name: DEFAULT_TASK_NAME,
name: t('defaultTaskName'),
project_id: selectedProject.id,
reporter_id: currentSession.id,
team_id: currentSession.team_id,
@@ -242,7 +242,7 @@ const ProjectViewHeader = memo(() => {
logger.error('Error creating task', error);
setCreatingTask(false);
}
}, [selectedProject?.id, currentSession, socket, dispatch, groupBy, tab]);
}, [selectedProject?.id, currentSession, socket, dispatch, groupBy, tab, t]);
// Memoized import task template handler
const handleImportTaskTemplate = useCallback(() => {

View File

@@ -524,6 +524,69 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
});
}
// NEW SIMPLIFIED APPROACH: Calculate all affected task updates and send them
const taskUpdates: Array<{
task_id: string;
sort_order: number;
status_id?: string;
priority_id?: string;
phase_id?: string;
}> = [];
// Add updates for all tasks in affected groups
if (activeGroupId === overGroupId) {
// Same group - just reorder
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(fromIndex, 1);
updatedTasks.splice(toIndex, 0, task);
updatedTasks.forEach((task, index) => {
taskUpdates.push({
task_id: task.id,
sort_order: index + 1, // 1-based indexing
});
});
} else {
// Different groups - update both source and target
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
const updatedTargetTasks = [...targetGroup.tasks];
if (isTargetGroupEmpty) {
updatedTargetTasks.push(task);
} else if (toIndex >= 0 && toIndex <= updatedTargetTasks.length) {
updatedTargetTasks.splice(toIndex, 0, task);
} else {
updatedTargetTasks.push(task);
}
// Add updates for source group
updatedSourceTasks.forEach((task, index) => {
taskUpdates.push({
task_id: task.id,
sort_order: index + 1,
});
});
// Add updates for target group (including the moved task)
updatedTargetTasks.forEach((task, index) => {
const update: any = {
task_id: task.id,
sort_order: index + 1,
};
// Add group-specific updates
if (groupBy === IGroupBy.STATUS) {
update.status_id = targetGroup.id;
} else if (groupBy === IGroupBy.PRIORITY) {
update.priority_id = targetGroup.id;
} else if (groupBy === IGroupBy.PHASE) {
update.phase_id = targetGroup.id;
}
taskUpdates.push(update);
});
}
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
project_id: projectId,
from_index: sourceGroup.tasks[fromIndex].sort_order,
@@ -534,6 +597,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
group_by: groupBy,
task: sourceGroup.tasks[fromIndex],
team_id: currentSession?.team_id,
task_updates: taskUpdates, // NEW: Send calculated updates
});
setTimeout(resetTaskRowStyles, 0);

View File

@@ -208,6 +208,18 @@ const TaskListTableWrapper = ({
>
<Flex vertical>
<Flex style={{ transform: 'translateY(6px)' }}>
{groupBy !== IGroupBy.PRIORITY &&
!showRenameInput &&
isEditable &&
name !== 'Unmapped' && (
<Dropdown menu={{ items }}>
<Button
icon={<EllipsisOutlined />}
className="borderless-icon-btn"
title={isEditable ? undefined : t('noPermission')}
/>
</Dropdown>
)}
<Button
className="custom-collapse-button"
style={{
@@ -243,18 +255,6 @@ const TaskListTableWrapper = ({
</Typography.Text>
)}
</Button>
{groupBy !== IGroupBy.PRIORITY &&
!showRenameInput &&
isEditable &&
name !== 'Unmapped' && (
<Dropdown menu={{ items }}>
<Button
icon={<EllipsisOutlined />}
className="borderless-icon-btn"
title={isEditable ? undefined : t('noPermission')}
/>
</Dropdown>
)}
</Flex>
<Collapsible
isOpen={isExpanded}

View File

@@ -944,18 +944,7 @@ const SelectionFieldCell: React.FC<{
columnKey: string;
updateValue: (taskId: string, columnKey: string, value: string) => void;
}> = ({ selectionsList, value, task, columnKey, updateValue }) => {
// Debug the selectionsList data
const [loggedInfo, setLoggedInfo] = useState(false);
useEffect(() => {
if (!loggedInfo) {
console.log('Selection column data:', {
columnKey,
selectionsList,
});
setLoggedInfo(true);
}
}, [columnKey, selectionsList, loggedInfo]);
return (
<CustomColumnSelectionCell
@@ -1256,19 +1245,6 @@ const renderCustomColumnContent = (
);
},
selection: () => {
// Debug the selectionsList data
const [loggedInfo, setLoggedInfo] = useState(false);
useEffect(() => {
if (!loggedInfo) {
console.log('Selection column data:', {
columnKey,
selectionsList: columnObj?.selectionsList,
});
setLoggedInfo(true);
}
}, [columnKey, loggedInfo]);
return (
<SelectionFieldCell
selectionsList={columnObj?.selectionsList || []}
@@ -1650,35 +1626,12 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
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) {
@@ -1686,36 +1639,17 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
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({
@@ -1758,34 +1692,10 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
// 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,
});
}
}
}

View File

@@ -4,6 +4,152 @@
width: 100%;
}
/* Global Confirmation Modal Styles */
/* Light mode confirmation modal styling (default) */
.ant-modal-confirm .ant-modal-content {
background-color: #ffffff;
border: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-header {
background-color: #ffffff;
border-bottom: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-body {
background-color: #ffffff;
color: #262626;
}
.ant-modal-confirm .ant-modal-footer {
background-color: #ffffff;
border-top: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-confirm-title {
color: #262626;
}
.ant-modal-confirm .ant-modal-confirm-content {
color: #595959;
}
/* Dark mode confirmation modal styling */
.dark .ant-modal-confirm .ant-modal-content,
[data-theme="dark"] .ant-modal-confirm .ant-modal-content,
html.dark .ant-modal-confirm .ant-modal-content {
background-color: #1f1f1f !important;
border: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-header,
[data-theme="dark"] .ant-modal-confirm .ant-modal-header,
html.dark .ant-modal-confirm .ant-modal-header {
background-color: #1f1f1f !important;
border-bottom: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-body,
[data-theme="dark"] .ant-modal-confirm .ant-modal-body,
html.dark .ant-modal-confirm .ant-modal-body {
background-color: #1f1f1f !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-modal-footer,
[data-theme="dark"] .ant-modal-confirm .ant-modal-footer,
html.dark .ant-modal-confirm .ant-modal-footer {
background-color: #1f1f1f !important;
border-top: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-confirm-title,
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-title,
html.dark .ant-modal-confirm .ant-modal-confirm-title {
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-modal-confirm-content,
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-content,
html.dark .ant-modal-confirm .ant-modal-confirm-content {
color: #8c8c8c !important;
}
.dark .ant-modal-confirm .ant-btn-default,
[data-theme="dark"] .ant-modal-confirm .ant-btn-default,
html.dark .ant-modal-confirm .ant-btn-default {
background-color: #141414 !important;
border-color: #303030 !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-btn-default:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-default:hover,
html.dark .ant-modal-confirm .ant-btn-default:hover {
background-color: #262626 !important;
border-color: #40a9ff !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-btn-primary,
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary,
html.dark .ant-modal-confirm .ant-btn-primary {
background-color: #1890ff !important;
border-color: #1890ff !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-primary:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary:hover,
html.dark .ant-modal-confirm .ant-btn-primary:hover {
background-color: #40a9ff !important;
border-color: #40a9ff !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-dangerous,
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous,
html.dark .ant-modal-confirm .ant-btn-dangerous {
background-color: #ff4d4f !important;
border-color: #ff4d4f !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-dangerous:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous:hover,
html.dark .ant-modal-confirm .ant-btn-dangerous:hover {
background-color: #ff7875 !important;
border-color: #ff7875 !important;
color: #ffffff !important;
}
/* Error modal specific styling */
.dark .ant-modal-error .ant-modal-content,
[data-theme="dark"] .ant-modal-error .ant-modal-content,
html.dark .ant-modal-error .ant-modal-content {
background-color: #1f1f1f !important;
border: 1px solid #303030 !important;
}
.dark .ant-modal-error .ant-modal-body,
[data-theme="dark"] .ant-modal-error .ant-modal-body,
html.dark .ant-modal-error .ant-modal-body {
background-color: #1f1f1f !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-error .ant-modal-confirm-title,
[data-theme="dark"] .ant-modal-error .ant-modal-confirm-title,
html.dark .ant-modal-error .ant-modal-confirm-title {
color: #d9d9d9 !important;
}
.dark .ant-modal-error .ant-modal-confirm-content,
[data-theme="dark"] .ant-modal-error .ant-modal-content,
html.dark .ant-modal-error .ant-modal-confirm-content {
color: #8c8c8c !important;
}
.task-group {
transition: all 0.2s ease;
}