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

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

411
README.md
View File

@@ -6,6 +6,24 @@
Worklenz Worklenz
</h1> </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"> <p align="center">
<a href="https://worklenz.com/task-management/">Task Management</a> | <a href="https://worklenz.com/task-management/">Task Management</a> |
<a href="https://worklenz.com/time-tracking/">Time Tracking</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 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. 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 ## Features
- **Project Planning**: Create and organize projects, assign tasks to team members. - **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 ## 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) The fastest way to get Worklenz running locally with all dependencies included.
- PostgreSQL database
- An S3-compatible storage service (like MinIO) or Azure Blob Storage
### Option 1: Manual Installation **Prerequisites:**
- Docker and Docker Compose installed on your system
- Git
1. Clone the repository **Steps:**
1. Clone the repository:
```bash ```bash
git clone https://github.com/Worklenz/worklenz.git git clone https://github.com/Worklenz/worklenz.git
cd worklenz cd worklenz
``` ```
2. Set up environment variables 2. Start the Docker containers:
- Copy the example environment files
```bash
cp worklenz-backend/.env.template worklenz-backend/.env
```
- Update the environment variables with your configuration
3. Install dependencies
```bash ```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 cd worklenz-backend
npm install npm install
# Install frontend dependencies # Frontend dependencies
cd ../worklenz-frontend cd ../worklenz-frontend
npm install npm install
``` ```
4. Set up the database 4. Set up the database:
```bash ```bash
# Create a PostgreSQL database named worklenz_db # Create a PostgreSQL database named worklenz_db
cd worklenz-backend 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 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 ```bash
# In one terminal, start the backend # Terminal 1: Start the backend
cd worklenz-backend cd worklenz-backend
npm run dev npm run dev
# In another terminal, start the frontend # Terminal 2: Start the frontend
cd worklenz-frontend cd worklenz-frontend
npm run dev npm run dev
``` ```
6. Access the application at http://localhost:5000 6. Access the application at http://localhost:5000
### Option 2: Docker Setup ## Deployment
The project includes a fully configured Docker setup with: For local development, follow the [Quick Start (Docker)](#-quick-start-docker---recommended) section above.
- Frontend React application
- Backend server
- PostgreSQL database
- MinIO for S3-compatible storage
1. Clone the repository: ### Remote Server Deployment
```bash
git clone https://github.com/Worklenz/worklenz.git
cd worklenz
```
2. Start the Docker containers (choose one option): When deploying to a remote server:
**Using Docker Compose directly** 1. Set up the environment files with your server's hostname:
```bash ```bash
docker-compose up -d # For HTTP/WS
``` ./update-docker-env.sh your-server-hostname
# For HTTPS/WSS
./update-docker-env.sh your-server-hostname true
```
3. The application will be available at: 2. Pull and run the latest Docker images:
- Frontend: http://localhost:5000 ```bash
- Backend API: http://localhost:3000 docker-compose pull
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin) docker-compose up -d
```
4. To stop the services: 3. Access the application through your server's hostname:
```bash - Frontend: http://your-server-hostname:5000
docker-compose down - 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 ## 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. 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. 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 - **MinIO Console**: http://localhost:9001
- Username: minioadmin - Username: minioadmin
- Password: minioadmin - Password: minioadmin
- **Default Bucket**: worklenz-bucket (created automatically when the containers start) - **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 ### Security Considerations
For production deployments: For production deployments:
@@ -177,20 +280,12 @@ For production deployments:
4. Enable HTTPS for all public endpoints 4. Enable HTTPS for all public endpoints
5. Review and update dependencies regularly 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 ## 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. 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. Email [info@worklenz.com](mailto:info@worklenz.com) to disclose any security vulnerabilities.
## License
This project is licensed under the [MIT License](LICENSE).
## Analytics ## 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. 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> </a>
</p> </p>
### Contributing ## Contributing
We welcome contributions from the community! If you'd like to contribute, please follow We welcome contributions from the community! If you'd like to contribute, please follow our [contributing guidelines](CONTRIBUTING.md).
our [contributing guidelines](CONTRIBUTING.md).
### License ## License
Worklenz is open source and released under the [GNU Affero General Public License Version 3 (AGPLv3)](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. By contributing to Worklenz, you agree that your contributions will be licensed under its AGPL.
# Worklenz React
This repository contains the React version of Worklenz with a Docker setup for easy development and deployment.
## Getting Started with Docker
The project includes a fully configured Docker setup with:
- Frontend React application
- Backend server
- PostgreSQL database
- MinIO for S3-compatible storage
### Prerequisites
- Docker and Docker Compose installed on your system
- Git
### Quick Start
1. Clone the repository:
```bash
git clone https://github.com/Worklenz/worklenz.git
cd worklenz
```
2. Start the Docker containers (choose one option):
**Option 1: Using the provided scripts (easiest)**
- On Windows:
```
start.bat
```
- On Linux/macOS:
```bash
./start.sh
```
**Option 2: Using Docker Compose directly**
```bash
docker-compose up -d
```
3. The application will be available at:
- Frontend: http://localhost:5000
- Backend API: http://localhost:3000
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin)
4. To stop the services (choose one option):
**Option 1: Using the provided scripts**
- On Windows:
```
stop.bat
```
- On Linux/macOS:
```bash
./stop.sh
```
**Option 2: Using Docker Compose directly**
```bash
docker-compose down
```
## MinIO Integration
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
### Working with MinIO
MinIO provides an S3-compatible API, so any code that works with S3 will work with MinIO by simply changing the endpoint URL. The backend has been configured to use MinIO by default, with no additional configuration required.
- **MinIO Console**: http://localhost:9001
- Username: minioadmin
- Password: minioadmin
- **Default Bucket**: worklenz-bucket (created automatically when the containers start)
### Backend Storage Configuration
The backend is pre-configured to use MinIO with the following settings:
```javascript
// S3 credentials with MinIO defaults
export const REGION = process.env.AWS_REGION || "us-east-1";
export const BUCKET = process.env.AWS_BUCKET || "worklenz-bucket";
export const S3_URL = process.env.S3_URL || "http://minio:9000/worklenz-bucket";
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "minioadmin";
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "minioadmin";
```
The S3 client is initialized with special MinIO configuration:
```javascript
const s3Client = new S3Client({
region: REGION,
credentials: {
accessKeyId: S3_ACCESS_KEY_ID || "",
secretAccessKey: S3_SECRET_ACCESS_KEY || "",
},
endpoint: getEndpointFromUrl(), // Extracts endpoint from S3_URL
forcePathStyle: true, // Required for MinIO
});
```
### Environment Configuration
The project uses the following environment file structure:
- **Frontend**:
- `worklenz-frontend/.env.development` - Development environment variables
- `worklenz-frontend/.env.production` - Production build variables
- **Backend**:
- `worklenz-backend/.env` - Backend environment variables
### Setting Up Environment Files
The Docker environment script will create or overwrite all environment files:
```bash
# For HTTP/WS
./update-docker-env.sh your-hostname
# For HTTPS/WSS
./update-docker-env.sh your-hostname true
```
This script generates properly configured environment files for both development and production environments.
## Docker Deployment
### Local Development with Docker
1. Set up the environment files:
```bash
# For HTTP/WS
./update-docker-env.sh
# For HTTPS/WSS
./update-docker-env.sh localhost true
```
2. Run the application using Docker Compose:
```bash
docker-compose up -d
```
3. Access the application:
- Frontend: http://localhost:5000
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
4. Video Guide
For a visual walkthrough of the local Docker deployment process, check out our [step-by-step video guide](https://www.youtube.com/watch?v=AfwAKxJbqLg).
### Remote Server Deployment
When deploying to a remote server:
1. Set up the environment files with your server's hostname:
```bash
# For HTTP/WS
./update-docker-env.sh your-server-hostname
# For HTTPS/WSS
./update-docker-env.sh your-server-hostname true
```
This ensures that the frontend correctly connects to the backend API.
2. Pull and run the latest Docker images:
```bash
docker-compose pull
docker-compose up -d
```
3. Access the application through your server's hostname:
- Frontend: http://your-server-hostname:5000
- Backend API: http://your-server-hostname:3000
4. Video Guide
For a complete walkthrough of deploying Worklenz to a remote server, check out our [deployment video guide](https://www.youtube.com/watch?v=CAZGu2iOXQs&t=10s).
### Environment Configuration
The Docker setup uses environment variables to configure the services:
- Frontend:
- `VITE_API_URL`: URL of the backend API (default: http://backend:3000 for container networking)
- `VITE_SOCKET_URL`: WebSocket URL for real-time communication (default: ws://backend:3000)
- Backend:
- Database connection parameters
- Storage configuration
- Other backend settings
For custom configuration, edit the `.env` file or the `update-docker-env.sh` script.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,19 +16,23 @@ export default class TaskPhasesController extends WorklenzControllerBase {
if (!req.query.id) if (!req.query.id)
return res.status(400).send(new ServerResponse(false, null, "Invalid request")); 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 = ` const q = `
INSERT INTO project_phases (name, color_code, project_id, sort_index) INSERT INTO project_phases (name, color_code, project_id, sort_index)
VALUES ( VALUES (
CONCAT('Untitled Phase (', (SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1, ')'),
$1, $1,
$2, $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; RETURNING id, name, color_code, sort_index;
`; `;
req.body.color_code = this.DEFAULT_PHASE_COLOR; 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; const [data] = result.rows;
data.color_code = getColor(data.name) + TASK_STATUS_COLOR_ALPHA; data.color_code = getColor(data.name) + TASK_STATUS_COLOR_ALPHA;

View File

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

View File

@@ -24,6 +24,14 @@ interface ChangeRequest {
priority: string; priority: string;
}; };
team_id: 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 { interface Config {
@@ -64,38 +72,72 @@ function updateUnmappedStatus(config: Config) {
export async function on_task_sort_order_change(_io: Server, socket: Socket, data: ChangeRequest) { export async function on_task_sort_order_change(_io: Server, socket: Socket, data: ChangeRequest) {
try { try {
const q = `SELECT handle_task_list_sort_order_change($1);`; // New simplified approach - use bulk updates if provided
if (data.task_updates && data.task_updates.length > 0) {
const config: Config = { // Check dependencies for status changes
from_index: data.from_index, if (data.group_by === GroupBy.STATUS && data.to_group) {
to_index: data.to_index, const canContinue = await TasksControllerV2.checkForCompletedDependencies(data.task.id, data.to_group);
task_id: data.task.id, if (!canContinue) {
from_group: data.from_group, return socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
to_group: data.to_group, completed_deps: canContinue
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); // 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) { // Common post-processing logic for both approaches
updateUnmappedStatus(config); if (data.group_by === GroupBy.STATUS) {
}
await db.query(q, [JSON.stringify(config)]);
await emitSortOrderChange(data, socket);
if (config.group_by === GroupBy.STATUS) {
const userId = getLoggedInUserIdFromSocket(socket); const userId = getLoggedInUserIdFromSocket(socket);
const isAlreadyAssigned = await TasksControllerV2.checkUserAssignedToTask(data.task.id, userId as string, data.team_id); 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({ void logPhaseChange({
task_id: data.task.id, task_id: data.task.id,
socket, 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({ void logStatusChange({
task_id: data.task.id, task_id: data.task.id,
socket, 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({ void logPriorityChange({
task_id: data.task.id, task_id: data.task.id,
socket, 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; return;
} catch (error) { } catch (error) {
log_error(error); log_error(error);

View File

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

View File

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

View File

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

View File

@@ -68,9 +68,10 @@
"clearing": "Po pastron...", "clearing": "Po pastron...",
"cancel": "Anulo", "cancel": "Anulo",
"search": "Kërko", "search": "Kërko",
"groupedBy": "I grupuar sipas", "groupedBy": "Grupuar sipas",
"manageStatuses": "Menaxho statuset", "manage": "Menaxho",
"managePhases": "Menaxho fazat", "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.", "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...", "enterNewStatusName": "Shkruani emrin e statusit të ri...",
"addStatus": "Shto status", "addStatus": "Shto status",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,8 +69,9 @@
"cancel": "Cancelar", "cancel": "Cancelar",
"search": "Buscar", "search": "Buscar",
"groupedBy": "Agrupado por", "groupedBy": "Agrupado por",
"manageStatuses": "Gestionar estados", "manage": "Gestionar",
"managePhases": "Gestionar fases", "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.", "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...", "enterNewStatusName": "Ingrese el nombre del nuevo estado...",
"addStatus": "Agregar estado", "addStatus": "Agregar estado",

View File

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

View File

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

View File

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

View File

@@ -69,8 +69,9 @@
"cancel": "Cancelar", "cancel": "Cancelar",
"search": "Pesquisar", "search": "Pesquisar",
"groupedBy": "Agrupado por", "groupedBy": "Agrupado por",
"manageStatuses": "Gerenciar status", "manage": "Gerenciar",
"managePhases": "Gerenciar fases", "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.", "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...", "enterNewStatusName": "Digite o nome do novo status...",
"addStatus": "Adicionar status", "addStatus": "Adicionar status",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,8 +73,17 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = ({
if (tasks.length === 0) { if (tasks.length === 0) {
return ( return (
<div className="virtualized-empty-state" style={{ height }}> <div className="virtualized-empty-state" style={{ height, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="empty-message">No tasks in this group</div> <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> </div>
); );
} }

View File

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

View File

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

View File

@@ -3,12 +3,13 @@ import { useDroppable } from '@dnd-kit/core';
// @ts-ignore: Heroicons module types // @ts-ignore: Heroicons module types
import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline'; import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd'; import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd';
import GroupProgressBar from './GroupProgressBar';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getContrastColor } from '@/utils/colorUtils'; import { getContrastColor } from '@/utils/colorUtils';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import { selectSelectedTaskIds, selectTask, deselectTask } from '@/features/task-management/selection.slice'; 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 { selectCurrentGrouping } from '@/features/task-management/grouping.slice';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
@@ -27,6 +28,10 @@ interface TaskGroupHeaderProps {
name: string; name: string;
count: number; count: number;
color?: string; // Color for the group indicator color?: string; // Color for the group indicator
todo_progress?: number;
doing_progress?: number;
done_progress?: number;
groupType?: string;
}; };
isCollapsed: boolean; isCollapsed: boolean;
onToggle: () => void; onToggle: () => void;
@@ -38,13 +43,14 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const selectedTaskIds = useAppSelector(selectSelectedTaskIds); const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
const groups = useAppSelector(selectGroups); const groups = useAppSelector(selectGroups);
const allTasks = useAppSelector(selectAllTasksArray);
const currentGrouping = useAppSelector(selectCurrentGrouping); const currentGrouping = useAppSelector(selectCurrentGrouping);
const { statusCategories } = useAppSelector(state => state.taskStatusReducer); const { statusCategories, status: statusList } = useAppSelector(state => state.taskStatusReducer);
const { trackMixpanelEvent } = useMixpanelTracking(); const { trackMixpanelEvent } = useMixpanelTracking();
const { isOwnerOrAdmin } = useAuthService(); const { isOwnerOrAdmin } = useAuthService();
const [dropdownVisible, setDropdownVisible] = useState(false); const [dropdownVisible, setDropdownVisible] = useState(false);
const [categoryModalVisible, setCategoryModalVisible] = useState(false);
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const [isChangingCategory, setIsChangingCategory] = useState(false); const [isChangingCategory, setIsChangingCategory] = useState(false);
const [isEditingName, setIsEditingName] = useState(false); const [isEditingName, setIsEditingName] = useState(false);
@@ -62,6 +68,74 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
return currentGroup?.taskIds || []; return currentGroup?.taskIds || [];
}, [currentGroup]); }, [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 // Calculate selection state for this group
const { isAllSelected, isPartiallySelected } = useMemo(() => { const { isAllSelected, isPartiallySelected } = useMemo(() => {
if (tasksInGroup.length === 0) { if (tasksInGroup.length === 0) {
@@ -94,7 +168,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Handle inline name editing // Handle inline name editing
const handleNameSave = useCallback(async () => { 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); setIsRenaming(true);
try { try {
@@ -122,12 +201,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Refresh task list to get updated group names // Refresh task list to get updated group names
dispatch(fetchTasksV3(projectId)); dispatch(fetchTasksV3(projectId));
setIsEditingName(false);
} catch (error) { } catch (error) {
logger.error('Error renaming group:', error); logger.error('Error renaming group:', error);
setEditingName(group.name); setEditingName(group.name);
} finally { } finally {
setIsEditingName(false);
setIsRenaming(false); setIsRenaming(false);
} }
}, [editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming]); }, [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]); }, [group.name, handleNameSave]);
const handleNameBlur = useCallback(() => { const handleNameBlur = useCallback(() => {
setIsEditingName(false); handleNameSave();
setEditingName(group.name); }, [handleNameSave]);
}, [group.name]);
// Handle dropdown menu actions // Handle dropdown menu actions
const handleRenameGroup = useCallback(() => { const handleRenameGroup = useCallback(() => {
@@ -161,10 +239,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
setEditingName(group.name); setEditingName(group.name);
}, [group.name]); }, [group.name]);
const handleChangeCategory = useCallback(() => {
setDropdownVisible(false);
setCategoryModalVisible(true);
}, []);
// Handle category change // Handle category change
const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => { 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 // Refresh status list and tasks
dispatch(fetchStatuses(projectId)); dispatch(fetchStatuses(projectId));
dispatch(fetchTasksV3(projectId)); dispatch(fetchTasksV3(projectId));
setCategoryModalVisible(false);
} catch (error) { } catch (error) {
logger.error('Error changing category:', 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 // Only show "Change Category" when grouped by status
if (currentGrouping === 'status') { if (currentGrouping === 'status') {
items.push({ const categorySubMenuItems = statusCategories.map((category) => ({
key: 'changeCategory', key: `category-${category.id}`,
icon: <ArrowPathIcon className="h-4 w-4" />, label: (
label: t('changeCategory'), <div className="flex items-center gap-2">
<Badge color={category.color_code} />
<span>{category.name}</span>
</div>
),
onClick: (e: any) => { onClick: (e: any) => {
e?.domEvent?.stopPropagation(); 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; return items;
}, [currentGrouping, handleRenameGroup, handleChangeCategory, isOwnerOrAdmin]); }, [currentGrouping, handleRenameGroup, handleCategoryChange, isOwnerOrAdmin, statusCategories, t]);
// Make the group header droppable // Make the group header droppable
const { isOver, setNodeRef } = useDroppable({ const { isOver, setNodeRef } = useDroppable({
@@ -232,75 +317,146 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
}, },
}); });
return ( return (
<div <div className="relative flex items-center">
ref={setNodeRef} <div
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 ${ ref={setNodeRef}
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : '' 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, style={{
color: headerTextColor, backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
position: 'sticky', color: headerTextColor,
top: 0, position: 'sticky',
zIndex: 25, // Higher than task rows but lower than column headers (z-30) top: 0,
height: '36px', zIndex: 25, // Higher than task rows but lower than column headers (z-30)
minHeight: '36px', height: '36px',
maxHeight: '36px' minHeight: '36px',
}} maxHeight: '36px'
onClick={onToggle} }}
> onClick={onToggle}
{/* Drag Handle Space - ultra minimal width */} >
<div style={{ width: '20px' }} className="flex items-center justify-center"> {/* Drag Handle Space - ultra minimal width */}
{/* Chevron button */} <div style={{ width: '20px' }} className="flex items-center justify-center">
<button {/* Chevron button */}
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out" <button
style={{ backgroundColor: 'transparent', color: headerTextColor }} className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
onClick={(e) => { style={{ backgroundColor: 'transparent', color: headerTextColor }}
e.stopPropagation(); onClick={(e) => {
onToggle(); e.stopPropagation();
}} onToggle();
>
<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
</div> className="transition-transform duration-300 ease-out"
</button> style={{
</div> 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 */} {/* Select All Checkbox Space - ultra minimal width */}
<div style={{ width: '28px' }} className="flex items-center justify-center"> <div style={{ width: '28px' }} className="flex items-center justify-center">
<Checkbox <Checkbox
checked={isAllSelected} checked={isAllSelected}
indeterminate={isPartiallySelected} indeterminate={isPartiallySelected}
onChange={handleSelectAllChange} onChange={handleSelectAllChange}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
color: headerTextColor, color: headerTextColor,
}} }}
/> />
</div> </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"> <div className="flex items-center flex-1 ml-1">
{/* Group name and count */} {/* Group name and count */}
<div className="flex items-center"> <div className="flex items-center">
<span {isEditingName ? (
className="text-sm font-semibold pr-2" <Input
style={{ color: headerTextColor }} value={editingName}
> onChange={(e) => setEditingName(e.target.value)}
{group.name} onKeyDown={handleNameKeyDown}
</span> 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 }}> <span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}>
({group.count}) ({group.count})
</span> </span>
</div> </div>
</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> </div>
); );
}; };

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,112 @@
border-top: 1px solid #303030; 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 { .dark-modal .ant-form-item-label > label {
color: #d9d9d9; color: #d9d9d9;
} }

View File

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

View File

@@ -20,6 +20,112 @@
border-top: 1px solid #303030; 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 { .dark-modal .ant-form-item-label > label {
color: #d9d9d9; color: #d9d9d9;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ export const GROUP_BY_OPTIONS: IGroupByOption[] = [
{ label: 'Phase', value: IGroupBy.PHASE }, { 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 => { export const getCurrentGroup = (): IGroupBy => {
const key = localStorage.getItem(LOCALSTORAGE_GROUP_KEY); const key = localStorage.getItem(LOCALSTORAGE_GROUP_KEY);

View File

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

View File

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

View File

@@ -17,8 +17,36 @@ interface LocalGroupingState {
collapsedGroups: string[]; 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 = { const initialState: LocalGroupingState = {
currentGrouping: null, currentGrouping: loadGroupingFromLocalStorage(),
customPhases: ['Planning', 'Development', 'Testing', 'Deployment'], customPhases: ['Planning', 'Development', 'Testing', 'Deployment'],
groupOrder: { groupOrder: {
status: ['todo', 'doing', 'done'], status: ['todo', 'doing', 'done'],
@@ -35,6 +63,7 @@ const groupingSlice = createSlice({
reducers: { reducers: {
setCurrentGrouping: (state, action: PayloadAction<GroupingType | null>) => { setCurrentGrouping: (state, action: PayloadAction<GroupingType | null>) => {
state.currentGrouping = action.payload; state.currentGrouping = action.payload;
saveGroupingToLocalStorage(action.payload);
}, },
addCustomPhase: (state, action: PayloadAction<string>) => { addCustomPhase: (state, action: PayloadAction<string>) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -208,6 +208,18 @@ const TaskListTableWrapper = ({
> >
<Flex vertical> <Flex vertical>
<Flex style={{ transform: 'translateY(6px)' }}> <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 <Button
className="custom-collapse-button" className="custom-collapse-button"
style={{ style={{
@@ -243,18 +255,6 @@ const TaskListTableWrapper = ({
</Typography.Text> </Typography.Text>
)} )}
</Button> </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> </Flex>
<Collapsible <Collapsible
isOpen={isExpanded} isOpen={isExpanded}

View File

@@ -944,18 +944,7 @@ const SelectionFieldCell: React.FC<{
columnKey: string; columnKey: string;
updateValue: (taskId: string, columnKey: string, value: string) => void; updateValue: (taskId: string, columnKey: string, value: string) => void;
}> = ({ selectionsList, value, task, columnKey, updateValue }) => { }> = ({ 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 ( return (
<CustomColumnSelectionCell <CustomColumnSelectionCell
@@ -1256,19 +1245,6 @@ const renderCustomColumnContent = (
); );
}, },
selection: () => { 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 ( return (
<SelectionFieldCell <SelectionFieldCell
selectionsList={columnObj?.selectionsList || []} selectionsList={columnObj?.selectionsList || []}
@@ -1650,35 +1626,12 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
const activeTask = displayTasks.find(task => task.id === active.id); const activeTask = displayTasks.find(task => task.id === active.id);
if (!activeTask) { if (!activeTask) {
console.error('Active task not found:', {
activeId: active.id,
displayTasks: displayTasks.map(t => ({ id: t.id, name: t.name })),
});
return; 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) // Use the tableId directly as the group ID (it should be the group ID)
const currentGroupId = tableId; 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 // Check if this is a reorder within the same group
const overTask = displayTasks.find(task => task.id === over.id); const overTask = displayTasks.find(task => task.id === over.id);
if (overTask) { if (overTask) {
@@ -1686,36 +1639,17 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
const oldIndex = displayTasks.findIndex(task => task.id === active.id); const oldIndex = displayTasks.findIndex(task => task.id === active.id);
const newIndex = displayTasks.findIndex(task => task.id === over.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) { if (oldIndex !== newIndex && oldIndex !== -1 && newIndex !== -1) {
// Get the actual sort_order values from the tasks // Get the actual sort_order values from the tasks
const fromSortOrder = activeTask.sort_order || oldIndex; const fromSortOrder = activeTask.sort_order || oldIndex;
const overTaskAtNewIndex = displayTasks[newIndex]; const overTaskAtNewIndex = displayTasks[newIndex];
const toSortOrder = overTaskAtNewIndex?.sort_order || 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 // Create updated task list with reordered tasks
const updatedTasks = [...displayTasks]; const updatedTasks = [...displayTasks];
const [movedTask] = updatedTasks.splice(oldIndex, 1); const [movedTask] = updatedTasks.splice(oldIndex, 1);
updatedTasks.splice(newIndex, 0, movedTask); 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 // Update local state immediately for better UX
dispatch( dispatch(
reorderTasks({ reorderTasks({
@@ -1758,34 +1692,10 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
// Validate required fields before sending // Validate required fields before sending
if (!body.task.id) { if (!body.task.id) {
console.error('Cannot send socket event: task.id is missing', { activeTask, active });
return; 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); socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
} else {
console.error('Cannot send socket event: missing required data', {
hasSocket: !!socket,
hasProjectId: !!project?.id,
hasActiveId: !!active.id,
hasActiveTaskId: !!activeTask.id,
activeTask,
active,
});
} }
} }
} }

View File

@@ -4,6 +4,152 @@
width: 100%; 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 { .task-group {
transition: all 0.2s ease; transition: all 0.2s ease;
} }