Merge branch 'main' of https://github.com/Worklenz/worklenz into test/row-kanban-board-v1.2.0
This commit is contained in:
411
README.md
411
README.md
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
3
worklenz-backend/.gitignore
vendored
3
worklenz-backend/.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
$$;
|
||||
@@ -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
|
||||
$$;
|
||||
|
||||
@@ -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"
|
||||
}]
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 të 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",
|
||||
|
||||
@@ -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 në 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ë të 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 në 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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "关闭"
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
"endDate": "结束日期",
|
||||
"projectSettings": "项目设置",
|
||||
"projectSummary": "项目摘要",
|
||||
"receiveProjectSummary": "每晚接收项目摘要。",
|
||||
"receiveProjectSummary": "每天晚上接收项目摘要。",
|
||||
"refreshProject": "刷新项目",
|
||||
"saveAsTemplate": "保存为模板",
|
||||
"invite": "邀请",
|
||||
@@ -25,6 +25,7 @@
|
||||
"importTaskTooltip": "从模板导入任务",
|
||||
"navigateBackTooltip": "返回项目列表",
|
||||
"projectStatusTooltip": "项目状态",
|
||||
"projectDatesInfo": "项目时间安排信息",
|
||||
"projectCategoryTooltip": "项目类别"
|
||||
"projectDatesInfo": "项目时间线信息",
|
||||
"projectCategoryTooltip": "项目类别",
|
||||
"defaultTaskName": "无标题任务"
|
||||
}
|
||||
@@ -62,7 +62,8 @@
|
||||
"clearing": "清除中...",
|
||||
"cancel": "取消",
|
||||
"search": "搜索",
|
||||
"groupedBy": "分组依据",
|
||||
"groupedBy": "分组方式",
|
||||
"manage": "管理",
|
||||
"manageStatuses": "管理状态",
|
||||
"managePhases": "管理阶段",
|
||||
"dragToReorderStatuses": "拖拽状态以重新排序。每个状态可以有不同的类别。",
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
"changeCategory": "更改类别",
|
||||
"clickToEditGroupName": "点击编辑组名称",
|
||||
"enterGroupName": "输入组名称",
|
||||
"todo": "待办",
|
||||
"inProgress": "进行中",
|
||||
"done": "已完成",
|
||||
"defaultTaskName": "无标题任务",
|
||||
|
||||
"indicators": {
|
||||
"tooltips": {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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} />,
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user