Merge pull request #150 from Worklenz/feature/recurring-tasks

Feature/recurring tasks
This commit is contained in:
Chamika J
2025-06-06 12:09:03 +05:30
committed by GitHub
75 changed files with 5209 additions and 3253 deletions

View File

@@ -1,6 +1,6 @@
<h1 align="center">
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
<img src="https://app.worklenz.com/assets/icons/icon-144x144.png" alt="Worklenz Logo" width="75">
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/icon-144x144.png" alt="Worklenz Logo" width="75">
</a>
<br>
Worklenz
@@ -315,6 +315,7 @@ docker-compose up -d
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.
@@ -403,6 +404,10 @@ This script generates properly configured environment files for both development
- 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:
@@ -428,6 +433,10 @@ When deploying to a remote server:
- 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:

View File

@@ -0,0 +1,60 @@
# Recurring Tasks: User Guide
## What Are Recurring Tasks?
Recurring tasks are tasks that repeat automatically on a schedule you choose. This helps you save time and ensures important work is never forgotten. For example, you can set up a recurring task for weekly team meetings, monthly reports, or daily check-ins.
## Why Use Recurring Tasks?
- **Save time:** No need to create the same task over and over.
- **Stay organized:** Tasks appear automatically when needed.
- **Never miss a deadline:** Tasks are created on time, every time.
## How to Set Up a Recurring Task
1. Go to the tasks section in your workspace.
2. Choose to create a new task and look for the option to make it recurring.
3. Fill in the task details (name, description, assignees, etc.).
4. Select your preferred schedule (see options below).
5. Save the task. It will now be created automatically based on your chosen schedule.
## Schedule Options
You can choose how often your task repeats. Here are the available options:
- **Daily:** The task is created every day.
- **Weekly:** The task is created once a week. You can pick one or more days (e.g., every Monday and Thursday).
- **Monthly:** The task is created once a month. You have two options:
- **On a specific date:** Choose a date from 1 to 28 (limited to 28 to ensure consistency across all months)
- **On a specific day:** Choose a week (first, second, third, fourth, or last) and a day of the week
- **Every X Days:** The task is created every specified number of days (e.g., every 3 days)
- **Every X Weeks:** The task is created every specified number of weeks (e.g., every 2 weeks)
- **Every X Months:** The task is created every specified number of months (e.g., every 3 months)
### Examples
- "Send team update" every Friday (weekly)
- "Submit expense report" on the 15th of each month (monthly, specific date)
- "Monthly team meeting" on the first Monday of each month (monthly, specific day)
- "Check backups" every day (daily)
- "Review project status" every Monday and Thursday (weekly, multiple days)
- "Quarterly report" every 3 months (every X months)
## Future Task Creation
The system automatically creates tasks up to a certain point in the future to ensure timely scheduling:
- **Daily Tasks:** Created up to 7 days in advance
- **Weekly Tasks:** Created up to 2 weeks in advance
- **Monthly Tasks:** Created up to 2 months in advance
- **Every X Days/Weeks/Months:** Created up to 2 intervals in advance
This ensures that:
- You always have upcoming tasks visible in your schedule
- Tasks are created at appropriate intervals
- The system maintains a reasonable number of future tasks
## Tips
- You can edit or stop a recurring task at any time.
- Assign team members and labels to recurring tasks for better organization.
- Check your task list regularly to see newly created recurring tasks.
- For monthly tasks, dates are limited to 1-28 to ensure the task occurs on the same date every month.
- Tasks are created automatically within the future limit window - you don't need to manually create them.
- If you need to see tasks further in the future, they will be created automatically as the current tasks are completed.
## Need Help?
If you have questions or need help setting up recurring tasks, contact your workspace admin or support team.

104
docs/recurring-tasks.md Normal file
View File

@@ -0,0 +1,104 @@
# Recurring Tasks Cron Job Documentation
## Overview
The recurring tasks cron job automates the creation of tasks based on predefined templates and schedules. It ensures that tasks are generated at the correct intervals without manual intervention, supporting efficient project management and timely task assignment.
## Purpose
- Automatically create tasks according to recurring schedules defined in the database.
- Prevent duplicate task creation for the same schedule and date.
- Assign team members and labels to newly created tasks as specified in the template.
## Scheduling Logic
- The cron job is scheduled using the [cron](https://www.npmjs.com/package/cron) package.
- The schedule is defined by a cron expression (e.g., `*/2 * * * *` for every 2 minutes, or `0 11 */1 * 1-5` for 11:00 UTC on weekdays).
- On each tick, the job:
1. Fetches all recurring task templates and their schedules.
2. Determines the next occurrence for each template using `calculateNextEndDate`.
3. Checks if a task for the next occurrence already exists.
4. Creates a new task if it does not exist and the next occurrence is within the allowed future window.
## Future Limit Logic
The system implements different future limits based on the schedule type to maintain an appropriate number of future tasks:
```typescript
const FUTURE_LIMITS = {
daily: moment.duration(7, 'days'),
weekly: moment.duration(2, 'weeks'),
monthly: moment.duration(2, 'months'),
every_x_days: (interval: number) => moment.duration(interval * 2, 'days'),
every_x_weeks: (interval: number) => moment.duration(interval * 2, 'weeks'),
every_x_months: (interval: number) => moment.duration(interval * 2, 'months')
};
```
### Implementation Details
- **Base Calculation:**
```typescript
const futureLimit = moment(template.last_checked_at || template.created_at)
.add(getFutureLimit(schedule.schedule_type, schedule.interval), 'days');
```
- **Task Creation Rules:**
1. Only create tasks if the next occurrence is before the future limit
2. Skip creation if a task already exists for that date
3. Update `last_checked_at` after processing
- **Benefits:**
- Prevents excessive task creation
- Maintains system performance
- Ensures timely task visibility
- Allows for schedule modifications
## Date Handling
- **Monthly Tasks:**
- Dates are limited to 1-28 to ensure consistency across all months
- This prevents issues with months having different numbers of days
- No special handling needed for February or months with 30/31 days
- **Weekly Tasks:**
- Supports multiple days of the week (0-6, where 0 is Sunday)
- Tasks are created for each selected day
- **Interval-based Tasks:**
- Every X days/weeks/months from the last task's end date
- Minimum interval is 1 day/week/month
- No maximum limit, but tasks are only created up to the future limit
## Database Interactions
- **Templates and Schedules:**
- Templates are stored in `task_recurring_templates`.
- Schedules are stored in `task_recurring_schedules`.
- The job joins these tables to get all necessary data for task creation.
- **Task Creation:**
- Uses a stored procedure `create_quick_task` to insert new tasks.
- Assigns team members and labels by calling appropriate functions/controllers.
- **State Tracking:**
- Updates `last_checked_at` and `last_created_task_end_date` in the schedule after processing.
- Maintains future limits based on schedule type.
## Task Creation Process
1. **Fetch Templates:** Retrieve all templates and their associated schedules.
2. **Determine Next Occurrence:** Use the last task's end date or the schedule's creation date to calculate the next due date.
3. **Check for Existing Task:** Ensure no duplicate task is created for the same schedule and date.
4. **Create Task:**
- Insert the new task using the template's data.
- Assign team members and labels as specified.
5. **Update Schedule:** Record the last checked and created dates for accurate future runs.
## Configuration & Extension Points
- **Cron Expression:** Modify the `TIME` constant in the code to change the schedule.
- **Task Template Structure:** Extend the template or schedule interfaces to support additional fields.
- **Task Creation Logic:** Customize the task creation process or add new assignment/labeling logic as needed.
- **Future Window:** Adjust the future limits by modifying the `FUTURE_LIMITS` configuration.
## Error Handling
- Errors are logged using the `log_error` utility.
- The job continues processing other templates even if one fails.
- Failed task creations are not retried automatically.
## References
- Source: `src/cron_jobs/recurring-tasks.ts`
- Utilities: `src/shared/utils.ts`
- Database: `src/config/db.ts`
- Controllers: `src/controllers/tasks-controller.ts`
---
For further customization or troubleshooting, refer to the source code and update the documentation as needed.

View File

@@ -73,8 +73,8 @@ cat > worklenz-backend/.env << EOL
NODE_ENV=production
PORT=3000
SESSION_NAME=worklenz.sid
SESSION_SECRET=change_me_in_production
COOKIE_SECRET=change_me_in_production
SESSION_SECRET=$(openssl rand -base64 48)
COOKIE_SECRET=$(openssl rand -base64 48)
# CORS
SOCKET_IO_CORS=${FRONTEND_URL}
@@ -123,7 +123,7 @@ SLACK_WEBHOOK=
COMMIT_BUILD_IMMEDIATELY=true
# JWT Secret
JWT_SECRET=change_me_in_production
JWT_SECRET=$(openssl rand -base64 48)
EOL
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")
@@ -138,4 +138,4 @@ echo "Frontend URL: ${FRONTEND_URL}"
echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000"
echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000"
echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}"
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"

View File

@@ -78,4 +78,8 @@ GOOGLE_CAPTCHA_SECRET_KEY=your_captcha_secret_key
GOOGLE_CAPTCHA_PASS_SCORE=0.8
# Email Cronjobs
ENABLE_EMAIL_CRONJOBS=true
ENABLE_EMAIL_CRONJOBS=true
# RECURRING_JOBS
ENABLE_RECURRING_JOBS=true
RECURRING_JOBS_INTERVAL="0 11 */1 * 1-5"

View File

@@ -1,131 +0,0 @@
module.exports = function (grunt) {
// Project configuration.
grunt.initConfig({
pkg: grunt.file.readJSON("package.json"),
clean: {
dist: "build"
},
compress: require("./grunt/grunt-compress"),
copy: {
main: {
files: [
{expand: true, cwd: "src", src: ["public/**"], dest: "build"},
{expand: true, cwd: "src", src: ["views/**"], dest: "build"},
{expand: true, cwd: "landing-page-assets", src: ["**"], dest: "build/public/assets"},
{expand: true, cwd: "src", src: ["shared/sample-data.json"], dest: "build", filter: "isFile"},
{expand: true, cwd: "src", src: ["shared/templates/**"], dest: "build", filter: "isFile"},
{expand: true, cwd: "src", src: ["shared/postgresql-error-codes.json"], dest: "build", filter: "isFile"},
]
},
packages: {
files: [
{expand: true, cwd: "", src: [".env"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: [".gitignore"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["release"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["jest.config.js"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["package.json"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["package-lock.json"], dest: "build", filter: "isFile"},
{expand: true, cwd: "", src: ["common_modules/**"], dest: "build"}
]
}
},
sync: {
main: {
files: [
{cwd: "src", src: ["views/**", "public/**"], dest: "build/"}, // makes all src relative to cwd
],
verbose: true,
failOnError: true,
compareUsing: "md5"
}
},
uglify: {
all: {
files: [{
expand: true,
cwd: "build",
src: "**/*.js",
dest: "build"
}]
},
controllers: {
files: [{
expand: true,
cwd: "build",
src: "controllers/*.js",
dest: "build"
}]
},
routes: {
files: [{
expand: true,
cwd: "build",
src: "routes/**/*.js",
dest: "build"
}]
},
assets: {
files: [{
expand: true,
cwd: "build",
src: "public/assets/**/*.js",
dest: "build"
}]
}
},
shell: {
tsc: {
command: "tsc --build tsconfig.prod.json"
},
esbuild: {
// command: "esbuild `find src -type f -name '*.ts'` --platform=node --minify=false --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=build"
command: "node esbuild && node cli/esbuild-patch"
},
tsc_dev: {
command: "tsc --build tsconfig.json"
},
swagger: {
command: "node ./cli/swagger"
},
inline_queries: {
command: "node ./cli/inline-queries"
}
},
watch: {
scripts: {
files: ["src/**/*.ts"],
tasks: ["shell:tsc_dev"],
options: {
debounceDelay: 250,
spawn: false,
}
},
other: {
files: ["src/**/*.pug", "landing-page-assets/**"],
tasks: ["sync"]
}
}
});
grunt.registerTask("clean", ["clean"]);
grunt.registerTask("copy", ["copy:main"]);
grunt.registerTask("swagger", ["shell:swagger"]);
grunt.registerTask("build:tsc", ["shell:tsc"]);
grunt.registerTask("build", ["clean", "shell:tsc", "copy:main", "compress"]);
grunt.registerTask("build:es", ["clean", "shell:esbuild", "copy:main", "uglify:assets", "compress"]);
grunt.registerTask("build:strict", ["clean", "shell:tsc", "copy:packages", "uglify:all", "copy:main", "compress"]);
grunt.registerTask("dev", ["clean", "copy:main", "shell:tsc_dev", "shell:inline_queries", "watch"]);
// Load the plugin that provides the "uglify" task.
grunt.loadNpmTasks("grunt-contrib-watch");
grunt.loadNpmTasks("grunt-contrib-clean");
grunt.loadNpmTasks("grunt-contrib-copy");
grunt.loadNpmTasks("grunt-contrib-uglify");
grunt.loadNpmTasks("grunt-contrib-compress");
grunt.loadNpmTasks("grunt-shell");
grunt.loadNpmTasks("grunt-sync");
// Default task(s).
grunt.registerTask("default", []);
};

View File

@@ -0,0 +1,160 @@
-- Migration: Fix progress_mode_type ENUM and casting issues
-- Date: 2025-04-27
-- Version: 1.0.0
BEGIN;
-- First, let's ensure the ENUM type exists with the correct values
DO $$
BEGIN
-- Check if the type exists
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'progress_mode_type') THEN
CREATE TYPE progress_mode_type AS ENUM ('manual', 'weighted', 'time', 'default');
ELSE
-- Add any missing values to the existing ENUM
BEGIN
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'manual';
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'weighted';
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'time';
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'default';
EXCEPTION
WHEN duplicate_object THEN
-- Ignore if values already exist
NULL;
END;
END IF;
END $$;
-- Update functions to use proper type casting
CREATE OR REPLACE FUNCTION on_update_task_progress(_body json) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_task_id UUID;
_progress_value INTEGER;
_parent_task_id UUID;
_project_id UUID;
_current_mode progress_mode_type;
BEGIN
_task_id = (_body ->> 'task_id')::UUID;
_progress_value = (_body ->> 'progress_value')::INTEGER;
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
-- Get the project ID and determine the current progress mode
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
IF _project_id IS NOT NULL THEN
SELECT
CASE
WHEN use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
WHEN use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
WHEN use_time_progress IS TRUE THEN 'time'::progress_mode_type
ELSE 'default'::progress_mode_type
END
INTO _current_mode
FROM projects
WHERE id = _project_id;
ELSE
_current_mode := 'default'::progress_mode_type;
END IF;
-- Update the task with progress value and set the progress mode
UPDATE tasks
SET progress_value = _progress_value,
manual_progress = TRUE,
progress_mode = _current_mode,
updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id;
-- Return the updated task info
RETURN JSON_BUILD_OBJECT(
'task_id', _task_id,
'progress_value', _progress_value,
'progress_mode', _current_mode
);
END;
$$;
-- Update the on_update_task_weight function to use proper type casting
CREATE OR REPLACE FUNCTION on_update_task_weight(_body json) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_task_id UUID;
_weight INTEGER;
_parent_task_id UUID;
_project_id UUID;
BEGIN
_task_id = (_body ->> 'task_id')::UUID;
_weight = (_body ->> 'weight')::INTEGER;
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
-- Get the project ID
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
-- Update the task with weight value and set progress_mode to 'weighted'
UPDATE tasks
SET weight = _weight,
progress_mode = 'weighted'::progress_mode_type,
updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id;
-- Return the updated task info
RETURN JSON_BUILD_OBJECT(
'task_id', _task_id,
'weight', _weight
);
END;
$$;
-- Update the reset_project_progress_values function to use proper type casting
CREATE OR REPLACE FUNCTION reset_project_progress_values() RETURNS TRIGGER
LANGUAGE plpgsql
AS
$$
DECLARE
_old_mode progress_mode_type;
_new_mode progress_mode_type;
_project_id UUID;
BEGIN
_project_id := NEW.id;
-- Determine old and new modes with proper type casting
_old_mode :=
CASE
WHEN OLD.use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
WHEN OLD.use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
WHEN OLD.use_time_progress IS TRUE THEN 'time'::progress_mode_type
ELSE 'default'::progress_mode_type
END;
_new_mode :=
CASE
WHEN NEW.use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
WHEN NEW.use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
WHEN NEW.use_time_progress IS TRUE THEN 'time'::progress_mode_type
ELSE 'default'::progress_mode_type
END;
-- If mode has changed, reset progress values for tasks with the old mode
IF _old_mode <> _new_mode THEN
-- Reset progress values for tasks that were set in the old mode
UPDATE tasks
SET progress_value = NULL,
progress_mode = NULL
WHERE project_id = _project_id
AND progress_mode = _old_mode;
END IF;
RETURN NEW;
END;
$$;
-- Update the tasks table to ensure proper type casting for existing values
UPDATE tasks
SET progress_mode = progress_mode::text::progress_mode_type
WHERE progress_mode IS NOT NULL;
COMMIT;

View File

@@ -23,33 +23,40 @@ ALTER TABLE projects
ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE;
-- Update function to consider manual progress
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id UUID) RETURNS JSON
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_parent_task_done FLOAT = 0;
_sub_tasks_done FLOAT = 0;
_sub_tasks_count FLOAT = 0;
_total_completed FLOAT = 0;
_total_tasks FLOAT = 0;
_ratio FLOAT = 0;
_is_manual BOOLEAN = FALSE;
_manual_value INTEGER = NULL;
_project_id UUID;
_use_manual_progress BOOLEAN = FALSE;
_parent_task_done FLOAT = 0;
_sub_tasks_done FLOAT = 0;
_sub_tasks_count FLOAT = 0;
_total_completed FLOAT = 0;
_total_tasks FLOAT = 0;
_ratio FLOAT = 0;
_is_manual BOOLEAN = FALSE;
_manual_value INTEGER = NULL;
_project_id UUID;
_use_manual_progress BOOLEAN = FALSE;
_use_weighted_progress BOOLEAN = FALSE;
_use_time_progress BOOLEAN = FALSE;
_use_time_progress BOOLEAN = FALSE;
_task_complete BOOLEAN = FALSE;
_progress_mode VARCHAR(20) = NULL;
BEGIN
-- Check if manual progress is set
SELECT manual_progress, progress_value, project_id
-- Check if manual progress is set for this task
SELECT manual_progress, progress_value, project_id, progress_mode,
EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = tasks.id
AND is_done IS TRUE
) AS is_complete
FROM tasks
WHERE id = _task_id
INTO _is_manual, _manual_value, _project_id;
INTO _is_manual, _manual_value, _project_id, _progress_mode, _task_complete;
-- Check if the project uses manual progress
IF _project_id IS NOT NULL
THEN
IF _project_id IS NOT NULL THEN
SELECT COALESCE(use_manual_progress, FALSE),
COALESCE(use_weighted_progress, FALSE),
COALESCE(use_time_progress, FALSE)
@@ -58,49 +65,212 @@ BEGIN
INTO _use_manual_progress, _use_weighted_progress, _use_time_progress;
END IF;
-- If manual progress is enabled and has a value, use it directly
IF _is_manual IS TRUE AND _manual_value IS NOT NULL
THEN
-- Get all subtasks
SELECT COUNT(*)
FROM tasks
WHERE parent_task_id = _task_id AND archived IS FALSE
INTO _sub_tasks_count;
-- If task is complete, always return 100%
IF _task_complete IS TRUE THEN
RETURN JSON_BUILD_OBJECT(
'ratio', 100,
'total_completed', 1,
'total_tasks', 1,
'is_manual', FALSE
);
END IF;
-- Determine current active mode
DECLARE
_current_mode VARCHAR(20) = CASE
WHEN _use_manual_progress IS TRUE THEN 'manual'
WHEN _use_weighted_progress IS TRUE THEN 'weighted'
WHEN _use_time_progress IS TRUE THEN 'time'
ELSE 'default'
END;
BEGIN
-- Only use manual progress value if it was set in the current active mode
-- and time progress is not enabled
IF _use_time_progress IS FALSE AND
((_is_manual IS TRUE AND _manual_value IS NOT NULL AND
(_progress_mode IS NULL OR _progress_mode = _current_mode)) OR
(_use_manual_progress IS TRUE AND _manual_value IS NOT NULL AND
(_progress_mode IS NULL OR _progress_mode = 'manual'))) THEN
RETURN JSON_BUILD_OBJECT(
'ratio', _manual_value,
'total_completed', 0,
'total_tasks', 0,
'is_manual', TRUE
);
);
END IF;
END;
-- If there are no subtasks, calculate based on the task itself
IF _sub_tasks_count = 0 THEN
-- Use time-based estimation if enabled
IF _use_time_progress IS TRUE THEN
-- Calculate progress based on logged time vs estimated time
WITH task_time_info AS (
SELECT
COALESCE(t.total_minutes, 0) as estimated_minutes,
COALESCE((
SELECT SUM(time_spent)
FROM task_work_log
WHERE task_id = t.id
), 0) as logged_minutes
FROM tasks t
WHERE t.id = _task_id
)
SELECT
CASE
WHEN _task_complete IS TRUE THEN 100
WHEN estimated_minutes > 0 THEN
LEAST((logged_minutes / estimated_minutes) * 100, 100)
ELSE 0
END
INTO _ratio
FROM task_time_info;
ELSE
-- Traditional calculation for non-time-based tasks
SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END)
INTO _parent_task_done;
_ratio = _parent_task_done * 100;
END IF;
ELSE
-- If project uses manual progress, calculate based on subtask manual progress values
IF _use_manual_progress IS TRUE AND _use_time_progress IS FALSE THEN
WITH subtask_progress AS (
SELECT
t.id,
t.manual_progress,
t.progress_value,
t.progress_mode,
EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) AS is_complete
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
),
subtask_with_values AS (
SELECT
CASE
WHEN is_complete IS TRUE THEN 100
WHEN progress_value IS NOT NULL AND (progress_mode = 'manual' OR progress_mode IS NULL) THEN progress_value
ELSE 0
END AS progress_value
FROM subtask_progress
)
SELECT COALESCE(AVG(progress_value), 0)
FROM subtask_with_values
INTO _ratio;
-- If project uses weighted progress, calculate based on subtask weights
ELSIF _use_weighted_progress IS TRUE AND _use_time_progress IS FALSE THEN
WITH subtask_progress AS (
SELECT
t.id,
t.manual_progress,
t.progress_value,
t.progress_mode,
EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) AS is_complete,
COALESCE(t.weight, 100) AS weight
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
),
subtask_with_values AS (
SELECT
CASE
WHEN is_complete IS TRUE THEN 100
WHEN progress_value IS NOT NULL AND (progress_mode = 'weighted' OR progress_mode IS NULL) THEN progress_value
ELSE 0
END AS progress_value,
weight
FROM subtask_progress
)
SELECT COALESCE(
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
0
)
FROM subtask_with_values
INTO _ratio;
-- If project uses time-based progress, calculate based on actual logged time
ELSIF _use_time_progress IS TRUE THEN
WITH task_time_info AS (
SELECT
t.id,
COALESCE(t.total_minutes, 0) as estimated_minutes,
COALESCE((
SELECT SUM(time_spent)
FROM task_work_log
WHERE task_id = t.id
), 0) as logged_minutes,
EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) AS is_complete
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(
CASE
WHEN is_complete IS TRUE THEN estimated_minutes
ELSE LEAST(logged_minutes, estimated_minutes)
END
) / NULLIF(SUM(estimated_minutes), 0) * 100,
0
)
FROM task_time_info
INTO _ratio;
ELSE
-- Traditional calculation based on completion status
SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END)
INTO _parent_task_done;
SELECT COUNT(*)
FROM tasks_with_status_view
WHERE parent_task_id = _task_id
AND is_done IS TRUE
INTO _sub_tasks_done;
_total_completed = _parent_task_done + _sub_tasks_done;
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
IF _total_tasks = 0 THEN
_ratio = 0;
ELSE
_ratio = (_total_completed / _total_tasks) * 100;
END IF;
END IF;
END IF;
-- Otherwise calculate automatically as before
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id AND archived IS FALSE INTO _sub_tasks_count;
SELECT COUNT(*)
FROM tasks_with_status_view
WHERE parent_task_id = _task_id
AND is_done IS TRUE
INTO _sub_tasks_done;
_total_completed = _parent_task_done + _sub_tasks_done;
_total_tasks = _sub_tasks_count; -- +1 for the parent task
IF _total_tasks > 0
THEN
_ratio = (_total_completed / _total_tasks) * 100;
ELSE
_ratio = _parent_task_done * 100;
-- Ensure ratio is between 0 and 100
IF _ratio < 0 THEN
_ratio = 0;
ELSIF _ratio > 100 THEN
_ratio = 100;
END IF;
RETURN JSON_BUILD_OBJECT(
'ratio', _ratio,
'total_completed', _total_completed,
'total_tasks', _total_tasks,
'is_manual', FALSE
);
'ratio', _ratio,
'total_completed', _total_completed,
'total_tasks', _total_tasks,
'is_manual', _is_manual
);
END
$$;
@@ -615,38 +785,38 @@ BEGIN
)
FROM subtask_with_values
INTO _ratio;
-- If project uses time-based progress, calculate based on estimated time
-- If project uses time-based progress, calculate based on actual logged time
ELSIF _use_time_progress IS TRUE
THEN
WITH subtask_progress AS (SELECT t.id,
t.manual_progress,
t.progress_value,
t.progress_mode,
EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE) AS is_complete,
COALESCE(t.total_minutes, 0) AS estimated_minutes
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE),
subtask_with_values AS (SELECT CASE
-- For completed tasks, always use 100%
WHEN is_complete IS TRUE THEN 100
-- For tasks with progress value set in the correct mode, use it
WHEN progress_value IS NOT NULL AND
(progress_mode = 'time' OR progress_mode IS NULL)
THEN progress_value
-- Default to 0 for incomplete tasks with no progress value or wrong mode
ELSE 0
END AS progress_value,
estimated_minutes
FROM subtask_progress)
WITH task_time_info AS (
SELECT
t.id,
COALESCE(t.total_minutes, 0) as estimated_minutes,
COALESCE((
SELECT SUM(time_spent)
FROM task_work_log
WHERE task_id = t.id
), 0) as logged_minutes,
EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) AS is_complete
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
0
)
FROM subtask_with_values
SUM(
CASE
WHEN is_complete IS TRUE THEN estimated_minutes
ELSE LEAST(logged_minutes, estimated_minutes)
END
) / NULLIF(SUM(estimated_minutes), 0) * 100,
0
)
FROM task_time_info
INTO _ratio;
ELSE
-- Traditional calculation based on completion status

View File

@@ -3351,15 +3351,15 @@ BEGIN
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
FROM (SELECT team_member_id,
project_member_id,
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id),
(SELECT email_notifications_enabled
COALESCE((SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id), '') as name,
COALESCE((SELECT email_notifications_enabled
FROM notification_settings
WHERE team_id = tm.team_id
AND notification_settings.user_id = u.id) AS email_notifications_enabled,
u.avatar_url,
AND notification_settings.user_id = u.id), false) AS email_notifications_enabled,
COALESCE(u.avatar_url, '') as avatar_url,
u.id AS user_id,
u.email,
u.socket_id AS socket_id,
COALESCE(u.email, '') as email,
COALESCE(u.socket_id, '') as socket_id,
tm.team_id AS team_id
FROM tasks_assignees
INNER JOIN team_members tm ON tm.id = tasks_assignees.team_member_id
@@ -4066,14 +4066,14 @@ DECLARE
_schedule_id JSON;
_task_completed_at TIMESTAMPTZ;
BEGIN
SELECT name FROM tasks WHERE id = _task_id INTO _task_name;
SELECT COALESCE(name, '') FROM tasks WHERE id = _task_id INTO _task_name;
SELECT name
SELECT COALESCE(name, '')
FROM task_statuses
WHERE id = (SELECT status_id FROM tasks WHERE id = _task_id)
INTO _previous_status_name;
SELECT name FROM task_statuses WHERE id = _status_id INTO _new_status_name;
SELECT COALESCE(name, '') FROM task_statuses WHERE id = _status_id INTO _new_status_name;
IF (_previous_status_name != _new_status_name)
THEN
@@ -4081,14 +4081,22 @@ BEGIN
SELECT get_task_complete_info(_task_id, _status_id) INTO _task_info;
SELECT name FROM users WHERE id = _user_id INTO _updater_name;
SELECT COALESCE(name, '') FROM users WHERE id = _user_id INTO _updater_name;
_message = CONCAT(_updater_name, ' transitioned "', _task_name, '" from ', _previous_status_name, '',
_new_status_name);
END IF;
SELECT completed_at FROM tasks WHERE id = _task_id INTO _task_completed_at;
SELECT schedule_id FROM tasks WHERE id = _task_id INTO _schedule_id;
-- Handle schedule_id properly for recurring tasks
SELECT CASE
WHEN schedule_id IS NULL THEN 'null'::json
ELSE json_build_object('id', schedule_id)
END
FROM tasks
WHERE id = _task_id
INTO _schedule_id;
SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON)
FROM (SELECT is_done, is_doing, is_todo
@@ -4097,7 +4105,7 @@ BEGIN
INTO _status_category;
RETURN JSON_BUILD_OBJECT(
'message', _message,
'message', COALESCE(_message, ''),
'project_id', (SELECT project_id FROM tasks WHERE id = _task_id),
'parent_done', (CASE
WHEN EXISTS(SELECT 1
@@ -4105,14 +4113,14 @@ BEGIN
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END),
'color_code', (_task_info ->> 'color_code')::TEXT,
'color_code_dark', (_task_info ->> 'color_code_dark')::TEXT,
'total_tasks', (_task_info ->> 'total_tasks')::INT,
'total_completed', (_task_info ->> 'total_completed')::INT,
'members', (_task_info ->> 'members')::JSON,
'color_code', COALESCE((_task_info ->> 'color_code')::TEXT, ''),
'color_code_dark', COALESCE((_task_info ->> 'color_code_dark')::TEXT, ''),
'total_tasks', COALESCE((_task_info ->> 'total_tasks')::INT, 0),
'total_completed', COALESCE((_task_info ->> 'total_completed')::INT, 0),
'members', COALESCE((_task_info ->> 'members')::JSON, '[]'::JSON),
'completed_at', _task_completed_at,
'status_category', _status_category,
'schedule_id', _schedule_id
'status_category', COALESCE(_status_category, '{}'::JSON),
'schedule_id', COALESCE(_schedule_id, 'null'::JSON)
);
END
$$;
@@ -6148,3 +6156,219 @@ BEGIN
RETURN v_new_id;
END;
$$;
CREATE OR REPLACE FUNCTION transfer_team_ownership(_team_id UUID, _new_owner_id UUID) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_old_owner_id UUID;
_owner_role_id UUID;
_admin_role_id UUID;
_old_org_id UUID;
_new_org_id UUID;
_has_license BOOLEAN;
_old_owner_role_id UUID;
_new_owner_role_id UUID;
_has_active_coupon BOOLEAN;
_other_teams_count INTEGER;
_new_owner_org_id UUID;
_license_type_id UUID;
_has_valid_license BOOLEAN;
BEGIN
-- Get the current owner's ID and organization
SELECT t.user_id, t.organization_id
INTO _old_owner_id, _old_org_id
FROM teams t
WHERE t.id = _team_id;
IF _old_owner_id IS NULL THEN
RAISE EXCEPTION 'Team not found';
END IF;
-- Get the new owner's organization
SELECT organization_id INTO _new_owner_org_id
FROM organizations
WHERE user_id = _new_owner_id;
-- Get the old organization
SELECT id INTO _old_org_id
FROM organizations
WHERE id = _old_org_id;
IF _old_org_id IS NULL THEN
RAISE EXCEPTION 'Organization not found';
END IF;
-- Check if new owner has any valid license type
SELECT EXISTS (
SELECT 1
FROM (
-- Check regular subscriptions
SELECT lus.user_id, lus.status, lus.active
FROM licensing_user_subscriptions lus
WHERE lus.user_id = _new_owner_id
AND lus.active = TRUE
AND lus.status IN ('active', 'trialing')
UNION ALL
-- Check custom subscriptions
SELECT lcs.user_id, lcs.subscription_status as status, TRUE as active
FROM licensing_custom_subs lcs
WHERE lcs.user_id = _new_owner_id
AND lcs.end_date > CURRENT_DATE
UNION ALL
-- Check trial status in organizations
SELECT o.user_id, o.subscription_status as status, TRUE as active
FROM organizations o
WHERE o.user_id = _new_owner_id
AND o.trial_in_progress = TRUE
AND o.trial_expire_date > CURRENT_DATE
) valid_licenses
) INTO _has_valid_license;
IF NOT _has_valid_license THEN
RAISE EXCEPTION 'New owner does not have a valid license (subscription, custom subscription, or trial)';
END IF;
-- Check if new owner has any active coupon codes
SELECT EXISTS (
SELECT 1
FROM licensing_coupon_codes lcc
WHERE lcc.redeemed_by = _new_owner_id
AND lcc.is_redeemed = TRUE
AND lcc.is_refunded = FALSE
) INTO _has_active_coupon;
IF _has_active_coupon THEN
RAISE EXCEPTION 'New owner has active coupon codes that need to be handled before transfer';
END IF;
-- Count other teams in the organization for information purposes
SELECT COUNT(*) INTO _other_teams_count
FROM teams
WHERE organization_id = _old_org_id
AND id != _team_id;
-- If new owner has their own organization, move the team to their organization
IF _new_owner_org_id IS NOT NULL THEN
-- Update the team to use the new owner's organization
UPDATE teams
SET user_id = _new_owner_id,
organization_id = _new_owner_org_id
WHERE id = _team_id;
-- Create notification about organization change
PERFORM create_notification(
_old_owner_id,
_team_id,
NULL,
NULL,
CONCAT('Team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b> has been moved to a different organization')
);
PERFORM create_notification(
_new_owner_id,
_team_id,
NULL,
NULL,
CONCAT('Team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b> has been moved to your organization')
);
ELSE
-- If new owner doesn't have an organization, transfer the old organization to them
UPDATE organizations
SET user_id = _new_owner_id
WHERE id = _old_org_id;
-- Update the team to use the same organization
UPDATE teams
SET user_id = _new_owner_id,
organization_id = _old_org_id
WHERE id = _team_id;
-- Notify both users about organization ownership transfer
PERFORM create_notification(
_old_owner_id,
NULL,
NULL,
NULL,
CONCAT('You are no longer the owner of organization <b>', (SELECT organization_name FROM organizations WHERE id = _old_org_id), '</b>')
);
PERFORM create_notification(
_new_owner_id,
NULL,
NULL,
NULL,
CONCAT('You are now the owner of organization <b>', (SELECT organization_name FROM organizations WHERE id = _old_org_id), '</b>')
);
END IF;
-- Get the owner and admin role IDs
SELECT id INTO _owner_role_id FROM roles WHERE team_id = _team_id AND owner = TRUE;
SELECT id INTO _admin_role_id FROM roles WHERE team_id = _team_id AND admin_role = TRUE;
-- Get current role IDs for both users
SELECT role_id INTO _old_owner_role_id
FROM team_members
WHERE team_id = _team_id AND user_id = _old_owner_id;
SELECT role_id INTO _new_owner_role_id
FROM team_members
WHERE team_id = _team_id AND user_id = _new_owner_id;
-- Update the old owner's role to admin if they want to stay in the team
IF _old_owner_role_id IS NOT NULL THEN
UPDATE team_members
SET role_id = _admin_role_id
WHERE team_id = _team_id AND user_id = _old_owner_id;
END IF;
-- Update the new owner's role to owner
IF _new_owner_role_id IS NOT NULL THEN
UPDATE team_members
SET role_id = _owner_role_id
WHERE team_id = _team_id AND user_id = _new_owner_id;
ELSE
-- If new owner is not a team member yet, add them
INSERT INTO team_members (user_id, team_id, role_id)
VALUES (_new_owner_id, _team_id, _owner_role_id);
END IF;
-- Create notification for both users about team ownership
PERFORM create_notification(
_old_owner_id,
_team_id,
NULL,
NULL,
CONCAT('You are no longer the owner of team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b>')
);
PERFORM create_notification(
_new_owner_id,
_team_id,
NULL,
NULL,
CONCAT('You are now the owner of team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b>')
);
RETURN json_build_object(
'success', TRUE,
'old_owner_id', _old_owner_id,
'new_owner_id', _new_owner_id,
'team_id', _team_id,
'old_org_id', _old_org_id,
'new_org_id', COALESCE(_new_owner_org_id, _old_org_id),
'old_role_id', _old_owner_role_id,
'new_role_id', _new_owner_role_id,
'has_valid_license', _has_valid_license,
'has_active_coupon', _has_active_coupon,
'other_teams_count', _other_teams_count,
'org_ownership_transferred', _new_owner_org_id IS NULL,
'team_moved_to_new_org', _new_owner_org_id IS NOT NULL
);
END;
$$;

File diff suppressed because it is too large Load Diff

View File

@@ -11,16 +11,30 @@
"repository": "GITHUB_REPO_HERE",
"author": "worklenz.com",
"scripts": {
"start": "node ./build/bin/www",
"tcs": "grunt build:tsc",
"build": "grunt build",
"watch": "grunt watch",
"dev": "grunt dev",
"es": "esbuild `find src -type f -name '*.ts'` --platform=node --minify=true --watch=true --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=dist",
"copy": "grunt copy",
"test": "jest",
"start": "node build/bin/www.js",
"dev": "npm run build:dev && npm run watch",
"build": "npm run clean && npm run compile && npm run copy && npm run compress",
"build:dev": "npm run clean && npm run compile:dev && npm run copy",
"build:prod": "npm run clean && npm run compile:prod && npm run copy && npm run minify && npm run compress",
"clean": "rimraf build",
"compile": "tsc --build tsconfig.prod.json",
"compile:dev": "tsc --build tsconfig.json",
"compile:prod": "tsc --build tsconfig.prod.json",
"copy": "npm run copy:assets && npm run copy:views && npm run copy:config && npm run copy:shared",
"copy:assets": "npx cpx2 \"src/public/**\" build/public",
"copy:views": "npx cpx2 \"src/views/**\" build/views",
"copy:config": "npx cpx2 \".env\" build && npx cpx2 \"package.json\" build",
"copy:shared": "npx cpx2 \"src/shared/postgresql-error-codes.json\" build/shared && npx cpx2 \"src/shared/sample-data.json\" build/shared && npx cpx2 \"src/shared/templates/**\" build/shared/templates",
"watch": "concurrently \"npm run watch:ts\" \"npm run watch:assets\"",
"watch:ts": "tsc --build tsconfig.json --watch",
"watch:assets": "npx cpx2 \"src/{public,views}/**\" build --watch",
"minify": "terser build/**/*.js --compress --mangle --output-dir build",
"compress": "node scripts/compress.js",
"swagger": "node ./cli/swagger",
"inline-queries": "node ./cli/inline-queries",
"sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties",
"tsc": "tsc",
"test": "jest --setupFiles dotenv/config",
"test:watch": "jest --watch --setupFiles dotenv/config"
},
"jestSonar": {
@@ -45,6 +59,7 @@
"cors": "^2.8.5",
"cron": "^2.4.0",
"crypto-js": "^4.1.1",
"csrf-sync": "^4.2.1",
"csurf": "^1.11.0",
"debug": "^4.3.4",
"dotenv": "^16.3.1",
@@ -120,26 +135,22 @@
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"chokidar": "^3.5.3",
"concurrently": "^9.1.2",
"cpx2": "^8.0.0",
"esbuild": "^0.17.19",
"esbuild-envfile-plugin": "^1.0.5",
"esbuild-node-externals": "^1.8.0",
"eslint": "^8.45.0",
"eslint-plugin-security": "^1.7.1",
"fs-extra": "^10.1.0",
"grunt": "^1.6.1",
"grunt-contrib-clean": "^2.0.1",
"grunt-contrib-compress": "^2.0.0",
"grunt-contrib-copy": "^1.0.0",
"grunt-contrib-uglify": "^5.2.2",
"grunt-contrib-watch": "^1.1.0",
"grunt-shell": "^4.0.0",
"grunt-sync": "^0.8.2",
"highcharts": "^11.1.0",
"jest": "^28.1.3",
"jest-sonar-reporter": "^2.0.0",
"ncp": "^2.0.0",
"nodeman": "^1.1.2",
"rimraf": "^6.0.1",
"swagger-jsdoc": "^6.2.8",
"terser": "^5.40.0",
"ts-jest": "^28.0.8",
"ts-node": "^10.9.1",
"tslint": "^6.1.3",

View File

@@ -0,0 +1,53 @@
const fs = require('fs');
const path = require('path');
const { createGzip } = require('zlib');
const { pipeline } = require('stream');
async function compressFile(inputPath, outputPath) {
return new Promise((resolve, reject) => {
const gzip = createGzip();
const source = fs.createReadStream(inputPath);
const destination = fs.createWriteStream(outputPath);
pipeline(source, gzip, destination, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
async function compressDirectory(dir) {
const files = fs.readdirSync(dir, { withFileTypes: true });
for (const file of files) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
await compressDirectory(fullPath);
} else if (file.name.endsWith('.js') || file.name.endsWith('.css')) {
const gzPath = fullPath + '.gz';
await compressFile(fullPath, gzPath);
console.log(`Compressed: ${fullPath} -> ${gzPath}`);
}
}
}
async function main() {
try {
const buildDir = path.join(__dirname, '../build');
if (fs.existsSync(buildDir)) {
await compressDirectory(buildDir);
console.log('Compression complete!');
} else {
console.log('Build directory not found. Run build first.');
}
} catch (error) {
console.error('Compression failed:', error);
process.exit(1);
}
}
main();

View File

@@ -6,7 +6,7 @@ import logger from "morgan";
import helmet from "helmet";
import compression from "compression";
import passport from "passport";
import csurf from "csurf";
import { csrfSync } from "csrf-sync";
import rateLimit from "express-rate-limit";
import cors from "cors";
import flash from "connect-flash";
@@ -112,17 +112,13 @@ function isLoggedIn(req: Request, _res: Response, next: NextFunction) {
return req.user ? next() : next(createError(401));
}
// CSRF configuration
const csrfProtection = csurf({
cookie: {
key: "XSRF-TOKEN",
path: "/",
httpOnly: false,
secure: isProduction(), // Only secure in production
sameSite: isProduction() ? "none" : "lax", // Different settings for dev vs prod
domain: isProduction() ? ".worklenz.com" : undefined // Only set domain in production
},
ignoreMethods: ["HEAD", "OPTIONS"]
// CSRF configuration using csrf-sync for session-based authentication
const {
invalidCsrfTokenError,
generateToken,
csrfSynchronisedProtection,
} = csrfSync({
getTokenFromRequest: (req: Request) => req.headers["x-csrf-token"] as string || (req.body && req.body["_csrf"])
});
// Apply CSRF selectively (exclude webhooks and public routes)
@@ -135,38 +131,25 @@ app.use((req, res, next) => {
) {
next();
} else {
csrfProtection(req, res, next);
csrfSynchronisedProtection(req, res, next);
}
});
// Set CSRF token cookie
// Set CSRF token method on request object for compatibility
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.csrfToken) {
const token = req.csrfToken();
res.cookie("XSRF-TOKEN", token, {
httpOnly: false,
secure: isProduction(),
sameSite: isProduction() ? "none" : "lax",
domain: isProduction() ? ".worklenz.com" : undefined,
path: "/"
});
// Add csrfToken method to request object for compatibility
if (!req.csrfToken && generateToken) {
req.csrfToken = (overwrite?: boolean) => generateToken(req, overwrite);
}
next();
});
// CSRF token refresh endpoint
app.get("/csrf-token", (req: Request, res: Response) => {
if (req.csrfToken) {
const token = req.csrfToken();
res.cookie("XSRF-TOKEN", token, {
httpOnly: false,
secure: isProduction(),
sameSite: isProduction() ? "none" : "lax",
domain: isProduction() ? ".worklenz.com" : undefined,
path: "/"
});
res.status(200).json({ done: true, message: "CSRF token refreshed" });
} else {
try {
const token = generateToken(req);
res.status(200).json({ done: true, message: "CSRF token refreshed", token });
} catch (error) {
res.status(500).json({ done: false, message: "Failed to generate CSRF token" });
}
});
@@ -219,7 +202,7 @@ if (isInternalServer()) {
// CSRF error handler
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
if (err.code === "EBADCSRFTOKEN") {
if (err === invalidCsrfTokenError) {
return res.status(403).json({
done: false,
message: "Invalid CSRF token",

View File

@@ -35,8 +35,18 @@ export default class AuthController extends WorklenzControllerBase {
const auth_error = errors.length > 0 ? errors[0] : null;
const message = messages.length > 0 ? messages[0] : null;
const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
const title = req.query.strategy ? midTitle : null;
// Determine title based on authentication status and strategy
let title = null;
if (req.query.strategy) {
if (auth_error) {
// Show failure title only when there's an actual error
title = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
} else if (req.isAuthenticated() && message) {
// Show success title when authenticated and there's a success message
title = req.query.strategy === "login" ? "Login Successful!" : "Signup Successful!";
}
// If no error and not authenticated, don't show any title (this might be a redirect without completion)
}
if (req.user)
req.user.build_v = FileConstants.getRelease();

View File

@@ -408,6 +408,65 @@ export default class ReportingAllocationController extends ReportingControllerBa
const { duration, date_range } = req.body;
// Calculate the date range (start and end)
let startDate: moment.Moment;
let endDate: moment.Moment;
if (date_range && date_range.length === 2) {
startDate = moment(date_range[0]);
endDate = moment(date_range[1]);
} else if (duration === DATE_RANGES.ALL_TIME) {
// Fetch the earliest start_date (or created_at if null) from selected projects
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
const minDateResult = await db.query(minDateQuery, []);
const minDate = minDateResult.rows[0]?.min_date;
startDate = minDate ? moment(minDate) : moment('2000-01-01');
endDate = moment();
} else {
switch (duration) {
case DATE_RANGES.YESTERDAY:
startDate = moment().subtract(1, "day");
endDate = moment().subtract(1, "day");
break;
case DATE_RANGES.LAST_WEEK:
startDate = moment().subtract(1, "week").startOf("isoWeek");
endDate = moment().subtract(1, "week").endOf("isoWeek");
break;
case DATE_RANGES.LAST_MONTH:
startDate = moment().subtract(1, "month").startOf("month");
endDate = moment().subtract(1, "month").endOf("month");
break;
case DATE_RANGES.LAST_QUARTER:
startDate = moment().subtract(3, "months").startOf("quarter");
endDate = moment().subtract(1, "quarter").endOf("quarter");
break;
default:
startDate = moment().startOf("day");
endDate = moment().endOf("day");
}
}
// Count only weekdays (Mon-Fri) in the period
let workingDays = 0;
let current = startDate.clone();
while (current.isSameOrBefore(endDate, 'day')) {
const day = current.isoWeekday();
if (day >= 1 && day <= 5) workingDays++;
current.add(1, 'day');
}
// Get hours_per_day for all selected projects
const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`;
const projectHoursResult = await db.query(projectHoursQuery, []);
const projectHoursMap: Record<string, number> = {};
for (const row of projectHoursResult.rows) {
projectHoursMap[row.id] = row.hours_per_day || 8;
}
// Sum total working hours for all selected projects
let totalWorkingHours = 0;
for (const pid of Object.keys(projectHoursMap)) {
totalWorkingHours += workingDays * projectHoursMap[pid];
}
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
const archivedClause = archived
? ""
@@ -430,6 +489,12 @@ export default class ReportingAllocationController extends ReportingControllerBa
for (const member of result.rows) {
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
member.color_code = getColor(member.name);
member.total_working_hours = totalWorkingHours;
member.utilization_percent = (totalWorkingHours > 0 && member.logged_time) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00';
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
// Over/under utilized hours: utilized_hours - total_working_hours
const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0;
member.over_under_utilized_hours = overUnder.toFixed(2);
}
return res.status(200).send(new ServerResponse(true, result.rows));

View File

@@ -1,6 +1,6 @@
import WorklenzControllerBase from "./worklenz-controller-base";
import {getColor} from "../shared/utils";
import {PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
import { getColor } from "../shared/utils";
import { PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
import moment from "moment/moment";
export const GroupBy = {
@@ -32,23 +32,14 @@ export default class TasksControllerBase extends WorklenzControllerBase {
}
public static updateTaskViewModel(task: any) {
console.log(`Processing task ${task.id} (${task.name})`);
console.log(` manual_progress: ${task.manual_progress}, progress_value: ${task.progress_value}`);
console.log(` project_use_manual_progress: ${task.project_use_manual_progress}, project_use_weighted_progress: ${task.project_use_weighted_progress}`);
console.log(` has subtasks: ${task.sub_tasks_count > 0}`);
// For parent tasks (with subtasks), always use calculated progress from subtasks
if (task.sub_tasks_count > 0) {
// For parent tasks without manual progress, calculate from subtasks (already done via db function)
console.log(` Parent task with subtasks: complete_ratio=${task.complete_ratio}`);
// Ensure progress matches complete_ratio for consistency
task.progress = task.complete_ratio || 0;
// Important: Parent tasks should not have manual progress
// If they somehow do, reset it
if (task.manual_progress) {
console.log(` WARNING: Parent task ${task.id} had manual_progress set to true, resetting`);
task.manual_progress = false;
task.progress_value = null;
}
@@ -58,28 +49,24 @@ export default class TasksControllerBase extends WorklenzControllerBase {
// For manually set progress, use that value directly
task.progress = parseInt(task.progress_value);
task.complete_ratio = parseInt(task.progress_value);
console.log(` Using manual progress: progress=${task.progress}, complete_ratio=${task.complete_ratio}`);
}
}
// For tasks with no subtasks and no manual progress, calculate based on time
else {
task.progress = task.total_minutes_spent && task.total_minutes
? ~~(task.total_minutes_spent / task.total_minutes * 100)
task.progress = task.total_minutes_spent && task.total_minutes
? ~~(task.total_minutes_spent / task.total_minutes * 100)
: 0;
// Set complete_ratio to match progress
task.complete_ratio = task.progress;
console.log(` Calculated time-based progress: progress=${task.progress}, complete_ratio=${task.complete_ratio}`);
}
// Ensure numeric values
task.progress = parseInt(task.progress) || 0;
task.complete_ratio = parseInt(task.complete_ratio) || 0;
task.overdue = task.total_minutes < task.total_minutes_spent;
task.time_spent = {hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60};
task.time_spent = { hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60 };
task.comments_count = Number(task.comments_count) ? +task.comments_count : 0;
task.attachments_count = Number(task.attachments_count) ? +task.attachments_count : 0;

View File

@@ -97,7 +97,6 @@ export default class TasksControllerV2 extends TasksControllerBase {
try {
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
const [data] = result.rows;
console.log("data", data);
if (data && data.info && data.info.ratio !== undefined) {
data.info.ratio = +((data.info.ratio || 0).toFixed());
return data.info;
@@ -833,9 +832,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
}
public static async refreshProjectTaskProgressValues(projectId: string): Promise<void> {
try {
console.log(`Refreshing progress values for project ${projectId}`);
try {
// Run the recalculate_all_task_progress function only for tasks in this project
const query = `
DO $$
@@ -893,10 +890,10 @@ export default class TasksControllerV2 extends TasksControllerBase {
END $$;
`;
const result = await db.query(query);
await db.query(query);
console.log(`Finished refreshing progress values for project ${projectId}`);
} catch (error) {
log_error('Error refreshing project task progress values', error);
log_error("Error refreshing project task progress values", error);
}
}

View File

@@ -1,11 +1,11 @@
import {startDailyDigestJob} from "./daily-digest-job";
import {startNotificationsJob} from "./notifications-job";
import {startProjectDigestJob} from "./project-digest-job";
import { startRecurringTasksJob } from "./recurring-tasks";
import {startRecurringTasksJob} from "./recurring-tasks";
export function startCronJobs() {
startNotificationsJob();
startDailyDigestJob();
startProjectDigestJob();
// startRecurringTasksJob();
if (process.env.ENABLE_RECURRING_JOBS === "true") startRecurringTasksJob();
}

View File

@@ -7,12 +7,90 @@ import TasksController from "../controllers/tasks-controller";
// At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday.
// const TIME = "0 11 */1 * 1-5";
const TIME = "*/2 * * * *";
const TIME = process.env.RECURRING_JOBS_INTERVAL || "0 11 */1 * 1-5";
const TIME_FORMAT = "YYYY-MM-DD";
// const TIME = "0 0 * * *"; // Runs at midnight every day
const log = (value: any) => console.log("recurring-task-cron-job:", value);
// Define future limits for different schedule types
// More conservative limits to prevent task list clutter
const FUTURE_LIMITS = {
daily: moment.duration(3, "days"),
weekly: moment.duration(1, "week"),
monthly: moment.duration(1, "month"),
every_x_days: (interval: number) => moment.duration(interval, "days"),
every_x_weeks: (interval: number) => moment.duration(interval, "weeks"),
every_x_months: (interval: number) => moment.duration(interval, "months")
};
// Helper function to get the future limit based on schedule type
function getFutureLimit(scheduleType: string, interval?: number): moment.Duration {
switch (scheduleType) {
case "daily":
return FUTURE_LIMITS.daily;
case "weekly":
return FUTURE_LIMITS.weekly;
case "monthly":
return FUTURE_LIMITS.monthly;
case "every_x_days":
return FUTURE_LIMITS.every_x_days(interval || 1);
case "every_x_weeks":
return FUTURE_LIMITS.every_x_weeks(interval || 1);
case "every_x_months":
return FUTURE_LIMITS.every_x_months(interval || 1);
default:
return moment.duration(3, "days"); // Default to 3 days
}
}
// Helper function to batch create tasks
async function createBatchTasks(template: ITaskTemplate & IRecurringSchedule, endDates: moment.Moment[]) {
const createdTasks = [];
for (const nextEndDate of endDates) {
const existingTaskQuery = `
SELECT id FROM tasks
WHERE schedule_id = $1 AND end_date::DATE = $2::DATE;
`;
const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]);
if (existingTaskResult.rows.length === 0) {
const createTaskQuery = `SELECT create_quick_task($1::json) as task;`;
const taskData = {
name: template.name,
priority_id: template.priority_id,
project_id: template.project_id,
reporter_id: template.reporter_id,
status_id: template.status_id || null,
end_date: nextEndDate.format(TIME_FORMAT),
schedule_id: template.schedule_id
};
const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]);
const createdTask = createTaskResult.rows[0].task;
if (createdTask) {
createdTasks.push(createdTask);
for (const assignee of template.assignees) {
await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by);
}
for (const label of template.labels) {
const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`;
await db.query(q, [createdTask.id, label.label_id]);
}
console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`);
}
} else {
console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`);
}
}
return createdTasks;
}
async function onRecurringTaskJobTick() {
try {
log("(cron) Recurring tasks job started.");
@@ -33,65 +111,44 @@ async function onRecurringTaskJobTick() {
? moment(template.last_task_end_date)
: moment(template.created_at);
const futureLimit = moment(template.last_checked_at || template.created_at).add(1, "week");
// Calculate future limit based on schedule type
const futureLimit = moment(template.last_checked_at || template.created_at)
.add(getFutureLimit(
template.schedule_type,
template.interval_days || template.interval_weeks || template.interval_months || 1
));
let nextEndDate = calculateNextEndDate(template, lastTaskEndDate);
const endDatesToCreate: moment.Moment[] = [];
// Find the next future occurrence
while (nextEndDate.isSameOrBefore(now)) {
// Find all future occurrences within the limit
while (nextEndDate.isSameOrBefore(futureLimit)) {
if (nextEndDate.isAfter(now)) {
endDatesToCreate.push(moment(nextEndDate));
}
nextEndDate = calculateNextEndDate(template, nextEndDate);
}
// Only create a task if it's within the future limit
if (nextEndDate.isSameOrBefore(futureLimit)) {
const existingTaskQuery = `
SELECT id FROM tasks
WHERE schedule_id = $1 AND end_date::DATE = $2::DATE;
// Batch create tasks for all future dates
if (endDatesToCreate.length > 0) {
const createdTasks = await createBatchTasks(template, endDatesToCreate);
createdTaskCount += createdTasks.length;
// Update the last_checked_at in the schedule
const updateScheduleQuery = `
UPDATE task_recurring_schedules
SET last_checked_at = $1::DATE,
last_created_task_end_date = $2
WHERE id = $3;
`;
const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]);
if (existingTaskResult.rows.length === 0) {
const createTaskQuery = `SELECT create_quick_task($1::json) as task;`;
const taskData = {
name: template.name,
priority_id: template.priority_id,
project_id: template.project_id,
reporter_id: template.reporter_id,
status_id: template.status_id || null,
end_date: nextEndDate.format(TIME_FORMAT),
schedule_id: template.schedule_id
};
const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]);
const createdTask = createTaskResult.rows[0].task;
if (createdTask) {
createdTaskCount++;
for (const assignee of template.assignees) {
await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by);
}
for (const label of template.labels) {
const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`;
await db.query(q, [createdTask.id, label.label_id]);
}
console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`);
}
} else {
console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`);
}
await db.query(updateScheduleQuery, [
moment().format(TIME_FORMAT),
endDatesToCreate[endDatesToCreate.length - 1].format(TIME_FORMAT),
template.schedule_id
]);
} else {
console.log(`No task created for template ${template.name} - next occurrence is beyond the future limit`);
console.log(`No tasks created for template ${template.name} - next occurrence is beyond the future limit`);
}
// Update the last_checked_at in the schedule
const updateScheduleQuery = `
UPDATE task_recurring_schedules
SET last_checked_at = $1::DATE, last_created_task_end_date = $2
WHERE id = $3;
`;
await db.query(updateScheduleQuery, [moment(template.last_checked_at || template.created_at).add(1, "day").format(TIME_FORMAT), nextEndDate.format(TIME_FORMAT), template.schedule_id]);
}
log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`);

View File

@@ -6,11 +6,11 @@ import { isProduction } from "../shared/utils";
const pgSession = require("connect-pg-simple")(session);
export default session({
name: process.env.SESSION_NAME,
name: process.env.SESSION_NAME || "worklenz.sid",
secret: process.env.SESSION_SECRET || "development-secret-key",
proxy: false,
proxy: true,
resave: false,
saveUninitialized: true,
saveUninitialized: false,
rolling: true,
store: new pgSession({
pool: db.pool,
@@ -18,10 +18,9 @@ export default session({
}),
cookie: {
path: "/",
// secure: isProduction(),
// httpOnly: isProduction(),
// sameSite: "none",
// domain: isProduction() ? ".worklenz.com" : undefined,
secure: isProduction(), // Use secure cookies in production
httpOnly: true,
sameSite: "lax", // Standard setting for same-origin requests
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
}
});

View File

@@ -3,13 +3,16 @@ import { Strategy as LocalStrategy } from "passport-local";
import { log_error } from "../../shared/utils";
import db from "../../config/db";
import { Request } from "express";
import { ERROR_KEY, SUCCESS_KEY } from "./passport-constants";
async function handleLogin(req: Request, email: string, password: string, done: any) {
console.log("Login attempt for:", email);
// Clear any existing flash messages
(req.session as any).flash = {};
if (!email || !password) {
console.log("Missing credentials");
return done(null, false, { message: "Please enter both email and password" });
const errorMsg = "Please enter both email and password";
req.flash(ERROR_KEY, errorMsg);
return done(null, false);
}
try {
@@ -19,23 +22,27 @@ async function handleLogin(req: Request, email: string, password: string, done:
AND google_id IS NULL
AND is_deleted IS FALSE;`;
const result = await db.query(q, [email]);
console.log("User query result count:", result.rowCount);
const [data] = result.rows;
if (!data?.password) {
console.log("No account found");
return done(null, false, { message: "No account found with this email" });
const errorMsg = "No account found with this email";
req.flash(ERROR_KEY, errorMsg);
return done(null, false);
}
const passwordMatch = bcrypt.compareSync(password, data.password);
console.log("Password match:", passwordMatch);
if (passwordMatch && email === data.email) {
delete data.password;
return done(null, data, {message: "User successfully logged in"});
const successMsg = "User successfully logged in";
req.flash(SUCCESS_KEY, successMsg);
return done(null, data);
}
return done(null, false, { message: "Incorrect email or password" });
const errorMsg = "Incorrect email or password";
req.flash(ERROR_KEY, errorMsg);
return done(null, false);
} catch (error) {
console.error("Login error:", error);
log_error(error, req.body);

View File

@@ -2,31 +2,30 @@
<html lang="en">
<head>
<title></title>
<title>Password Changed | Worklenz</title>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta content="width=device-width,initial-scale=1" name="viewport">
<style>
* {
box-sizing: border-box
box-sizing: border-box;
}
body {
margin: 0;
padding: 0
padding: 0;
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
-webkit-text-size-adjust: none;
text-size-adjust: none;
font-family: 'Mada', Arial, sans-serif;
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: inherit !important
}
#MessageViewBody a {
color: inherit;
text-decoration: none
}
p {
line-height: inherit
.main-container {
background: #fff;
border-radius: 18px;
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
margin: 40px auto 0 auto;
max-width: 500px;
padding: 0 0 20px 0;
}
.padding-30 {
@@ -42,33 +41,48 @@
mso-hide: all;
display: none;
max-height: 0;
overflow: hidden
overflow: hidden;
}
.footer {
text-align: center;
color: #b0b8c9;
font-size: 13px;
margin-top: 30px;
padding-bottom: 18px;
}
@media (max-width: 525px) {
.main-container {
margin: 0;
border-radius: 0;
box-shadow: none;
max-width: 100% !important;
}
.desktop_hide table.icons-inner {
display: inline-block !important
display: inline-block !important;
}
.icons-inner {
text-align: center
text-align: center;
}
.icons-inner td {
margin: 0 auto
margin: 0 auto;
}
.row-content {
width: 95% !important
width: 95% !important;
}
.mobile_hide {
display: none
display: none;
}
.stack .column {
width: 100%;
display: block
display: block;
}
.mobile_hide {
@@ -76,135 +90,145 @@
max-height: 0;
max-width: 0;
overflow: hidden;
font-size: 0
font-size: 0;
}
.desktop_hide,
.desktop_hide table {
display: table !important;
max-height: none !important
max-height: none !important;
}
}
</style>
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
</head>
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none;">
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tbody>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
<div class="main-container">
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tbody>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
padding-bottom: 20px;" width="220">
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="left" class="alignment" style="line-height:10px">
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
target="_blank"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
</div>
</td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:320px;margin-bottom: 40px;"
width="320">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="left" class="alignment" style="line-height:10px">
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
target="_blank"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
</div>
</td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:320px;margin-bottom: 40px;"
width="320">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
width="100%">
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="center" class="alignment" style="line-height:10px;margin-top: 30px;">
<img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-password-changed.png"
style="display:block;height:auto;border:0;width:100px;max-width:100%;margin-bottom: 10px;"
width="100">
</div>
</td>
</tr>
<tr>
<td class="pad">
<h1
style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:10px;margin-bottom:0;padding-top: 10px;padding-bottom: 10px;font-family: 'Mada', Arial, sans-serif;">
Password Changed Successfully
</h1>
<div
style="color:#505771;font-size:19px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:150%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px;margin-top: 18px;">
<p style="margin-top: 0px;margin-bottom: 18px;">Hi,</p>
<p style="margin:0;margin-bottom:10px">This is a confirmation that your Worklenz
account password was changed.</p>
<p style="margin:0;margin-bottom:10px">If you did not make this change, please <a
href="mailto:support@worklenz.com"
style="color:#4992f0;text-decoration:none;">contact our support team</a>
immediately.</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</table>
</td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad"
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
<table cellpadding="0" cellspacing="0" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="center" class="alignment" style="line-height:10px"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-password-changed.png"
style="display:block;height:auto;border:0;width:100px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
width="100">
</div>
</td>
</tr>
<tr>
<td class="pad">
<div
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:150%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
<p style="margin-top: 0px;margin-bottom: 30px;">
We wanted to let you know that your Worklenz password was reset.
</p>
</div>
<td class="alignment" style="vertical-align:middle;text-align:center">
<!--[if vml]>
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
<![endif]-->
<!--[if !vml]><!-->
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
</table>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</table>
</td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad"
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
<table cellpadding="0" cellspacing="0" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="alignment" style="vertical-align:middle;text-align:center">
<!--[if vml]>
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
<![endif]-->
<!--[if !vml]><!-->
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table><!-- End -->
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<div class="footer">
If you have any questions, contact us at <a href="mailto:support@worklenz.com"
style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
&copy; 2025 Worklenz. All rights reserved.
</div>
</div>
</body>
</html>

View File

@@ -2,31 +2,30 @@
<html lang="en">
<head>
<title></title>
<title>Reset Your Password | Worklenz</title>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta content="width=device-width,initial-scale=1" name="viewport">
<style>
* {
box-sizing: border-box
box-sizing: border-box;
}
body {
margin: 0;
padding: 0
padding: 0;
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
-webkit-text-size-adjust: none;
text-size-adjust: none;
font-family: 'Mada', Arial, sans-serif;
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: inherit !important
}
#MessageViewBody a {
color: inherit;
text-decoration: none
}
p {
line-height: inherit
.main-container {
background: #fff;
border-radius: 18px;
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
margin: 40px auto 0 auto;
max-width: 500px;
padding: 0 0 20px 0;
}
.padding-30 {
@@ -42,33 +41,68 @@
mso-hide: all;
display: none;
max-height: 0;
overflow: hidden
overflow: hidden;
}
.modern-btn {
text-decoration: none;
display: inline-block;
color: #fff;
background: linear-gradient(90deg, #54bf6b 0%, #4992f0d9 100%);
border-radius: 6px;
font-weight: 600;
padding: 10px 32px;
font-size: 17px;
box-shadow: 0 2px 8px rgba(73, 146, 240, 0.15);
transition: background 0.2s, box-shadow 0.2s;
margin-top: 10px;
margin-bottom: 30px;
}
.modern-btn:hover {
background: linear-gradient(90deg, #4992f0d9 0%, #54bf6b 100%);
box-shadow: 0 4px 16px rgba(73, 146, 240, 0.18);
}
.footer {
text-align: center;
color: #b0b8c9;
font-size: 13px;
margin-top: 30px;
padding-bottom: 18px;
}
@media (max-width: 525px) {
.main-container {
margin: 0;
border-radius: 0;
box-shadow: none;
max-width: 100% !important;
}
.desktop_hide table.icons-inner {
display: inline-block !important
display: inline-block !important;
}
.icons-inner {
text-align: center
text-align: center;
}
.icons-inner td {
margin: 0 auto
margin: 0 auto;
}
.row-content {
width: 95% !important
width: 95% !important;
}
.mobile_hide {
display: none
display: none;
}
.stack .column {
width: 100%;
display: block
display: block;
}
.mobile_hide {
@@ -76,179 +110,137 @@
max-height: 0;
max-width: 0;
overflow: hidden;
font-size: 0
font-size: 0;
}
.desktop_hide,
.desktop_hide table {
display: table !important;
max-height: none !important
max-height: none !important;
}
}
</style>
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
</head>
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tbody>
<body>
<div class="main-container">
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="background:transparent;" width="100%">
<tbody>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
padding-bottom: 20px;" width="220">
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="left" class="alignment" style="line-height:10px">
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
target="_blank"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
</div>
</td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
width="475">
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad">
<h1
style="margin:0;color:rgb(66, 66, 66);font-size:26px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:normal;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;">
<span class="tinyMce-placeholder">Reset your password on Worklenz</span>
</h1>
</td>
</tr>
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="center" class="alignment" style="line-height:10px"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png"
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
width="180">
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
width="100%">
<tr>
<td class="pad">
<div
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:120%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
width="100%">
<tr>
<td class="pad">
<div
style="color:rgb(143,152,164);font-size:16px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:18px">
<p style="margin:0;margin-bottom:10px">You have requested to reset your password
</p>
<p style="margin:0;margin-bottom:10px">To reset your password, click the following link and follow the instructions.</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
width="100%">
<tr>
<td class="pad">
<div align="center" class="alignment">
<a href="https://[VAR_HOSTNAME]/auth/verify-reset-email/[VAR_USER_ID]/[VAR_HASH]">
<div
style="text-decoration:none;display:inline-block;color:#fff;background: #54bf6b;border-radius:3px;width:auto;font-weight:400;padding-top:6px;padding-bottom:7px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;text-align:center;mso-border-alt:none;word-break:keep-all">
<span
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
dir="ltr" style="word-break: break-word; line-height: 28px;">Reset my password</span></span>
</div>
</a>
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
</div>
</td>
</tr>
</td>
</tr>
</tbody>
</table>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px; padding-bottom: 20px;" width="220">
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="left" class="alignment" style="line-height:10px">
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1" target="_blank"><img src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png" style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
</div>
</td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;" width="475">
<tbody>
<tr>
<td class="column column-1" style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0" width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad">
<h1 style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;font-family: 'Mada', Arial, sans-serif;">
<span class="tinyMce-placeholder">Reset your password</span>
</h1>
</td>
</tr>
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="center" class="alignment" style="line-height:10px"><img src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png" style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;" width="180">
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word" width="100%">
<tr>
<td class="pad">
<div style="color:#505771;font-size:20px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:130%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px">
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word" width="100%">
<tr>
<td class="pad">
<div style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
<p style="margin:0;margin-bottom:10px">We received a request to reset your Worklenz account password.</p>
<p style="margin:0;margin-bottom:10px">Click the button below to set a new password. If you did not request this, you can safely ignore this email.</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;" width="100%">
<tr>
<td class="pad">
<div align="center" class="alignment">
<a href="https://[VAR_HOSTNAME]/auth/verify-reset-email/[VAR_USER_ID]/[VAR_HASH]" class="modern-btn">
Reset my password
</a>
</div>
<div style="color:#b0b8c9;font-size:14px;text-align:center;margin-top:10px;">
<p style="margin:0;">For your security, this link will expire in 1 hour.</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0"
width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad"
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
<table cellpadding="0" cellspacing="0" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0"
width="100%">
<tr>
<td class="alignment" style="vertical-align:middle;text-align:center">
<!--[if vml]>
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
<![endif]-->
<!--[if !vml]><!-->
<table cellpadding="0"
cellspacing="0"
class="icons-inner" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
<tbody>
<tr>
<td class="column column-1" style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0" width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad" style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
<table cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="alignment" style="vertical-align:middle;text-align:center">
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table><!-- End -->
</tbody>
</table>
<div class="footer">
If you have any questions, contact us at <a href="mailto:support@worklenz.com" style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
&copy; 2025 Worklenz. All rights reserved.
</div>
</div>
</body>
</html>

View File

@@ -2,31 +2,30 @@
<html lang="en">
<head>
<title></title>
<title>Join Your Team on Worklenz</title>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta content="width=device-width,initial-scale=1" name="viewport">
<style>
* {
box-sizing: border-box
box-sizing: border-box;
}
body {
margin: 0;
padding: 0
padding: 0;
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
-webkit-text-size-adjust: none;
text-size-adjust: none;
font-family: 'Mada', Arial, sans-serif;
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: inherit !important
}
#MessageViewBody a {
color: inherit;
text-decoration: none
}
p {
line-height: inherit
.main-container {
background: #fff;
border-radius: 18px;
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
margin: 40px auto 0 auto;
max-width: 500px;
padding: 0 0 20px 0;
}
.padding-30 {
@@ -42,33 +41,68 @@
mso-hide: all;
display: none;
max-height: 0;
overflow: hidden
overflow: hidden;
}
.modern-btn {
text-decoration: none;
display: inline-block;
color: #fff;
background: linear-gradient(90deg, #54bf6b 0%, #4992f0d9 100%);
border-radius: 6px;
font-weight: 600;
padding: 10px 32px;
font-size: 17px;
box-shadow: 0 2px 8px rgba(73, 146, 240, 0.15);
transition: background 0.2s, box-shadow 0.2s;
margin-top: 10px;
margin-bottom: 30px;
}
.modern-btn:hover {
background: linear-gradient(90deg, #4992f0d9 0%, #54bf6b 100%);
box-shadow: 0 4px 16px rgba(73, 146, 240, 0.18);
}
.footer {
text-align: center;
color: #b0b8c9;
font-size: 13px;
margin-top: 30px;
padding-bottom: 18px;
}
@media (max-width: 525px) {
.main-container {
margin: 0;
border-radius: 0;
box-shadow: none;
max-width: 100% !important;
}
.desktop_hide table.icons-inner {
display: inline-block !important
display: inline-block !important;
}
.icons-inner {
text-align: center
text-align: center;
}
.icons-inner td {
margin: 0 auto
margin: 0 auto;
}
.row-content {
width: 95% !important
width: 95% !important;
}
.mobile_hide {
display: none
display: none;
}
.stack .column {
width: 100%;
display: block
display: block;
}
.mobile_hide {
@@ -76,181 +110,134 @@
max-height: 0;
max-width: 0;
overflow: hidden;
font-size: 0
font-size: 0;
}
.desktop_hide,
.desktop_hide table {
display: table !important;
max-height: none !important
max-height: none !important;
}
}
</style>
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
</head>
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tbody>
<body>
<div class="main-container">
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="background:transparent;" width="100%">
<tbody>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
padding-bottom: 20px;" width="220">
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="left" class="alignment" style="line-height:10px">
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
target="_blank"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
</div>
</td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
width="475">
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad">
<h1
style="margin:0;color:rgb(66, 66, 66);font-size:26px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:normal;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;">
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
</h1>
</td>
</tr>
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="center" class="alignment" style="line-height:10px"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png"
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
width="180">
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
width="100%">
<tr>
<td class="pad">
<div
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:120%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
width="100%">
<tr>
<td class="pad">
<div
style="color:rgb(143,152,164);font-size:16px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:18px">
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team
on Worklenz!
</p>
<p>Sign in to your Worklenz account to continue.</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
width="100%">
<tr>
<td class="pad">
<div align="center" class="alignment">
<a href="https://[VAR_HOSTNAME]/auth/login?team=[VAR_TEAM_ID]&user=[VAR_USER_ID]&project=[PROJECT_ID]">
<div
style="text-decoration:none;display:inline-block;color:#fff;background: #54bf6b;border-radius:3px;width:auto;font-weight:400;padding-top:6px;padding-bottom:7px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;text-align:center;mso-border-alt:none;word-break:keep-all">
<span
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
dir="ltr" style="word-break: break-word; line-height: 28px;">Go to
Worklenz</span></span>
</div>
</a>
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
</div>
</td>
</tr>
</td>
</tr>
</tbody>
</table>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px; padding-bottom: 20px;" width="220">
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="left" class="alignment" style="line-height:10px">
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1" target="_blank"><img src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png" style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
</div>
</td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;" width="475">
<tbody>
<tr>
<td class="column column-1" style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0" width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad">
<h1 style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;font-family: 'Mada', Arial, sans-serif;">
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
</h1>
</td>
</tr>
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="center" class="alignment" style="line-height:10px"><img src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png" style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;" width="180">
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word" width="100%">
<tr>
<td class="pad">
<div style="color:#505771;font-size:20px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:130%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px">
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word" width="100%">
<tr>
<td class="pad">
<div style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team on Worklenz!</p>
<p>Sign in to your Worklenz account to continue.</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;" width="100%">
<tr>
<td class="pad">
<div align="center" class="alignment">
<a href="https://[VAR_HOSTNAME]/auth/login?team=[VAR_TEAM_ID]&user=[VAR_USER_ID]&project=[PROJECT_ID]" class="modern-btn">
Go to Worklenz
</a>
</div>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0"
width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad"
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
<table cellpadding="0" cellspacing="0" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0"
width="100%">
<tr>
<td class="alignment" style="vertical-align:middle;text-align:center">
<!--[if vml]>
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
<![endif]-->
<!--[if !vml]><!-->
<table cellpadding="0"
cellspacing="0"
class="icons-inner" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
<tbody>
<tr>
<td class="column column-1" style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0" width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad" style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
<table cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="alignment" style="vertical-align:middle;text-align:center">
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table><!-- End -->
</tbody>
</table>
<div class="footer">
If you have any questions, contact us at <a href="mailto:support@worklenz.com" style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
&copy; 2025 Worklenz. All rights reserved.
</div>
</div>
</body>
</html>

View File

@@ -2,31 +2,30 @@
<html lang="en">
<head>
<title></title>
<title>Join Your Team on Worklenz</title>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta content="width=device-width,initial-scale=1" name="viewport">
<style>
* {
box-sizing: border-box
box-sizing: border-box;
}
body {
margin: 0;
padding: 0
padding: 0;
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
-webkit-text-size-adjust: none;
text-size-adjust: none;
font-family: 'Mada', Arial, sans-serif;
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: inherit !important
}
#MessageViewBody a {
color: inherit;
text-decoration: none
}
p {
line-height: inherit
.main-container {
background: #fff;
border-radius: 18px;
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
margin: 40px auto 0 auto;
max-width: 500px;
padding: 0 0 20px 0;
}
.padding-30 {
@@ -42,33 +41,68 @@
mso-hide: all;
display: none;
max-height: 0;
overflow: hidden
overflow: hidden;
}
.modern-btn {
text-decoration: none;
display: inline-block;
color: #fff;
background: linear-gradient(90deg, #6249f0 0%, #4992f0d9 100%);
border-radius: 6px;
font-weight: 600;
padding: 10px 32px;
font-size: 17px;
box-shadow: 0 2px 8px rgba(73, 146, 240, 0.15);
transition: background 0.2s, box-shadow 0.2s;
margin-top: 10px;
margin-bottom: 30px;
}
.modern-btn:hover {
background: linear-gradient(90deg, #4992f0d9 0%, #6249f0 100%);
box-shadow: 0 4px 16px rgba(73, 146, 240, 0.18);
}
.footer {
text-align: center;
color: #b0b8c9;
font-size: 13px;
margin-top: 30px;
padding-bottom: 18px;
}
@media (max-width: 525px) {
.main-container {
margin: 0;
border-radius: 0;
box-shadow: none;
max-width: 100% !important;
}
.desktop_hide table.icons-inner {
display: inline-block !important
display: inline-block !important;
}
.icons-inner {
text-align: center
text-align: center;
}
.icons-inner td {
margin: 0 auto
margin: 0 auto;
}
.row-content {
width: 95% !important
width: 95% !important;
}
.mobile_hide {
display: none
display: none;
}
.stack .column {
width: 100%;
display: block
display: block;
}
.mobile_hide {
@@ -76,180 +110,174 @@
max-height: 0;
max-width: 0;
overflow: hidden;
font-size: 0
font-size: 0;
}
.desktop_hide,
.desktop_hide table {
display: table !important;
max-height: none !important
max-height: none !important;
}
}
</style>
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
</head>
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tbody>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
padding-bottom: 20px;" width="220">
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="left" class="alignment" style="line-height:10px">
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
target="_blank"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
</div>
</td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
width="475">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad">
<h1
style="margin:0;color:rgb(66, 66, 66);font-size:26px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:normal;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;">
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
</h1>
</td>
</tr>
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="center" class="alignment" style="line-height:10px"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-unregistered-team-member.png"
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
width="180">
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
width="100%">
<tr>
<td class="pad">
<div
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:120%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
width="100%">
<tr>
<td class="pad">
<div
style="color:rgb(143,152,164);font-size:16px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:18px">
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team
on Worklenz!</p>
<p>Create an account in Worklenz to continue.</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
width="100%">
<tr>
<td class="pad">
<div align="center" class="alignment">
<a href="https://[VAR_HOSTNAME]/auth/signup?email=[VAR_EMAIL]&user=[VAR_USER_ID]&team=[VAR_TEAM_ID]&name=[VAR_USER_NAME]&project=[PROJECT_ID]">
<div
style="text-decoration:none;display:inline-block;color:#fff;background: #6249f0;border-radius:3px;width:auto;font-weight:400;padding-top:6px;padding-bottom:7px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;text-align:center;mso-border-alt:none;word-break:keep-all">
<span
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
dir="ltr" style="word-break: break-word; line-height: 28px;">Join
Worklenz</span></span>
</div>
</a>
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
</div>
</td>
</tr>
<body>
<div class="main-container">
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
style="background:transparent;"
width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tbody>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
padding-bottom: 20px;" width="220">
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="left" class="alignment" style="line-height:10px">
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
target="_blank"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
</div>
</td>
</tr>
</tbody>
</table>
</table>
</td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0"
width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad"
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
<table cellpadding="0" cellspacing="0" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0"
width="100%">
<tr>
<td class="alignment" style="vertical-align:middle;text-align:center">
<!--[if vml]>
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
<![endif]-->
<!--[if !vml]><!-->
<table cellpadding="0"
cellspacing="0"
class="icons-inner" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table><!-- End -->
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
width="475">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad">
<h1
style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;font-family: 'Mada', Arial, sans-serif;">
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
</h1>
</td>
</tr>
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="center" class="alignment" style="line-height:10px"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-unregistered-team-member.png"
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
width="180">
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
width="100%">
<tr>
<td class="pad">
<div
style="color:#505771;font-size:20px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:130%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px">
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
width="100%">
<tr>
<td class="pad">
<div
style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team
on Worklenz!</p>
<p>Create an account in Worklenz to continue.</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
width="100%">
<tr>
<td class="pad">
<div align="center" class="alignment">
<a href="https://[VAR_HOSTNAME]/auth/signup?email=[VAR_EMAIL]&user=[VAR_USER_ID]&team=[VAR_TEAM_ID]&name=[VAR_USER_NAME]&project=[PROJECT_ID]" class="modern-btn">
Join Worklenz
</a>
</div>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0"
width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad"
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
<table cellpadding="0" cellspacing="0" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0"
width="100%">
<tr>
<td class="alignment" style="vertical-align:middle;text-align:center">
<table cellpadding="0"
cellspacing="0"
class="icons-inner" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<div class="footer">
If you have any questions, contact us at <a href="mailto:support@worklenz.com" style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
&copy; 2025 Worklenz. All rights reserved.
</div>
</div>
</body>
</html>

View File

@@ -2,31 +2,30 @@
<html lang="en">
<head>
<title></title>
<title>Welcome to Worklenz</title>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta content="width=device-width,initial-scale=1" name="viewport">
<style>
* {
box-sizing: border-box
box-sizing: border-box;
}
body {
margin: 0;
padding: 0
padding: 0;
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
-webkit-text-size-adjust: none;
text-size-adjust: none;
font-family: 'Mada', Arial, sans-serif;
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: inherit !important
}
#MessageViewBody a {
color: inherit;
text-decoration: none
}
p {
line-height: inherit
.main-container {
background: #fff;
border-radius: 18px;
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
margin: 40px auto 0 auto;
max-width: 500px;
padding: 0 0 20px 0;
}
.padding-30 {
@@ -42,33 +41,68 @@
mso-hide: all;
display: none;
max-height: 0;
overflow: hidden
overflow: hidden;
}
.modern-btn {
text-decoration: none;
display: inline-block;
color: #fff;
background: linear-gradient(90deg, #4992f0d9 0%, #3b6fd6 100%);
border-radius: 6px;
font-weight: 600;
padding: 10px 32px;
font-size: 17px;
box-shadow: 0 2px 8px rgba(73, 146, 240, 0.15);
transition: background 0.2s, box-shadow 0.2s;
margin-top: 10px;
margin-bottom: 30px;
}
.modern-btn:hover {
background: linear-gradient(90deg, #3b6fd6 0%, #4992f0d9 100%);
box-shadow: 0 4px 16px rgba(73, 146, 240, 0.18);
}
.footer {
text-align: center;
color: #b0b8c9;
font-size: 13px;
margin-top: 30px;
padding-bottom: 18px;
}
@media (max-width: 525px) {
.main-container {
margin: 0;
border-radius: 0;
box-shadow: none;
max-width: 100% !important;
}
.desktop_hide table.icons-inner {
display: inline-block !important
display: inline-block !important;
}
.icons-inner {
text-align: center
text-align: center;
}
.icons-inner td {
margin: 0 auto
margin: 0 auto;
}
.row-content {
width: 95% !important
width: 95% !important;
}
.mobile_hide {
display: none
display: none;
}
.stack .column {
width: 100%;
display: block
display: block;
}
.mobile_hide {
@@ -76,179 +110,173 @@
max-height: 0;
max-width: 0;
overflow: hidden;
font-size: 0
font-size: 0;
}
.desktop_hide,
.desktop_hide table {
display: table !important;
max-height: none !important
max-height: none !important;
}
}
</style>
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
</head>
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tbody>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
padding-bottom: 20px;" width="220">
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="left" class="alignment" style="line-height:10px">
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
target="_blank"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
</div>
</td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
width="475">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad">
<h1
style="margin:0;color:rgb(66, 66, 66);font-size:26px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:normal;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;">
<span class="tinyMce-placeholder">Let's get started with Worklenz.</span>
</h1>
</td>
</tr>
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="center" class="alignment" style="line-height:10px"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-signup.png"
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
width="180">
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
width="100%">
<tr>
<td class="pad">
<div
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:120%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
width="100%">
<tr>
<td class="pad">
<div
style="color:rgb(143,152,164);font-size:16px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:18px">
<p style="margin:0;margin-bottom:10px">Thanks for joining Worklenz!</p>
<p style="margin:0"> We're excited to have you on board. </p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
width="100%">
<tr>
<td class="pad">
<div align="center" class="alignment">
<a href="https://[VAR_HOSTNAME]/auth/login">
<div
style="text-decoration:none;display:inline-block;color:#fff;background:#4992f0d9;border-radius:3px;width:auto;font-weight:400;padding-top:6px;padding-bottom:7px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;text-align:center;mso-border-alt:none;word-break:keep-all">
<span
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
dir="ltr" style="word-break: break-word; line-height: 28px;">Go to
Worklenz</span></span>
</div>
</a>
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
</div>
</td>
</tr>
<body>
<div class="main-container">
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;background:transparent;"
width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tbody>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
padding-bottom: 20px;" width="220">
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="left" class="alignment" style="line-height:10px">
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
target="_blank"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
</div>
</td>
</tr>
</tbody>
</table>
</table>
</td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0"
width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad"
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
<table cellpadding="0" cellspacing="0" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0"
width="100%">
<tr>
<td class="alignment" style="vertical-align:middle;text-align:center">
<!--[if vml]>
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
<![endif]-->
<!--[if !vml]><!-->
<table cellpadding="0"
cellspacing="0"
class="icons-inner" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table><!-- End -->
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
width="475">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad">
<h1
style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;font-family: 'Mada', Arial, sans-serif;">
<span class="tinyMce-placeholder">Let's get started with Worklenz.</span>
</h1>
</td>
</tr>
<tr>
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
<div align="center" class="alignment" style="line-height:10px"><img
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-signup.png"
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
width="180">
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
width="100%">
<tr>
<td class="pad">
<div
style="color:#505771;font-size:20px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:130%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px">
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
width="100%">
<tr>
<td class="pad">
<div
style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
<p style="margin:0;margin-bottom:10px">Thanks for joining Worklenz!</p>
<p style="margin:0"> We're excited to have you on board. </p>
</div>
</td>
</tr>
</table>
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
width="100%">
<tr>
<td class="pad">
<div align="center" class="alignment">
<a href="https://[VAR_HOSTNAME]/auth/login" class="modern-btn">
Go to Worklenz
</a>
</div>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0"
width="100%">
<tbody>
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
<tbody>
<tr>
<td class="column column-1"
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
width="100%">
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
<tr>
<td class="pad"
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
<table cellpadding="0" cellspacing="0" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0"
width="100%">
<tr>
<td class="alignment" style="vertical-align:middle;text-align:center">
<table cellpadding="0"
cellspacing="0"
class="icons-inner" role="presentation"
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<div class="footer">
If you have any questions, contact us at <a href="mailto:support@worklenz.com" style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
&copy; 2025 Worklenz. All rights reserved.
</div>
</div>
</body>
</html>

View File

@@ -12,7 +12,7 @@ COPY . .
RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \
echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js
RUN npm run build
RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build
FROM node:22-alpine AS production

View File

@@ -48,6 +48,17 @@
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
<script type="text/javascript">
if (window.location.hostname === 'app.worklenz.com') {
var hs = document.createElement('script');
hs.type = 'text/javascript';
hs.id = 'hs-script-loader';
hs.async = true;
hs.defer = true;
hs.src = '//js.hs-scripts.com/22348300.js';
document.body.appendChild(hs);
}
</script>
</body>
</html>

View File

@@ -15,7 +15,8 @@
"hide-start-date": "Hide Start Date",
"show-start-date": "Show Start Date",
"hours": "Hours",
"minutes": "Minutes"
"minutes": "Minutes",
"recurring": "Recurring"
},
"description": {
"title": "Description",

View File

@@ -0,0 +1,34 @@
{
"recurring": "Recurring",
"recurringTaskConfiguration": "Recurring task configuration",
"repeats": "Repeats",
"daily": "Daily",
"weekly": "Weekly",
"everyXDays": "Every X Days",
"everyXWeeks": "Every X Weeks",
"everyXMonths": "Every X Months",
"monthly": "Monthly",
"selectDaysOfWeek": "Select Days of the Week",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat",
"sun": "Sun",
"monthlyRepeatType": "Monthly repeat type",
"onSpecificDate": "On a specific date",
"onSpecificDay": "On a specific day",
"dateOfMonth": "Date of the month",
"weekOfMonth": "Week of the month",
"dayOfWeek": "Day of the week",
"first": "First",
"second": "Second",
"third": "Third",
"fourth": "Fourth",
"last": "Last",
"intervalDays": "Interval (days)",
"intervalWeeks": "Interval (weeks)",
"intervalMonths": "Interval (months)",
"saveChanges": "Save Changes"
}

View File

@@ -30,7 +30,8 @@
"taskWeight": "Task Weight",
"taskWeightTooltip": "Set the weight of this subtask (percentage)",
"taskWeightRequired": "Please enter a task weight",
"taskWeightRange": "Weight must be between 0 and 100"
"taskWeightRange": "Weight must be between 0 and 100",
"recurring": "Recurring"
},
"labels": {
"labelInputPlaceholder": "Search or create",

View File

@@ -15,7 +15,8 @@
"hide-start-date": "Ocultar fecha de inicio",
"show-start-date": "Mostrar fecha de inicio",
"hours": "Horas",
"minutes": "Minutos"
"minutes": "Minutos",
"recurring": "Recurrente"
},
"description": {
"title": "Descripción",

View File

@@ -0,0 +1,34 @@
{
"recurring": "Recurrente",
"recurringTaskConfiguration": "Configuración de tarea recurrente",
"repeats": "Repeticiones",
"daily": "Diario",
"weekly": "Semanal",
"everyXDays": "Cada X días",
"everyXWeeks": "Cada X semanas",
"everyXMonths": "Cada X meses",
"monthly": "Mensual",
"selectDaysOfWeek": "Seleccionar días de la semana",
"mon": "Lun",
"tue": "Mar",
"wed": "Mié",
"thu": "Jue",
"fri": "Vie",
"sat": "Sáb",
"sun": "Dom",
"monthlyRepeatType": "Tipo de repetición mensual",
"onSpecificDate": "En una fecha específica",
"onSpecificDay": "En un día específico",
"dateOfMonth": "Fecha del mes",
"weekOfMonth": "Semana del mes",
"dayOfWeek": "Día de la semana",
"first": "Primero",
"second": "Segundo",
"third": "Tercero",
"fourth": "Cuarto",
"last": "Último",
"intervalDays": "Intervalo (días)",
"intervalWeeks": "Intervalo (semanas)",
"intervalMonths": "Intervalo (meses)",
"saveChanges": "Guardar cambios"
}

View File

@@ -30,7 +30,8 @@
"taskWeight": "Peso de la Tarea",
"taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)",
"taskWeightRequired": "Por favor, introduce un peso para la tarea",
"taskWeightRange": "El peso debe estar entre 0 y 100"
"taskWeightRange": "El peso debe estar entre 0 y 100",
"recurring": "Recurrente"
},
"labels": {
"labelInputPlaceholder": "Buscar o crear",

View File

@@ -15,7 +15,8 @@
"hide-start-date": "Ocultar data de início",
"show-start-date": "Mostrar data de início",
"hours": "Horas",
"minutes": "Minutos"
"minutes": "Minutos",
"recurring": "Recorrente"
},
"description": {
"title": "Descrição",

View File

@@ -0,0 +1,34 @@
{
"recurring": "Recorrente",
"recurringTaskConfiguration": "Configuração de tarefa recorrente",
"repeats": "Repete",
"daily": "Diário",
"weekly": "Semanal",
"everyXDays": "A cada X dias",
"everyXWeeks": "A cada X semanas",
"everyXMonths": "A cada X meses",
"monthly": "Mensal",
"selectDaysOfWeek": "Selecionar dias da semana",
"mon": "Seg",
"tue": "Ter",
"wed": "Qua",
"thu": "Qui",
"fri": "Sex",
"sat": "Sáb",
"sun": "Dom",
"monthlyRepeatType": "Tipo de repetição mensal",
"onSpecificDate": "Em uma data específica",
"onSpecificDay": "Em um dia específico",
"dateOfMonth": "Data do mês",
"weekOfMonth": "Semana do mês",
"dayOfWeek": "Dia da semana",
"first": "Primeira",
"second": "Segunda",
"third": "Terceira",
"fourth": "Quarta",
"last": "Última",
"intervalDays": "Intervalo (dias)",
"intervalWeeks": "Intervalo (semanas)",
"intervalMonths": "Intervalo (meses)",
"saveChanges": "Salvar alterações"
}

View File

@@ -30,7 +30,8 @@
"taskWeight": "Peso da Tarefa",
"taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)",
"taskWeightRequired": "Por favor, insira um peso para a tarefa",
"taskWeightRange": "O peso deve estar entre 0 e 100"
"taskWeightRange": "O peso deve estar entre 0 e 100",
"recurring": "Recorrente"
},
"labels": {
"labelInputPlaceholder": "Pesquisar ou criar",

View File

@@ -13,6 +13,7 @@ import router from './app/routes';
// Hooks & Utils
import { useAppSelector } from './hooks/useAppSelector';
import { initMixpanel } from './utils/mixpanelInit';
import { initializeCsrfToken } from './api/api-client';
// Types & Constants
import { Language } from './features/i18n/localesSlice';
@@ -35,6 +36,13 @@ const App: React.FC<{ children: React.ReactNode }> = ({ children }) => {
});
}, [language]);
// Initialize CSRF token on app startup
useEffect(() => {
initializeCsrfToken().catch(error => {
logger.error('Failed to initialize CSRF token:', error);
});
}, []);
return (
<Suspense fallback={<SuspenseFallback />}>
<ThemeWrapper>

View File

@@ -4,27 +4,36 @@ import alertService from '@/services/alerts/alertService';
import logger from '@/utils/errorLogger';
import config from '@/config/env';
export const getCsrfToken = (): string | null => {
const match = document.cookie.split('; ').find(cookie => cookie.startsWith('XSRF-TOKEN='));
// Store CSRF token in memory (since csrf-sync uses session-based tokens)
let csrfToken: string | null = null;
if (!match) {
return null;
}
return decodeURIComponent(match.split('=')[1]);
export const getCsrfToken = (): string | null => {
return csrfToken;
};
// Function to refresh CSRF token if needed
// Function to refresh CSRF token from server
export const refreshCsrfToken = async (): Promise<string | null> => {
try {
// Make a GET request to the server to get a fresh CSRF token
await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
return getCsrfToken();
const response = await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
if (response.data && response.data.token) {
csrfToken = response.data.token;
return csrfToken;
}
return null;
} catch (error) {
console.error('Failed to refresh CSRF token:', error);
return null;
}
};
// Initialize CSRF token on app load
export const initializeCsrfToken = async (): Promise<void> => {
if (!csrfToken) {
await refreshCsrfToken();
}
};
const apiClient = axios.create({
baseURL: config.apiUrl,
withCredentials: true,
@@ -36,12 +45,16 @@ const apiClient = axios.create({
// Request interceptor
apiClient.interceptors.request.use(
config => {
const token = getCsrfToken();
if (token) {
config.headers['X-CSRF-Token'] = token;
async config => {
// Ensure we have a CSRF token before making requests
if (!csrfToken) {
await refreshCsrfToken();
}
if (csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken;
} else {
console.warn('No CSRF token found');
console.warn('No CSRF token available');
}
return config;
},
@@ -84,7 +97,7 @@ apiClient.interceptors.response.use(
(typeof errorResponse.data === 'object' &&
errorResponse.data !== null &&
'message' in errorResponse.data &&
errorResponse.data.message === 'Invalid CSRF token' ||
(errorResponse.data.message === 'invalid csrf token' || errorResponse.data.message === 'Invalid CSRF token') ||
(error as any).code === 'EBADCSRFTOKEN')) {
alertService.error('Security Error', 'Invalid security token. Refreshing your session...');
@@ -94,7 +107,7 @@ apiClient.interceptors.response.use(
// Update the token in the failed request
error.config.headers['X-CSRF-Token'] = newToken;
// Retry the original request with the new token
return axios(error.config);
return apiClient(error.config);
} else {
// If token refresh failed, redirect to login
window.location.href = '/auth/login';

View File

@@ -5,7 +5,7 @@ import { toQueryString } from '@/utils/toQueryString';
import { IHomeTasksModel, IHomeTasksConfig } from '@/types/home/home-page.types';
import { IMyTask } from '@/types/home/my-tasks.types';
import { IProject } from '@/types/project/project.types';
import { getCsrfToken } from '../api-client';
import { getCsrfToken, refreshCsrfToken } from '../api-client';
import config from '@/config/env';
const rootUrl = '/home';
@@ -14,9 +14,18 @@ const api = createApi({
reducerPath: 'homePageApi',
baseQuery: fetchBaseQuery({
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
prepareHeaders: headers => {
headers.set('X-CSRF-Token', getCsrfToken() || '');
prepareHeaders: async headers => {
// Get CSRF token, refresh if needed
let token = getCsrfToken();
if (!token) {
token = await refreshCsrfToken();
}
if (token) {
headers.set('X-CSRF-Token', token);
}
headers.set('Content-Type', 'application/json');
return headers;
},
credentials: 'include',
}),

View File

@@ -5,7 +5,7 @@ import { IProjectCategory } from '@/types/project/projectCategory.types';
import { IProjectsViewModel } from '@/types/project/projectsViewModel.types';
import { IServerResponse } from '@/types/common.types';
import { IProjectMembersViewModel } from '@/types/projectMember.types';
import { getCsrfToken } from '../api-client';
import { getCsrfToken, refreshCsrfToken } from '../api-client';
import config from '@/config/env';
const rootUrl = '/projects';
@@ -14,9 +14,18 @@ export const projectsApi = createApi({
reducerPath: 'projectsApi',
baseQuery: fetchBaseQuery({
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
prepareHeaders: headers => {
headers.set('X-CSRF-Token', getCsrfToken() || '');
prepareHeaders: async headers => {
// Get CSRF token, refresh if needed
let token = getCsrfToken();
if (!token) {
token = await refreshCsrfToken();
}
if (token) {
headers.set('X-CSRF-Token', token);
}
headers.set('Content-Type', 'application/json');
return headers;
},
credentials: 'include',
}),

View File

@@ -0,0 +1,16 @@
import { API_BASE_URL } from "@/shared/constants";
import { IServerResponse } from "@/types/common.types";
import { ITaskRecurringSchedule } from "@/types/tasks/task-recurring-schedule";
import apiClient from "../api-client";
const rootUrl = `${API_BASE_URL}/task-recurring`;
export const taskRecurringApiService = {
getTaskRecurringData: async (schedule_id: string): Promise<IServerResponse<ITaskRecurringSchedule>> => {
const response = await apiClient.get(`${rootUrl}/${schedule_id}`);
return response.data;
},
updateTaskRecurringData: async (schedule_id: string, body: any): Promise<IServerResponse<ITaskRecurringSchedule>> => {
return apiClient.put(`${rootUrl}/${schedule_id}`, body);
}
}

View File

@@ -5,6 +5,16 @@ import { ITaskLogViewModel } from "@/types/tasks/task-log-view.types";
const rootUrl = `${API_BASE_URL}/task-time-log`;
export interface IRunningTimer {
task_id: string;
start_time: string;
task_name: string;
project_id: string;
project_name: string;
parent_task_id?: string;
parent_task_name?: string;
}
export const taskTimeLogsApiService = {
getByTask: async (id: string) : Promise<IServerResponse<ITaskLogViewModel[]>> => {
const response = await apiClient.get(`${rootUrl}/task/${id}`);
@@ -26,6 +36,11 @@ export const taskTimeLogsApiService = {
return response.data;
},
getRunningTimers: async (): Promise<IServerResponse<IRunningTimer[]>> => {
const response = await apiClient.get(`${rootUrl}/running-timers`);
return response.data;
},
exportToExcel(taskId: string) {
window.location.href = `${import.meta.env.VITE_API_URL}${API_BASE_URL}/task-time-log/export/${taskId}`;
},

View File

@@ -8,7 +8,7 @@ type EmptyListPlaceholderProps = {
};
const EmptyListPlaceholder = ({
imageSrc = '/src/assets/images/empty-box.webp',
imageSrc = 'https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp',
imageHeight = 60,
text,
}: EmptyListPlaceholderProps) => {

View File

@@ -0,0 +1,24 @@
import { useEffect } from 'react';
const HubSpot = () => {
useEffect(() => {
const script = document.createElement('script');
script.type = 'text/javascript';
script.id = 'hs-script-loader';
script.async = true;
script.defer = true;
script.src = '//js.hs-scripts.com/22348300.js';
document.body.appendChild(script);
return () => {
const existingScript = document.getElementById('hs-script-loader');
if (existingScript) {
existingScript.remove();
}
};
}, []);
return null;
};
export default HubSpot;

View File

@@ -21,10 +21,10 @@ const HomeTasksStatusDropdown = ({ task, teamId }: HomeTasksStatusDropdownProps)
const { socket, connected } = useSocket();
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
const {
refetch
} = useGetMyTasksQuery(homeTasksConfig, {
skip: true // Skip automatic queries entirely
});
refetch
} = useGetMyTasksQuery(homeTasksConfig, {
skip: false, // Ensure this query runs
});
const [selectedStatus, setSelectedStatus] = useState<ITaskStatus | undefined>(undefined);

View File

@@ -23,14 +23,14 @@ const HomeTasksDatePicker = ({ record }: HomeTasksDatePickerProps) => {
const { t } = useTranslation('home');
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
skip: true // Skip automatic queries entirely
skip: false
});
// Use useMemo to avoid re-renders when record.end_date is the same
const initialDate = useMemo(() =>
const initialDate = useMemo(() =>
record.end_date ? dayjs(record.end_date) : null
, [record.end_date]);
, [record.end_date]);
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(initialDate);
// Update selected date when record changes

View File

@@ -0,0 +1,382 @@
import React, { useState, useMemo, useEffect } from 'react';
import {
Form,
Switch,
Button,
Popover,
Select,
Checkbox,
Radio,
InputNumber,
Skeleton,
Row,
Col,
} from 'antd';
import { SettingOutlined } from '@ant-design/icons';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { IRepeatOption, ITaskRecurring, ITaskRecurringSchedule, ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule';
import { ITaskViewModel } from '@/types/tasks/task.types';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { updateRecurringChange } from '@/features/tasks/tasks.slice';
import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service';
import logger from '@/utils/errorLogger';
import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice';
const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1);
const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
const { socket, connected } = useSocket();
const dispatch = useAppDispatch();
const { t } = useTranslation('task-drawer/task-drawer-recurring-config');
const repeatOptions: IRepeatOption[] = [
{ label: t('daily'), value: ITaskRecurring.Daily },
{ label: t('weekly'), value: ITaskRecurring.Weekly },
{ label: t('everyXDays'), value: ITaskRecurring.EveryXDays },
{ label: t('everyXWeeks'), value: ITaskRecurring.EveryXWeeks },
{ label: t('everyXMonths'), value: ITaskRecurring.EveryXMonths },
{ label: t('monthly'), value: ITaskRecurring.Monthly },
];
const daysOfWeek = [
{ label: t('sun'), value: 0, checked: false },
{ label: t('mon'), value: 1, checked: false },
{ label: t('tue'), value: 2, checked: false },
{ label: t('wed'), value: 3, checked: false },
{ label: t('thu'), value: 4, checked: false },
{ label: t('fri'), value: 5, checked: false },
{ label: t('sat'), value: 6, checked: false }
];
const weekOptions = [
{ label: t('first'), value: 1 },
{ label: t('second'), value: 2 },
{ label: t('third'), value: 3 },
{ label: t('fourth'), value: 4 },
{ label: t('last'), value: 5 }
];
const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value }));
const [recurring, setRecurring] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [repeatOption, setRepeatOption] = useState<IRepeatOption>({});
const [selectedDays, setSelectedDays] = useState<number[]>([]);
const [monthlyOption, setMonthlyOption] = useState('date');
const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1);
const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value);
const [selectedMonthlyDay, setSelectedMonthlyDay] = useState(dayOptions[0].value);
const [intervalDays, setIntervalDays] = useState(1);
const [intervalWeeks, setIntervalWeeks] = useState(1);
const [intervalMonths, setIntervalMonths] = useState(1);
const [loadingData, setLoadingData] = useState(false);
const [updatingData, setUpdatingData] = useState(false);
const [scheduleData, setScheduleData] = useState<ITaskRecurringSchedule>({});
const handleChange = (checked: boolean) => {
if (!task.id) return;
socket?.emit(SocketEvents.TASK_RECURRING_CHANGE.toString(), {
task_id: task.id,
schedule_id: task.schedule_id,
});
socket?.once(
SocketEvents.TASK_RECURRING_CHANGE.toString(),
(schedule: ITaskRecurringScheduleData) => {
if (schedule.id && schedule.schedule_type) {
const selected = repeatOptions.find(e => e.value == schedule.schedule_type);
if (selected) setRepeatOption(selected);
}
dispatch(updateRecurringChange(schedule));
dispatch(setTaskRecurringSchedule({ schedule_id: schedule.id as string, task_id: task.id }));
setRecurring(checked);
if (!checked) setShowConfig(false);
}
);
};
const configVisibleChange = (visible: boolean) => {
setShowConfig(visible);
};
const isMonthlySelected = useMemo(
() => repeatOption.value === ITaskRecurring.Monthly,
[repeatOption]
);
const handleDayCheckboxChange = (checkedValues: number[]) => {
setSelectedDays(checkedValues);
};
const getSelectedDays = () => {
return daysOfWeek
.filter(day => day.checked) // Get only the checked days
.map(day => day.value); // Extract their numeric values
}
const getUpdateBody = () => {
if (!task.id || !task.schedule_id || !repeatOption.value) return;
const body: ITaskRecurringSchedule = {
id: task.id,
schedule_type: repeatOption.value
};
switch (repeatOption.value) {
case ITaskRecurring.Weekly:
body.days_of_week = getSelectedDays();
break;
case ITaskRecurring.Monthly:
if (monthlyOption === 'date') {
body.date_of_month = selectedMonthlyDate;
setSelectedMonthlyDate(0);
setSelectedMonthlyDay(0);
} else {
body.week_of_month = selectedMonthlyWeek;
body.day_of_month = selectedMonthlyDay;
setSelectedMonthlyDate(0);
}
break;
case ITaskRecurring.EveryXDays:
body.interval_days = intervalDays;
break;
case ITaskRecurring.EveryXWeeks:
body.interval_weeks = intervalWeeks;
break;
case ITaskRecurring.EveryXMonths:
body.interval_months = intervalMonths;
break;
}
return body;
}
const handleSave = async () => {
if (!task.id || !task.schedule_id) return;
try {
setUpdatingData(true);
const body = getUpdateBody();
const res = await taskRecurringApiService.updateTaskRecurringData(task.schedule_id, body);
if (res.done) {
setRecurring(true);
setShowConfig(false);
configVisibleChange(false);
}
} catch (e) {
logger.error("handleSave", e);
} finally {
setUpdatingData(false);
}
};
const updateDaysOfWeek = () => {
for (let i = 0; i < daysOfWeek.length; i++) {
daysOfWeek[i].checked = scheduleData.days_of_week?.includes(daysOfWeek[i].value) ?? false;
}
};
const getScheduleData = async () => {
if (!task.schedule_id) return;
setLoadingData(true);
try {
const res = await taskRecurringApiService.getTaskRecurringData(task.schedule_id);
if (res.done) {
setScheduleData(res.body);
if (!res.body) {
setRepeatOption(repeatOptions[0]);
} else {
const selected = repeatOptions.find(e => e.value == res.body.schedule_type);
if (selected) {
setRepeatOption(selected);
setSelectedMonthlyDate(scheduleData.date_of_month || 1);
setSelectedMonthlyDay(scheduleData.day_of_month || 0);
setSelectedMonthlyWeek(scheduleData.week_of_month || 0);
setIntervalDays(scheduleData.interval_days || 1);
setIntervalWeeks(scheduleData.interval_weeks || 1);
setIntervalMonths(scheduleData.interval_months || 1);
setMonthlyOption(selectedMonthlyDate ? 'date' : 'day');
updateDaysOfWeek();
}
}
};
} catch (e) {
logger.error("getScheduleData", e);
}
finally {
setLoadingData(false);
}
}
const handleResponse = (response: ITaskRecurringScheduleData) => {
if (!task || !response.task_id) return;
};
useEffect(() => {
if (!task) return;
if (task) setRecurring(!!task.schedule_id);
if (task.schedule_id) void getScheduleData();
socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse);
}, [task?.schedule_id]);
return (
<div>
<Form.Item className="w-100 mb-2 align-form-item" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Switch checked={recurring} onChange={handleChange} />
&nbsp;
{recurring && (
<Popover
title={t('recurringTaskConfiguration')}
content={
<Skeleton loading={loadingData} active>
<Form layout="vertical">
<Form.Item label={t('repeats')}>
<Select
value={repeatOption.value}
onChange={val => {
const option = repeatOptions.find(opt => opt.value === val);
if (option) {
setRepeatOption(option);
}
}}
options={repeatOptions}
style={{ width: 200 }}
/>
</Form.Item>
{repeatOption.value === ITaskRecurring.Weekly && (
<Form.Item label={t('selectDaysOfWeek')}>
<Checkbox.Group
options={daysOfWeek.map(day => ({
label: day.label,
value: day.value
}))}
value={selectedDays}
onChange={handleDayCheckboxChange}
style={{ width: '100%' }}
>
<Row>
{daysOfWeek.map(day => (
<Col span={8} key={day.value}>
<Checkbox value={day.value}>{day.label}</Checkbox>
</Col>
))}
</Row>
</Checkbox.Group>
</Form.Item>
)}
{isMonthlySelected && (
<>
<Form.Item label={t('monthlyRepeatType')}>
<Radio.Group
value={monthlyOption}
onChange={e => setMonthlyOption(e.target.value)}
>
<Radio.Button value="date">{t('onSpecificDate')}</Radio.Button>
<Radio.Button value="day">{t('onSpecificDay')}</Radio.Button>
</Radio.Group>
</Form.Item>
{monthlyOption === 'date' && (
<Form.Item label={t('dateOfMonth')}>
<Select
value={selectedMonthlyDate}
onChange={setSelectedMonthlyDate}
options={monthlyDateOptions.map(date => ({
label: date.toString(),
value: date,
}))}
style={{ width: 120 }}
/>
</Form.Item>
)}
{monthlyOption === 'day' && (
<>
<Form.Item label={t('weekOfMonth')}>
<Select
value={selectedMonthlyWeek}
onChange={setSelectedMonthlyWeek}
options={weekOptions}
style={{ width: 150 }}
/>
</Form.Item>
<Form.Item label={t('dayOfWeek')}>
<Select
value={selectedMonthlyDay}
onChange={setSelectedMonthlyDay}
options={dayOptions}
style={{ width: 150 }}
/>
</Form.Item>
</>
)}
</>
)}
{repeatOption.value === ITaskRecurring.EveryXDays && (
<Form.Item label={t('intervalDays')}>
<InputNumber
min={1}
value={intervalDays}
onChange={value => value && setIntervalDays(value)}
/>
</Form.Item>
)}
{repeatOption.value === ITaskRecurring.EveryXWeeks && (
<Form.Item label={t('intervalWeeks')}>
<InputNumber
min={1}
value={intervalWeeks}
onChange={value => value && setIntervalWeeks(value)}
/>
</Form.Item>
)}
{repeatOption.value === ITaskRecurring.EveryXMonths && (
<Form.Item label={t('intervalMonths')}>
<InputNumber
min={1}
value={intervalMonths}
onChange={value => value && setIntervalMonths(value)}
/>
</Form.Item>
)}
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Button
type="primary"
size="small"
loading={updatingData}
onClick={handleSave}
>
{t('saveChanges')}
</Button>
</Form.Item>
</Form>
</Skeleton>
}
overlayStyle={{ width: 510 }}
open={showConfig}
onOpenChange={configVisibleChange}
trigger="click"
>
<Button type="link" loading={loadingData} style={{ padding: 0 }}>
{repeatOption.label} <SettingOutlined />
</Button>
</Popover>
)}
</div>
</Form.Item>
</div>
);
};
export default TaskDrawerRecurringConfig;

View File

@@ -29,6 +29,8 @@ import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billa
import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progress';
import { useAppSelector } from '@/hooks/useAppSelector';
import logger from '@/utils/errorLogger';
import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/task-drawer-recurring-config';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
interface TaskDetailsFormProps {
taskFormViewModel?: ITaskFormViewModel | null;
@@ -44,29 +46,32 @@ const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps)
const { project } = useAppSelector(state => state.projectReducer);
const hasSubTasks = task?.sub_tasks_count > 0;
const isSubTask = !!task?.parent_task_id;
// Add more aggressive logging and checks
logger.debug(`Task ${task.id} status: hasSubTasks=${hasSubTasks}, isSubTask=${isSubTask}, modes: time=${project?.use_time_progress}, manual=${project?.use_manual_progress}, weighted=${project?.use_weighted_progress}`);
// STRICT RULE: Never show progress input for parent tasks with subtasks
// This is the most important check and must be done first
if (hasSubTasks) {
logger.debug(`Task ${task.id} has ${task.sub_tasks_count} subtasks. Hiding progress input.`);
return null;
}
// Only for tasks without subtasks, determine which input to show based on project mode
if (project?.use_time_progress) {
// In time-based mode, show progress input ONLY for tasks without subtasks
return <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
return (
<TaskDrawerProgress task={{ ...task, sub_tasks_count: hasSubTasks ? 1 : 0 }} form={form} />
);
} else if (project?.use_manual_progress) {
// In manual mode, show progress input ONLY for tasks without subtasks
return <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
return (
<TaskDrawerProgress task={{ ...task, sub_tasks_count: hasSubTasks ? 1 : 0 }} form={form} />
);
} else if (project?.use_weighted_progress && isSubTask) {
// In weighted mode, show weight input for subtasks
return <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
return (
<TaskDrawerProgress task={{ ...task, sub_tasks_count: hasSubTasks ? 1 : 0 }} form={form} />
);
}
return null;
};
@@ -147,7 +152,13 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
<Form.Item name="assignees" label={t('taskInfoTab.details.assignees')}>
<Flex gap={4} align="center">
<Avatars members={taskFormViewModel?.task?.assignee_names || []} />
<Avatars
members={
taskFormViewModel?.task?.assignee_names ||
(taskFormViewModel?.task?.names as unknown as InlineMember[]) ||
[]
}
/>
<TaskDrawerAssigneeSelector
task={(taskFormViewModel?.task as ITaskViewModel) || null}
/>
@@ -159,10 +170,7 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
<TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} />
{taskFormViewModel?.task && (
<ConditionalProgressInput
task={taskFormViewModel?.task as ITaskViewModel}
form={form}
/>
<ConditionalProgressInput task={taskFormViewModel?.task as ITaskViewModel} form={form} />
)}
<Form.Item name="priority" label={t('taskInfoTab.details.priority')}>
@@ -175,6 +183,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
<TaskDrawerBillable task={taskFormViewModel?.task as ITaskViewModel} />
</Form.Item>
<Form.Item name="recurring" label={t('taskInfoTab.details.recurring')}>
<TaskDrawerRecurringConfig task={taskFormViewModel?.task as ITaskViewModel} />
</Form.Item>
<Form.Item name="notify" label={t('taskInfoTab.details.notify')}>
<NotifyMemberSelector task={taskFormViewModel?.task as ITaskViewModel} t={t} />
</Form.Item>

View File

@@ -27,6 +27,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
const { socket, connected } = useSocket();
const { clearTaskFromUrl } = useTaskDrawerUrlSync();
const isDeleting = useRef(false);
const [isEditing, setIsEditing] = useState(false);
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
const [taskName, setTaskName] = useState<string>(taskFormViewModel?.task?.name ?? '');
@@ -88,6 +89,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
};
const handleInputBlur = () => {
setIsEditing(false);
if (
!selectedTaskId ||
!connected ||
@@ -113,21 +115,39 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
return (
<Flex gap={12} align="center" style={{ marginBlockEnd: 6 }}>
<Flex style={{ position: 'relative', width: '100%' }}>
<Input
ref={inputRef}
size="large"
value={taskName}
onChange={e => onTaskNameChange(e)}
onBlur={handleInputBlur}
placeholder={t('taskHeader.taskNamePlaceholder')}
className="task-name-input"
style={{
width: '100%',
border: 'none',
}}
showCount={false}
maxLength={250}
/>
{isEditing ? (
<Input
ref={inputRef}
size="large"
value={taskName}
onChange={e => onTaskNameChange(e)}
onBlur={handleInputBlur}
placeholder={t('taskHeader.taskNamePlaceholder')}
className="task-name-input"
style={{
width: '100%',
border: 'none',
}}
showCount={true}
maxLength={250}
autoFocus
/>
) : (
<p
onClick={() => setIsEditing(true)}
style={{
margin: 0,
padding: '4px 11px',
fontSize: '16px',
cursor: 'pointer',
wordWrap: 'break-word',
overflowWrap: 'break-word',
width: '100%'
}}
>
{taskName || t('taskHeader.taskNamePlaceholder')}
</p>
)}
</Flex>
<TaskDrawerStatusDropdown

View File

@@ -12,7 +12,7 @@ import { ITaskViewModel } from '@/types/tasks/task.types';
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
import { Select } from 'antd';
import { useEffect, useMemo } from 'react';
import { useMemo } from 'react';
interface TaskDrawerStatusDropdownProps {
statuses: ITaskStatus[];
@@ -21,7 +21,7 @@ interface TaskDrawerStatusDropdownProps {
}
const TaskDrawerStatusDropdown = ({ statuses, task, teamId }: TaskDrawerStatusDropdownProps) => {
const { socket, connected } = useSocket();
const { socket } = useSocket();
const dispatch = useAppDispatch();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { tab } = useTabSearchParam();

View File

@@ -58,7 +58,7 @@ import alertService from '@/services/alerts/alertService';
interface ITaskAssignee {
id: string;
name?: string;
name: string;
email?: string;
avatar_url?: string;
team_member_id: string;
@@ -437,7 +437,7 @@ const TaskListBulkActionsBar = () => {
placement="top"
arrow
trigger={['click']}
destroyPopupOnHide
destroyOnHidden
onOpenChange={value => {
if (!value) {
setSelectedLabels([]);

View File

@@ -5,7 +5,6 @@ import { Col, ConfigProvider, Flex, Menu, MenuProps, Alert } from 'antd';
import { createPortal } from 'react-dom';
import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members';
import HelpButton from './help/HelpButton';
import InviteButton from './invite/InviteButton';
import MobileMenuButton from './mobileMenu/MobileMenuButton';
import NavbarLogo from './navbar-logo';
@@ -22,6 +21,7 @@ import { useAuthService } from '@/hooks/useAuth';
import { authApiService } from '@/api/auth/auth.api.service';
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
import logger from '@/utils/errorLogger';
import TimerButton from './timers/timer-button';
const Navbar = () => {
const [current, setCurrent] = useState<string>('home');
@@ -90,6 +90,7 @@ const Navbar = () => {
}, [location]);
return (
<Col
style={{
width: '100%',
@@ -101,14 +102,6 @@ const Navbar = () => {
justifyContent: 'space-between',
}}
>
{daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && (
<Alert
message={daysUntilExpiry > 0 ? `Your license will expire in ${daysUntilExpiry} days` : `Your license has expired ${Math.abs(daysUntilExpiry)} days ago`}
type="warning"
showIcon
style={{ width: '100%', marginTop: 12 }}
/>
)}
<Flex
style={{
width: '100%',
@@ -152,7 +145,7 @@ const Navbar = () => {
<Flex align="center">
<SwitchTeamButton />
<NotificationButton />
<HelpButton />
<TimerButton />
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
</Flex>
</Flex>

View File

@@ -0,0 +1,275 @@
import { ClockCircleOutlined, StopOutlined } from '@ant-design/icons';
import { Badge, Button, Dropdown, List, Tooltip, Typography, Space, Divider, theme } from 'antd';
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { taskTimeLogsApiService, IRunningTimer } from '@/api/tasks/task-time-logs.api.service';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice';
import moment from 'moment';
const { Text } = Typography;
const { useToken } = theme;
const TimerButton = () => {
const [runningTimers, setRunningTimers] = useState<IRunningTimer[]>([]);
const [loading, setLoading] = useState(false);
const [currentTimes, setCurrentTimes] = useState<Record<string, string>>({});
const [dropdownOpen, setDropdownOpen] = useState(false);
const { t } = useTranslation('navbar');
const { token } = useToken();
const dispatch = useAppDispatch();
const { socket } = useSocket();
const fetchRunningTimers = useCallback(async () => {
try {
setLoading(true);
const response = await taskTimeLogsApiService.getRunningTimers();
if (response.done) {
setRunningTimers(response.body || []);
}
} catch (error) {
console.error('Error fetching running timers:', error);
} finally {
setLoading(false);
}
}, []);
const updateCurrentTimes = () => {
const newTimes: Record<string, string> = {};
runningTimers.forEach(timer => {
const startTime = moment(timer.start_time);
const now = moment();
const duration = moment.duration(now.diff(startTime));
const hours = Math.floor(duration.asHours());
const minutes = duration.minutes();
const seconds = duration.seconds();
newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
setCurrentTimes(newTimes);
};
useEffect(() => {
fetchRunningTimers();
// Set up polling to refresh timers every 30 seconds
const pollInterval = setInterval(() => {
fetchRunningTimers();
}, 30000);
return () => clearInterval(pollInterval);
}, [fetchRunningTimers]);
useEffect(() => {
if (runningTimers.length > 0) {
updateCurrentTimes();
const interval = setInterval(updateCurrentTimes, 1000);
return () => clearInterval(interval);
}
}, [runningTimers]);
// Listen for timer start/stop events and project updates to refresh the count
useEffect(() => {
if (!socket) return;
const handleTimerStart = (data: string) => {
try {
const { id } = typeof data === 'string' ? JSON.parse(data) : data;
if (id) {
// Refresh the running timers list when a new timer is started
fetchRunningTimers();
}
} catch (error) {
console.error('Error parsing timer start event:', error);
}
};
const handleTimerStop = (data: string) => {
try {
const { id } = typeof data === 'string' ? JSON.parse(data) : data;
if (id) {
// Refresh the running timers list when a timer is stopped
fetchRunningTimers();
}
} catch (error) {
console.error('Error parsing timer stop event:', error);
}
};
const handleProjectUpdates = () => {
// Refresh timers when project updates are available
fetchRunningTimers();
};
socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
return () => {
socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
};
}, [socket, fetchRunningTimers]);
const hasRunningTimers = () => {
return runningTimers.length > 0;
};
const timerCount = () => {
return runningTimers.length;
};
const handleStopTimer = (taskId: string) => {
if (!socket) return;
socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId }));
dispatch(updateTaskTimeTracking({ taskId, timeTracking: null }));
};
const dropdownContent = (
<div
style={{
width: 350,
maxHeight: 400,
overflow: 'auto',
backgroundColor: token.colorBgElevated,
borderRadius: token.borderRadius,
boxShadow: token.boxShadowSecondary,
border: `1px solid ${token.colorBorderSecondary}`
}}
>
{runningTimers.length === 0 ? (
<div style={{ padding: 16, textAlign: 'center' }}>
<Text type="secondary">No running timers</Text>
</div>
) : (
<List
dataSource={runningTimers}
renderItem={(timer) => (
<List.Item
style={{
padding: '12px 16px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
backgroundColor: 'transparent'
}}
>
<div style={{ width: '100%' }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text strong style={{ fontSize: 14, color: token.colorText }}>
{timer.task_name}
</Text>
<div style={{
display: 'inline-block',
backgroundColor: token.colorPrimaryBg,
color: token.colorPrimary,
padding: '2px 8px',
borderRadius: token.borderRadiusSM,
fontSize: 11,
fontWeight: 500,
marginTop: 2
}}>
{timer.project_name}
</div>
{timer.parent_task_name && (
<Text type="secondary" style={{ fontSize: 11 }}>
Parent: {timer.parent_task_name}
</Text>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 11 }}>
Started: {moment(timer.start_time).format('HH:mm')}
</Text>
<Text
strong
style={{
fontSize: 14,
color: token.colorPrimary,
fontFamily: 'monospace'
}}
>
{currentTimes[timer.task_id] || '00:00:00'}
</Text>
</div>
</div>
<Button
size="small"
icon={<StopOutlined />}
onClick={(e) => {
e.stopPropagation();
handleStopTimer(timer.task_id);
}}
style={{
backgroundColor: token.colorErrorBg,
borderColor: token.colorError,
color: token.colorError,
fontWeight: 500
}}
>
Stop
</Button>
</div>
</Space>
</div>
</List.Item>
)}
/>
)}
{runningTimers.length > 0 && (
<>
<Divider style={{ margin: 0, borderColor: token.colorBorderSecondary }} />
<div
style={{
padding: '8px 16px',
textAlign: 'center',
backgroundColor: token.colorFillQuaternary,
borderBottomLeftRadius: token.borderRadius,
borderBottomRightRadius: token.borderRadius
}}
>
<Text type="secondary" style={{ fontSize: 11 }}>
{runningTimers.length} timer{runningTimers.length !== 1 ? 's' : ''} running
</Text>
</div>
</>
)}
</div>
);
return (
<Dropdown
popupRender={() => dropdownContent}
trigger={['click']}
placement="bottomRight"
open={dropdownOpen}
onOpenChange={(open) => {
setDropdownOpen(open);
if (open) {
fetchRunningTimers();
}
}}
>
<Tooltip title="Running Timers">
<Button
style={{ height: '62px', width: '60px' }}
type="text"
icon={
hasRunningTimers() ? (
<Badge count={timerCount()}>
<ClockCircleOutlined style={{ fontSize: 20 }} />
</Badge>
) : (
<ClockCircleOutlined style={{ fontSize: 20 }} />
)
}
loading={loading}
/>
</Tooltip>
</Dropdown>
);
};
export default TimerButton;

View File

@@ -105,6 +105,15 @@ const taskDrawerSlice = createSlice({
}>) => {
state.timeLogEditing = action.payload;
},
setTaskRecurringSchedule: (state, action: PayloadAction<{
schedule_id: string;
task_id: string;
}>) => {
const { schedule_id, task_id } = action.payload;
if (state.taskFormViewModel?.task && state.taskFormViewModel.task.id === task_id) {
state.taskFormViewModel.task.schedule_id = schedule_id;
}
},
},
extraReducers: builder => {
builder.addCase(fetchTask.pending, state => {
@@ -133,5 +142,6 @@ export const {
setTaskLabels,
setTaskSubscribers,
setTimeLogEditing,
setTaskRecurringSchedule
} = taskDrawerSlice.actions;
export default taskDrawerSlice.reducer;

View File

@@ -22,6 +22,7 @@ import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-respon
import { produce } from 'immer';
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
import { SocketEvents } from '@/shared/socket-events';
import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule';
export enum IGroupBy {
STATUS = 'status',
@@ -1006,6 +1007,15 @@ const taskSlice = createSlice({
column.pinned = isVisible;
}
},
updateRecurringChange: (state, action: PayloadAction<ITaskRecurringScheduleData>) => {
const {id, schedule_type, task_id} = action.payload;
const taskInfo = findTaskInGroups(state.taskGroups, task_id as string);
if (!taskInfo) return;
const { task } = taskInfo;
task.schedule_id = id;
}
},
extraReducers: builder => {
@@ -1165,6 +1175,7 @@ export const {
updateSubTasks,
updateCustomColumnValue,
updateCustomColumnPinned,
updateRecurringChange
} = taskSlice.actions;
export default taskSlice.reducer;

View File

@@ -0,0 +1,69 @@
import { useEffect, useCallback } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
import {
fetchLabelsByProject,
fetchTaskAssignees,
} from '@/features/tasks/tasks.slice';
import { getTeamMembers } from '@/features/team-members/team-members.slice';
/**
* Hook to manage filter data loading independently of main task list loading
* This ensures filter data loading doesn't block the main UI skeleton
*/
export const useFilterDataLoader = () => {
const dispatch = useAppDispatch();
const { priorities } = useAppSelector(state => ({
priorities: state.priorityReducer.priorities,
}));
const { projectId } = useAppSelector(state => ({
projectId: state.projectReducer.projectId,
}));
// Load filter data asynchronously
const loadFilterData = useCallback(async () => {
try {
// Load priorities if not already loaded (usually fast/cached)
if (!priorities.length) {
dispatch(fetchPriorities());
}
// Load project-specific data in parallel without blocking
if (projectId) {
// These dispatch calls are fire-and-forget
// They will update the UI when ready, but won't block initial render
dispatch(fetchLabelsByProject(projectId));
dispatch(fetchTaskAssignees(projectId));
}
// Load team members for member filters
dispatch(getTeamMembers({
index: 0,
size: 100,
field: null,
order: null,
search: null,
all: true
}));
} catch (error) {
console.error('Error loading filter data:', error);
// Don't throw - filter loading errors shouldn't break the main UI
}
}, [dispatch, priorities.length, projectId]);
// Load filter data on mount and when dependencies change
useEffect(() => {
// Use setTimeout to ensure this runs after the main component render
// This prevents filter loading from blocking the initial render
const timeoutId = setTimeout(loadFilterData, 0);
return () => clearTimeout(timeoutId);
}, [loadFilterData]);
return {
loadFilterData,
};
};

View File

@@ -0,0 +1,146 @@
import { useMemo, useCallback } from 'react';
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
KeyboardSensor,
TouchSensor,
} from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { updateTaskStatus } from '@/features/tasks/tasks.slice';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
export const useTaskDragAndDrop = () => {
const dispatch = useAppDispatch();
// Memoize the selector to prevent unnecessary rerenders
const taskGroups = useAppSelector(state => state.taskReducer.taskGroups);
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
// Memoize sensors configuration for better performance
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
const handleDragStart = useCallback((event: DragStartEvent) => {
// Add visual feedback for drag start
const { active } = event;
if (active) {
document.body.style.cursor = 'grabbing';
}
}, []);
const handleDragOver = useCallback((event: DragOverEvent) => {
// Handle drag over logic if needed
// This can be used for visual feedback during drag
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
// Reset cursor
document.body.style.cursor = '';
const { active, over } = event;
if (!active || !over || !taskGroups) {
return;
}
try {
const activeId = active.id as string;
const overId = over.id as string;
// Find the task being dragged
let draggedTask: IProjectTask | null = null;
let sourceGroupId: string | null = null;
for (const group of taskGroups) {
const task = group.tasks?.find((t: IProjectTask) => t.id === activeId);
if (task) {
draggedTask = task;
sourceGroupId = group.id;
break;
}
}
if (!draggedTask || !sourceGroupId) {
console.warn('Could not find dragged task');
return;
}
// Determine target group
let targetGroupId: string | null = null;
// Check if dropped on a group container
const targetGroup = taskGroups.find((group: ITaskListGroup) => group.id === overId);
if (targetGroup) {
targetGroupId = targetGroup.id;
} else {
// Check if dropped on another task
for (const group of taskGroups) {
const targetTask = group.tasks?.find((t: IProjectTask) => t.id === overId);
if (targetTask) {
targetGroupId = group.id;
break;
}
}
}
if (!targetGroupId || targetGroupId === sourceGroupId) {
return; // No change needed
}
// Update task status based on group change
const targetGroupData = taskGroups.find((group: ITaskListGroup) => group.id === targetGroupId);
if (targetGroupData && groupBy === 'status') {
const updatePayload: any = {
task_id: draggedTask.id,
status_id: targetGroupData.id,
};
if (draggedTask.parent_task_id) {
updatePayload.parent_task = draggedTask.parent_task_id;
}
dispatch(updateTaskStatus(updatePayload));
}
} catch (error) {
console.error('Error handling drag end:', error);
}
},
[taskGroups, groupBy, dispatch]
);
// Memoize the drag and drop configuration
const dragAndDropConfig = useMemo(
() => ({
sensors,
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragEnd: handleDragEnd,
}),
[sensors, handleDragStart, handleDragOver, handleDragEnd]
);
return dragAndDropConfig;
};

View File

@@ -0,0 +1,343 @@
import { useCallback, useEffect } 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 alertService from '@/services/alerts/alertService';
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
import { ILabelsChangeResponse } from '@/types/tasks/taskList.types';
import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types';
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-response';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import {
fetchTaskAssignees,
updateTaskAssignees,
fetchLabelsByProject,
updateTaskLabel,
updateTaskStatus,
updateTaskPriority,
updateTaskEndDate,
updateTaskEstimation,
updateTaskName,
updateTaskPhase,
updateTaskStartDate,
updateTaskDescription,
updateSubTasks,
updateTaskProgress,
} from '@/features/tasks/tasks.slice';
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
import {
setStartDate,
setTaskAssignee,
setTaskEndDate,
setTaskLabels,
setTaskPriority,
setTaskStatus,
setTaskSubscribers,
} from '@/features/task-drawer/task-drawer.slice';
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
export const useTaskSocketHandlers = () => {
const dispatch = useAppDispatch();
const { socket } = useSocket();
const currentSession = useAuthService().getCurrentSession();
const { loadingAssignees, taskGroups } = useAppSelector((state: any) => state.taskReducer);
const { projectId } = useAppSelector((state: any) => state.projectReducer);
// Memoize socket event handlers
const handleAssigneesUpdate = useCallback(
(data: ITaskAssigneesUpdateResponse) => {
if (!data) return;
const updatedAssignees = data.assignees?.map(assignee => ({
...assignee,
selected: true,
})) || [];
const groupId = taskGroups?.find((group: ITaskListGroup) =>
group.tasks?.some(
(task: IProjectTask) =>
task.id === data.id ||
(task.sub_tasks && task.sub_tasks.some((subtask: IProjectTask) => subtask.id === data.id))
)
)?.id;
if (groupId) {
dispatch(
updateTaskAssignees({
groupId,
taskId: data.id,
assignees: updatedAssignees,
})
);
dispatch(
setTaskAssignee({
...data,
manual_progress: false,
} as IProjectTask)
);
if (currentSession?.team_id && !loadingAssignees) {
dispatch(fetchTaskAssignees(currentSession.team_id));
}
}
},
[taskGroups, dispatch, currentSession?.team_id, loadingAssignees]
);
const handleLabelsChange = useCallback(
async (labels: ILabelsChangeResponse) => {
if (!labels) return;
await Promise.all([
dispatch(updateTaskLabel(labels)),
dispatch(setTaskLabels(labels)),
dispatch(fetchLabels()),
projectId && dispatch(fetchLabelsByProject(projectId)),
]);
},
[dispatch, projectId]
);
const handleTaskStatusChange = useCallback(
(response: ITaskListStatusChangeResponse) => {
if (!response) return;
if (response.completed_deps === false) {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
);
return;
}
dispatch(updateTaskStatus(response));
dispatch(deselectAll());
},
[dispatch]
);
const handleTaskProgress = useCallback(
(data: {
id: string;
status: string;
complete_ratio: number;
completed_count: number;
total_tasks_count: number;
parent_task: string;
}) => {
if (!data) return;
dispatch(
updateTaskProgress({
taskId: data.parent_task || data.id,
progress: data.complete_ratio,
totalTasksCount: data.total_tasks_count,
completedCount: data.completed_count,
})
);
},
[dispatch]
);
const handlePriorityChange = useCallback(
(response: ITaskListPriorityChangeResponse) => {
if (!response) return;
dispatch(updateTaskPriority(response));
dispatch(setTaskPriority(response));
dispatch(deselectAll());
},
[dispatch]
);
const handleEndDateChange = useCallback(
(task: {
id: string;
parent_task: string | null;
end_date: string;
}) => {
if (!task) return;
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
dispatch(updateTaskEndDate({ task: taskWithProgress }));
dispatch(setTaskEndDate(taskWithProgress));
},
[dispatch]
);
const handleTaskNameChange = useCallback(
(data: { id: string; parent_task: string; name: string }) => {
if (!data) return;
dispatch(updateTaskName(data));
},
[dispatch]
);
const handlePhaseChange = useCallback(
(data: ITaskPhaseChangeResponse) => {
if (!data) return;
dispatch(updateTaskPhase(data));
dispatch(deselectAll());
},
[dispatch]
);
const handleStartDateChange = useCallback(
(task: {
id: string;
parent_task: string | null;
start_date: string;
}) => {
if (!task) return;
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
dispatch(updateTaskStartDate({ task: taskWithProgress }));
dispatch(setStartDate(taskWithProgress));
},
[dispatch]
);
const handleTaskSubscribersChange = useCallback(
(data: InlineMember[]) => {
if (!data) return;
dispatch(setTaskSubscribers(data));
},
[dispatch]
);
const handleEstimationChange = useCallback(
(task: {
id: string;
parent_task: string | null;
estimation: number;
}) => {
if (!task) return;
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
dispatch(updateTaskEstimation({ task: taskWithProgress }));
},
[dispatch]
);
const handleTaskDescriptionChange = useCallback(
(data: {
id: string;
parent_task: string;
description: string;
}) => {
if (!data) return;
dispatch(updateTaskDescription(data));
},
[dispatch]
);
const handleNewTaskReceived = useCallback(
(data: IProjectTask) => {
if (!data) return;
if (data.parent_task_id) {
dispatch(updateSubTasks(data));
}
},
[dispatch]
);
const handleTaskProgressUpdated = useCallback(
(data: {
task_id: string;
progress_value?: number;
weight?: number;
}) => {
if (!data || !taskGroups) return;
if (data.progress_value !== undefined) {
for (const group of taskGroups) {
const task = group.tasks?.find((task: IProjectTask) => task.id === data.task_id);
if (task) {
dispatch(
updateTaskProgress({
taskId: data.task_id,
progress: data.progress_value,
totalTasksCount: task.total_tasks_count || 0,
completedCount: task.completed_count || 0,
})
);
break;
}
}
}
},
[dispatch, taskGroups]
);
// Register socket event listeners
useEffect(() => {
if (!socket) return;
const eventHandlers = [
{ event: SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handler: handleAssigneesUpdate },
{ event: SocketEvents.TASK_LABELS_CHANGE.toString(), handler: handleLabelsChange },
{ event: SocketEvents.TASK_STATUS_CHANGE.toString(), handler: handleTaskStatusChange },
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgress },
{ event: SocketEvents.TASK_PRIORITY_CHANGE.toString(), handler: handlePriorityChange },
{ event: SocketEvents.TASK_END_DATE_CHANGE.toString(), handler: handleEndDateChange },
{ event: SocketEvents.TASK_NAME_CHANGE.toString(), handler: handleTaskNameChange },
{ event: SocketEvents.TASK_PHASE_CHANGE.toString(), handler: handlePhaseChange },
{ event: SocketEvents.TASK_START_DATE_CHANGE.toString(), handler: handleStartDateChange },
{ event: SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handler: handleTaskSubscribersChange },
{ event: SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handler: handleEstimationChange },
{ event: SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handler: handleTaskDescriptionChange },
{ event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived },
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated },
];
// Register all event listeners
eventHandlers.forEach(({ event, handler }) => {
socket.on(event, handler);
});
// Cleanup function
return () => {
eventHandlers.forEach(({ event, handler }) => {
socket.off(event, handler);
});
};
}, [
socket,
handleAssigneesUpdate,
handleLabelsChange,
handleTaskStatusChange,
handleTaskProgress,
handlePriorityChange,
handleEndDateChange,
handleTaskNameChange,
handlePhaseChange,
handleStartDateChange,
handleTaskSubscribersChange,
handleEstimationChange,
handleTaskDescriptionChange,
handleNewTaskReceived,
handleTaskProgressUpdated,
]);
};

View File

@@ -58,9 +58,9 @@ html.light body {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial,
"Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji" !important;
font-family:
-apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial, "Noto Sans",
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !important;
}
/* helper classes */
@@ -145,3 +145,4 @@ Not supports in Firefox and IE */
tr:hover .action-buttons {
opacity: 1;
}

View File

@@ -7,7 +7,7 @@ import { colors } from '../styles/colors';
import { verifyAuthentication } from '@/features/auth/authSlice';
import { useEffect } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import TawkTo from '@/components/TawkTo';
import HubSpot from '@/components/HubSpot';
const MainLayout = () => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
@@ -68,9 +68,6 @@ const MainLayout = () => {
<Outlet />
</Col>
</Layout.Content>
{import.meta.env.VITE_APP_ENV === 'production' && (
<TawkTo propertyId="67ecc524f62fbf190db18bde" widgetId="1inqe45sq" />
)}
</Layout>
</ConfigProvider>
);

View File

@@ -132,7 +132,7 @@ const RecentAndFavouriteProjectList = () => {
<div style={{ maxHeight: 420, overflow: 'auto' }}>
{projectsData?.body?.length === 0 ? (
<Empty
image="https://app.worklenz.com/assets/images/empty-box.webp"
image="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
imageStyle={{ height: 60 }}
style={{
display: 'flex',

View File

@@ -89,7 +89,7 @@ const TasksList: React.FC = React.memo(() => {
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
}, [dispatch]);
const handleSelectTask = useCallback((task : IMyTask) => {
const handleSelectTask = useCallback((task: IMyTask) => {
dispatch(setSelectedTaskId(task.id || ''));
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
dispatch(setProjectId(task.project_id || ''));
@@ -155,7 +155,7 @@ const TasksList: React.FC = React.memo(() => {
render: (_, record) => {
return (
<Tooltip title={record.project_name}>
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth:120 }} ellipsis={{ tooltip: true }}>
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth: 120 }} ellipsis={{ tooltip: true }}>
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
{record.project_name}
</Typography.Paragraph>
@@ -259,7 +259,7 @@ const TasksList: React.FC = React.memo(() => {
<Skeleton active />
) : data?.body.total === 0 ? (
<EmptyListPlaceholder
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
text=" No tasks to show."
/>
) : (
@@ -271,10 +271,10 @@ const TasksList: React.FC = React.memo(() => {
columns={columns as TableProps<IMyTask>['columns']}
size="middle"
rowClassName={() => 'custom-row-height'}
loading={homeTasksFetching && !skipAutoRefetch}
loading={homeTasksFetching && skipAutoRefetch}
pagination={false}
/>
<div style={{ marginTop: 16, textAlign: 'right', display: 'flex', justifyContent: 'flex-end' }}>
<Pagination
current={currentPage}

View File

@@ -147,7 +147,7 @@ const TodoList = () => {
<div style={{ maxHeight: 420, overflow: 'auto' }}>
{data?.body.length === 0 ? (
<EmptyListPlaceholder
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
text={t('home:todoList.noTasks')}
/>
) : (

View File

@@ -263,7 +263,7 @@ const ProjectViewMembers = () => {
>
{members?.total === 0 ? (
<EmptyListPlaceholder
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
imageHeight={120}
text={t('emptyText')}
/>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { PushpinFilled, PushpinOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd';
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
@@ -43,6 +43,14 @@ const ProjectView = () => {
const [pinnedTab, setPinnedTab] = useState<string>(searchParams.get('pinned_tab') || '');
const [taskid, setTaskId] = useState<string>(searchParams.get('task') || '');
const resetProjectData = useCallback(() => {
dispatch(setProjectId(null));
dispatch(resetStatuses());
dispatch(deselectAll());
dispatch(resetTaskListData());
dispatch(resetBoardData());
}, [dispatch]);
useEffect(() => {
if (projectId) {
dispatch(setProjectId(projectId));
@@ -59,9 +67,13 @@ const ProjectView = () => {
dispatch(setSelectedTaskId(taskid || ''));
dispatch(setShowTaskDrawer(true));
}
}, [dispatch, navigate, projectId, taskid]);
const pinToDefaultTab = async (itemKey: string) => {
return () => {
resetProjectData();
};
}, [dispatch, navigate, projectId, taskid, resetProjectData]);
const pinToDefaultTab = useCallback(async (itemKey: string) => {
if (!itemKey || !projectId) return;
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
@@ -88,9 +100,9 @@ const ProjectView = () => {
}).toString(),
});
}
};
}, [projectId, activeTab, navigate]);
const handleTabChange = (key: string) => {
const handleTabChange = useCallback((key: string) => {
setActiveTab(key);
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
navigate({
@@ -100,9 +112,9 @@ const ProjectView = () => {
pinned_tab: pinnedTab,
}).toString(),
});
};
}, [dispatch, location.pathname, navigate, pinnedTab]);
const tabMenuItems = tabItems.map(item => ({
const tabMenuItems = useMemo(() => tabItems.map(item => ({
key: item.key,
label: (
<Flex align="center" style={{ color: colors.skyBlue }}>
@@ -144,21 +156,17 @@ const ProjectView = () => {
</Flex>
),
children: item.element,
}));
})), [pinnedTab, pinToDefaultTab]);
const resetProjectData = () => {
dispatch(setProjectId(null));
dispatch(resetStatuses());
dispatch(deselectAll());
dispatch(resetTaskListData());
dispatch(resetBoardData());
};
useEffect(() => {
return () => {
resetProjectData();
};
}, []);
const portalElements = useMemo(() => (
<>
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
</>
), []);
return (
<div style={{ marginBlockStart: 80, marginBlockEnd: 24, minHeight: '80vh' }}>
@@ -169,34 +177,12 @@ const ProjectView = () => {
onChange={handleTabChange}
items={tabMenuItems}
tabBarStyle={{ paddingInline: 0 }}
destroyInactiveTabPane={true}
// tabBarExtraContent={
// <div>
// <span style={{ position: 'relative', top: '-10px' }}>
// <Tooltip title="Members who are active on this project will be displayed here.">
// <QuestionCircleOutlined />
// </Tooltip>
// </span>
// <span
// style={{
// position: 'relative',
// right: '20px',
// top: '10px',
// }}
// >
// <Badge status="success" dot className="profile-badge" />
// </span>
// </div>
// }
destroyOnHidden={true}
/>
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
{portalElements}
</div>
);
};
export default ProjectView;
export default React.memo(ProjectView);

View File

@@ -0,0 +1,241 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDroppable } from '@dnd-kit/core';
import Flex from 'antd/es/flex';
import Badge from 'antd/es/badge';
import Button from 'antd/es/button';
import Dropdown from 'antd/es/dropdown';
import Input from 'antd/es/input';
import Typography from 'antd/es/typography';
import { MenuProps } from 'antd/es/menu';
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
import { colors } from '@/styles/colors';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import Collapsible from '@/components/collapsible/collapsible';
import TaskListTable from '../../task-list-table/task-list-table';
import { IGroupBy, updateTaskGroupColor } from '@/features/tasks/tasks.slice';
import { useAuthService } from '@/hooks/useAuth';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
import { ALPHA_CHANNEL } from '@/shared/constants';
import useIsProjectManager from '@/hooks/useIsProjectManager';
import logger from '@/utils/errorLogger';
interface TaskGroupProps {
taskGroup: ITaskListGroup;
groupBy: string;
color: string;
activeId?: string | null;
}
const TaskGroup: React.FC<TaskGroupProps> = ({
taskGroup,
groupBy,
color,
activeId
}) => {
const { t } = useTranslation('task-list-table');
const dispatch = useAppDispatch();
const { trackMixpanelEvent } = useMixpanelTracking();
const isProjectManager = useIsProjectManager();
const currentSession = useAuthService().getCurrentSession();
const [isExpanded, setIsExpanded] = useState(true);
const [isRenaming, setIsRenaming] = useState(false);
const [groupName, setGroupName] = useState(taskGroup.name || '');
const { projectId } = useAppSelector((state: any) => state.projectReducer);
const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
// Memoize droppable configuration
const { setNodeRef } = useDroppable({
id: taskGroup.id,
data: {
type: 'group',
groupId: taskGroup.id,
},
});
// Memoize task count
const taskCount = useMemo(() => taskGroup.tasks?.length || 0, [taskGroup.tasks]);
// Memoize dropdown items
const dropdownItems: MenuProps['items'] = useMemo(() => {
if (groupBy !== IGroupBy.STATUS || !isProjectManager) return [];
return [
{
key: 'rename',
label: t('renameText'),
icon: <EditOutlined />,
onClick: () => setIsRenaming(true),
},
{
key: 'change-category',
label: t('changeCategoryText'),
icon: <RetweetOutlined />,
children: [
{
key: 'todo',
label: t('todoText'),
onClick: () => handleStatusCategoryChange('0'),
},
{
key: 'doing',
label: t('doingText'),
onClick: () => handleStatusCategoryChange('1'),
},
{
key: 'done',
label: t('doneText'),
onClick: () => handleStatusCategoryChange('2'),
},
],
},
];
}, [groupBy, isProjectManager, t]);
const handleStatusCategoryChange = async (category: string) => {
if (!projectId || !taskGroup.id) return;
try {
await statusApiService.updateStatus({
id: taskGroup.id,
category_id: category,
project_id: projectId,
});
dispatch(fetchStatuses());
trackMixpanelEvent(evt_project_board_column_setting_click, {
column_id: taskGroup.id,
action: 'change_category',
category,
});
} catch (error) {
logger.error('Error updating status category:', error);
}
};
const handleRename = async () => {
if (!projectId || !taskGroup.id || !groupName.trim()) return;
try {
if (groupBy === IGroupBy.STATUS) {
await statusApiService.updateStatus({
id: taskGroup.id,
name: groupName.trim(),
project_id: projectId,
});
dispatch(fetchStatuses());
} else if (groupBy === IGroupBy.PHASE) {
const phaseData: ITaskPhase = {
id: taskGroup.id,
name: groupName.trim(),
project_id: projectId,
color_code: taskGroup.color_code,
};
await phasesApiService.updatePhase(phaseData);
dispatch(fetchPhasesByProjectId(projectId));
}
setIsRenaming(false);
} catch (error) {
logger.error('Error renaming group:', error);
}
};
const handleColorChange = async (newColor: string) => {
if (!projectId || !taskGroup.id) return;
try {
const baseColor = newColor.endsWith(ALPHA_CHANNEL)
? newColor.slice(0, -ALPHA_CHANNEL.length)
: newColor;
if (groupBy === IGroupBy.PHASE) {
const phaseData: ITaskPhase = {
id: taskGroup.id,
name: taskGroup.name || '',
project_id: projectId,
color_code: baseColor,
};
await phasesApiService.updatePhase(phaseData);
dispatch(fetchPhasesByProjectId(projectId));
}
dispatch(updateTaskGroupColor({
groupId: taskGroup.id,
color: baseColor,
}));
} catch (error) {
logger.error('Error updating group color:', error);
}
};
return (
<div ref={setNodeRef}>
<Flex vertical>
{/* Group Header */}
<Flex style={{ transform: 'translateY(6px)' }}>
<Button
className="custom-collapse-button"
style={{
backgroundColor: color,
border: 'none',
borderBottomLeftRadius: isExpanded ? 0 : 4,
borderBottomRightRadius: isExpanded ? 0 : 4,
color: colors.darkGray,
minWidth: 200,
}}
icon={<RightOutlined rotate={isExpanded ? 90 : 0} />}
onClick={() => setIsExpanded(!isExpanded)}
>
{isRenaming ? (
<Input
size="small"
value={groupName}
onChange={e => setGroupName(e.target.value)}
onBlur={handleRename}
onPressEnter={handleRename}
onClick={e => e.stopPropagation()}
autoFocus
/>
) : (
<Typography.Text style={{ fontSize: 14, fontWeight: 600 }}>
{taskGroup.name} ({taskCount})
</Typography.Text>
)}
</Button>
{dropdownItems.length > 0 && !isRenaming && (
<Dropdown menu={{ items: dropdownItems }} trigger={['click']}>
<Button icon={<EllipsisOutlined />} className="borderless-icon-btn" />
</Dropdown>
)}
</Flex>
{/* Task List */}
<Collapsible isOpen={isExpanded}>
<TaskListTable
taskList={taskGroup.tasks || []}
tableId={taskGroup.id}
groupBy={groupBy}
color={color}
activeId={activeId}
/>
</Collapsible>
</Flex>
</div>
);
};
export default React.memo(TaskGroup);

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import Flex from 'antd/es/flex';
import Skeleton from 'antd/es/skeleton';
import { useSearchParams } from 'react-router-dom';
import TaskListFilters from './task-list-filters/task-list-filters';
import TaskGroupWrapper from './task-list-table/task-group-wrapper/task-group-wrapper';
import TaskGroupWrapperOptimized from './task-group-wrapper-optimized';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice';
@@ -17,64 +17,99 @@ const ProjectViewTaskList = () => {
const dispatch = useAppDispatch();
const { projectView } = useTabSearchParam();
const [searchParams, setSearchParams] = useSearchParams();
// Add local loading state to immediately show skeleton
const [isLoading, setIsLoading] = useState(true);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const { projectId } = useAppSelector(state => state.projectReducer);
const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector(
state => state.taskReducer
);
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
state => state.taskStatusReducer
);
const { loadingPhases } = useAppSelector(state => state.phaseReducer);
const { loadingColumns } = useAppSelector(state => state.taskReducer);
// Split selectors to prevent unnecessary rerenders
const projectId = useAppSelector(state => state.projectReducer.projectId);
const taskGroups = useAppSelector(state => state.taskReducer.taskGroups);
const loadingGroups = useAppSelector(state => state.taskReducer.loadingGroups);
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
const archived = useAppSelector(state => state.taskReducer.archived);
const fields = useAppSelector(state => state.taskReducer.fields);
const search = useAppSelector(state => state.taskReducer.search);
const statusCategories = useAppSelector(state => state.taskStatusReducer.statusCategories);
const loadingStatusCategories = useAppSelector(state => state.taskStatusReducer.loading);
const loadingPhases = useAppSelector(state => state.phaseReducer.loadingPhases);
// Single source of truth for loading state - EXCLUDE labels loading from skeleton
// Labels loading should not block the main task list display
const isLoading = useMemo(() =>
loadingGroups || loadingPhases || loadingStatusCategories || !initialLoadComplete,
[loadingGroups, loadingPhases, loadingStatusCategories, initialLoadComplete]
);
// Memoize the empty state check
const isEmptyState = useMemo(() =>
taskGroups && taskGroups.length === 0 && !isLoading,
[taskGroups, isLoading]
);
// Handle view type changes
useEffect(() => {
// Set default view to list if projectView is not list or board
if (projectView !== 'list' && projectView !== 'board') {
searchParams.set('tab', 'tasks-list');
searchParams.set('pinned_tab', 'tasks-list');
setSearchParams(searchParams);
const newParams = new URLSearchParams(searchParams);
newParams.set('tab', 'tasks-list');
newParams.set('pinned_tab', 'tasks-list');
setSearchParams(newParams);
}
}, [projectView, searchParams, setSearchParams]);
}, [projectView, setSearchParams, searchParams]);
// Batch initial data fetching - core data only
useEffect(() => {
// Set loading state based on all loading conditions
setIsLoading(loadingGroups || loadingColumns || loadingPhases || loadingStatusCategories);
}, [loadingGroups, loadingColumns, loadingPhases, loadingStatusCategories]);
const fetchInitialData = async () => {
if (!projectId || !groupBy || initialLoadComplete) return;
useEffect(() => {
const loadData = async () => {
if (projectId && groupBy) {
const promises = [];
if (!loadingColumns) promises.push(dispatch(fetchTaskListColumns(projectId)));
if (!loadingPhases) promises.push(dispatch(fetchPhasesByProjectId(projectId)));
if (!loadingGroups && projectView === 'list') {
promises.push(dispatch(fetchTaskGroups(projectId)));
}
if (!statusCategories.length) {
promises.push(dispatch(fetchStatusesCategories()));
}
// Wait for all data to load
await Promise.all(promises);
try {
// Batch only essential API calls for initial load
// Filter data (labels, assignees, etc.) will load separately and not block the UI
await Promise.allSettled([
dispatch(fetchTaskListColumns(projectId)),
dispatch(fetchPhasesByProjectId(projectId)),
dispatch(fetchStatusesCategories()),
]);
setInitialLoadComplete(true);
} catch (error) {
console.error('Error fetching initial data:', error);
setInitialLoadComplete(true); // Still mark as complete to prevent infinite loading
}
};
loadData();
}, [dispatch, projectId, groupBy, fields, search, archived]);
fetchInitialData();
}, [projectId, groupBy, dispatch, initialLoadComplete]);
// Fetch task groups with dependency on initial load completion
useEffect(() => {
const fetchTasks = async () => {
if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return;
try {
await dispatch(fetchTaskGroups(projectId));
} catch (error) {
console.error('Error fetching task groups:', error);
}
};
fetchTasks();
}, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]);
// Memoize the task groups to prevent unnecessary re-renders
const memoizedTaskGroups = useMemo(() => taskGroups || [], [taskGroups]);
return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
{/* Filters load independently and don't block the main content */}
<TaskListFilters position="list" />
{(taskGroups && taskGroups.length === 0 && !isLoading) ? (
{isEmptyState ? (
<Empty description="No tasks group found" />
) : (
<Skeleton active loading={isLoading} className='mt-4 p-4'>
<TaskGroupWrapper taskGroups={taskGroups} groupBy={groupBy} />
<TaskGroupWrapperOptimized
taskGroups={memoizedTaskGroups}
groupBy={groupBy}
/>
</Skeleton>
)}
</Flex>

View File

@@ -0,0 +1,112 @@
import React, { useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import Flex from 'antd/es/flex';
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
import {
DndContext,
pointerWithin,
} from '@dnd-kit/core';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { useAppSelector } from '@/hooks/useAppSelector';
import TaskListTableWrapper from './task-list-table/task-list-table-wrapper/task-list-table-wrapper';
import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { useTaskDragAndDrop } from '@/hooks/useTaskDragAndDrop';
interface TaskGroupWrapperOptimizedProps {
taskGroups: ITaskListGroup[];
groupBy: string;
}
const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOptimizedProps) => {
const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
// Use extracted hooks
useTaskSocketHandlers();
const {
activeId,
sensors,
handleDragStart,
handleDragEnd,
handleDragOver,
resetTaskRowStyles,
} = useTaskDragAndDrop({ taskGroups, groupBy });
// Memoize task groups with colors
const taskGroupsWithColors = useMemo(() =>
taskGroups?.map(taskGroup => ({
...taskGroup,
displayColor: themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code,
})) || [],
[taskGroups, themeMode]
);
// Add drag styles
useEffect(() => {
const style = document.createElement('style');
style.textContent = `
.task-row[data-is-dragging="true"] {
opacity: 0.5 !important;
transform: rotate(5deg) !important;
z-index: 1000 !important;
position: relative !important;
}
.task-row {
transition: transform 0.2s ease, opacity 0.2s ease;
}
`;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, []);
// Handle animation cleanup after drag ends
useIsomorphicLayoutEffect(() => {
if (activeId === null) {
const timeoutId = setTimeout(resetTaskRowStyles, 50);
return () => clearTimeout(timeoutId);
}
}, [activeId, resetTaskRowStyles]);
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
>
<Flex gap={24} vertical>
{taskGroupsWithColors.map(taskGroup => (
<TaskListTableWrapper
key={taskGroup.id}
taskList={taskGroup.tasks}
tableId={taskGroup.id}
name={taskGroup.name}
groupBy={groupBy}
statusCategory={taskGroup.category_id}
color={taskGroup.displayColor}
activeId={activeId}
/>
))}
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
{createPortal(
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
document.body,
'task-template-drawer'
)}
</Flex>
</DndContext>
);
};
export default React.memo(TaskGroupWrapperOptimized);

View File

@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
import {
fetchLabelsByProject,
fetchTaskAssignees,
@@ -33,23 +34,49 @@ const TaskListFilters: React.FC<TaskListFiltersProps> = ({ position }) => {
const { projectView } = useTabSearchParam();
const priorities = useAppSelector(state => state.priorityReducer.priorities);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const archived = useAppSelector(state => state.taskReducer.archived);
const handleShowArchivedChange = () => dispatch(toggleArchived());
// Load filter data asynchronously and non-blocking
// This runs independently of the main task list loading
useEffect(() => {
const fetchInitialData = async () => {
if (!priorities.length) await dispatch(fetchPriorities());
if (projectId) {
await dispatch(fetchLabelsByProject(projectId));
await dispatch(fetchTaskAssignees(projectId));
const loadFilterData = async () => {
try {
// Load priorities first (usually cached/fast)
if (!priorities.length) {
dispatch(fetchPriorities());
}
// Load project-specific filter data in parallel, but don't await
// This allows the main task list to load while filters are still loading
if (projectId) {
// Fire and forget - these will update the UI when ready
dispatch(fetchLabelsByProject(projectId));
dispatch(fetchTaskAssignees(projectId));
}
// Load team members (usually needed for member filters)
dispatch(getTeamMembers({
index: 0,
size: 100,
field: null,
order: null,
search: null,
all: true
}));
} catch (error) {
console.error('Error loading filter data:', error);
// Don't throw - filter loading errors shouldn't break the main UI
}
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
};
fetchInitialData();
// Use setTimeout to ensure this runs after the main component render
// This prevents filter loading from blocking the initial render
const timeoutId = setTimeout(loadFilterData, 0);
return () => clearTimeout(timeoutId);
}, [dispatch, priorities.length, projectId]);
return (

View File

@@ -2,7 +2,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import Flex from 'antd/es/flex';
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
@@ -87,16 +87,22 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees);
const { projectId } = useAppSelector(state => state.projectReducer);
const sensors = useSensors(
useSensor(PointerSensor, {
// Move useSensors to top level and memoize its configuration
const sensorConfig = useMemo(
() => ({
activationConstraint: { distance: 8 },
})
}),
[]
);
const pointerSensor = useSensor(PointerSensor, sensorConfig);
const sensors = useSensors(pointerSensor);
useEffect(() => {
setGroups(taskGroups);
}, [taskGroups]);
// Memoize resetTaskRowStyles to prevent unnecessary re-renders
const resetTaskRowStyles = useCallback(() => {
document.querySelectorAll<HTMLElement>('.task-row').forEach(row => {
row.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
@@ -106,21 +112,18 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
});
}, []);
// Socket handler for assignee updates
useEffect(() => {
if (!socket) return;
const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
// Memoize socket event handlers
const handleAssigneesUpdate = useCallback(
(data: ITaskAssigneesUpdateResponse) => {
if (!data) return;
const updatedAssignees = data.assignees.map(assignee => ({
const updatedAssignees = data.assignees?.map(assignee => ({
...assignee,
selected: true,
}));
})) || [];
// Find the group that contains the task or its subtasks
const groupId = groups.find(group =>
group.tasks.some(
const groupId = groups?.find(group =>
group.tasks?.some(
task =>
task.id === data.id ||
(task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id))
@@ -136,47 +139,41 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
})
);
dispatch(setTaskAssignee(data));
dispatch(
setTaskAssignee({
...data,
manual_progress: false,
} as IProjectTask)
);
if (currentSession?.team_id && !loadingAssignees) {
dispatch(fetchTaskAssignees(currentSession.team_id));
}
}
};
},
[groups, dispatch, currentSession?.team_id, loadingAssignees]
);
socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
return () => {
socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
};
}, [socket, currentSession?.team_id, loadingAssignees, groups, dispatch]);
// Socket handler for label updates
useEffect(() => {
if (!socket) return;
const handleLabelsChange = async (labels: ILabelsChangeResponse) => {
// Memoize socket event handlers
const handleLabelsChange = useCallback(
async (labels: ILabelsChangeResponse) => {
if (!labels) return;
await Promise.all([
dispatch(updateTaskLabel(labels)),
dispatch(setTaskLabels(labels)),
dispatch(fetchLabels()),
projectId && dispatch(fetchLabelsByProject(projectId)),
]);
};
},
[dispatch, projectId]
);
socket.on(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
socket.on(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
// Memoize socket event handlers
const handleTaskStatusChange = useCallback(
(response: ITaskListStatusChangeResponse) => {
if (!response) return;
return () => {
socket.off(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
socket.off(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
};
}, [socket, dispatch, projectId]);
// Socket handler for status updates
useEffect(() => {
if (!socket) return;
const handleTaskStatusChange = (response: ITaskListStatusChangeResponse) => {
if (response.completed_deps === false) {
alertService.error(
'Task is not completed',
@@ -186,11 +183,14 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
}
dispatch(updateTaskStatus(response));
// dispatch(setTaskStatus(response));
dispatch(deselectAll());
};
},
[dispatch]
);
const handleTaskProgress = (data: {
// Memoize socket event handlers
const handleTaskProgress = useCallback(
(data: {
id: string;
status: string;
complete_ratio: number;
@@ -198,6 +198,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
total_tasks_count: number;
parent_task: string;
}) => {
if (!data) return;
dispatch(
updateTaskProgress({
taskId: data.parent_task || data.id,
@@ -206,190 +208,150 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
completedCount: data.completed_count,
})
);
};
},
[dispatch]
);
socket.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
// Memoize socket event handlers
const handlePriorityChange = useCallback(
(response: ITaskListPriorityChangeResponse) => {
if (!response) return;
return () => {
socket.off(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
socket.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
};
}, [socket, dispatch]);
// Socket handler for priority updates
useEffect(() => {
if (!socket) return;
const handlePriorityChange = (response: ITaskListPriorityChangeResponse) => {
dispatch(updateTaskPriority(response));
dispatch(setTaskPriority(response));
dispatch(deselectAll());
};
},
[dispatch]
);
socket.on(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
return () => {
socket.off(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
};
}, [socket, dispatch]);
// Socket handler for due date updates
useEffect(() => {
if (!socket) return;
const handleEndDateChange = (task: {
// Memoize socket event handlers
const handleEndDateChange = useCallback(
(task: {
id: string;
parent_task: string | null;
end_date: string;
}) => {
dispatch(updateTaskEndDate({ task }));
dispatch(setTaskEndDate(task));
};
if (!task) return;
socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
return () => {
socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
};
}, [socket, dispatch]);
dispatch(updateTaskEndDate({ task: taskWithProgress }));
dispatch(setTaskEndDate(taskWithProgress));
},
[dispatch]
);
// Socket handler for task name updates
useEffect(() => {
if (!socket) return;
// Memoize socket event handlers
const handleTaskNameChange = useCallback(
(data: { id: string; parent_task: string; name: string }) => {
if (!data) return;
const handleTaskNameChange = (data: { id: string; parent_task: string; name: string }) => {
dispatch(updateTaskName(data));
};
},
[dispatch]
);
socket.on(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
// Memoize socket event handlers
const handlePhaseChange = useCallback(
(data: ITaskPhaseChangeResponse) => {
if (!data) return;
return () => {
socket.off(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
};
}, [socket, dispatch]);
// Socket handler for phase updates
useEffect(() => {
if (!socket) return;
const handlePhaseChange = (data: ITaskPhaseChangeResponse) => {
dispatch(updateTaskPhase(data));
dispatch(deselectAll());
};
},
[dispatch]
);
socket.on(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
return () => {
socket.off(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
};
}, [socket, dispatch]);
// Socket handler for start date updates
useEffect(() => {
if (!socket) return;
const handleStartDateChange = (task: {
// Memoize socket event handlers
const handleStartDateChange = useCallback(
(task: {
id: string;
parent_task: string | null;
start_date: string;
}) => {
dispatch(updateTaskStartDate({ task }));
dispatch(setStartDate(task));
};
if (!task) return;
socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
return () => {
socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
};
}, [socket, dispatch]);
dispatch(updateTaskStartDate({ task: taskWithProgress }));
dispatch(setStartDate(taskWithProgress));
},
[dispatch]
);
// Socket handler for task subscribers updates
useEffect(() => {
if (!socket) return;
// Memoize socket event handlers
const handleTaskSubscribersChange = useCallback(
(data: InlineMember[]) => {
if (!data) return;
const handleTaskSubscribersChange = (data: InlineMember[]) => {
dispatch(setTaskSubscribers(data));
};
},
[dispatch]
);
socket.on(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
return () => {
socket.off(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
};
}, [socket, dispatch]);
// Socket handler for task estimation updates
useEffect(() => {
if (!socket) return;
const handleEstimationChange = (task: {
// Memoize socket event handlers
const handleEstimationChange = useCallback(
(task: {
id: string;
parent_task: string | null;
estimation: number;
}) => {
dispatch(updateTaskEstimation({ task }));
};
if (!task) return;
socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
return () => {
socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
};
}, [socket, dispatch]);
dispatch(updateTaskEstimation({ task: taskWithProgress }));
},
[dispatch]
);
// Socket handler for task description updates
useEffect(() => {
if (!socket) return;
const handleTaskDescriptionChange = (data: {
// Memoize socket event handlers
const handleTaskDescriptionChange = useCallback(
(data: {
id: string;
parent_task: string;
description: string;
}) => {
if (!data) return;
dispatch(updateTaskDescription(data));
};
},
[dispatch]
);
socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
return () => {
socket.off(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
};
}, [socket, dispatch]);
// Socket handler for new task creation
useEffect(() => {
if (!socket) return;
const handleNewTaskReceived = (data: IProjectTask) => {
// Memoize socket event handlers
const handleNewTaskReceived = useCallback(
(data: IProjectTask) => {
if (!data) return;
if (data.parent_task_id) {
dispatch(updateSubTasks(data));
}
};
},
[dispatch]
);
socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
return () => {
socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
};
}, [socket, dispatch]);
// Socket handler for task progress updates
useEffect(() => {
if (!socket) return;
const handleTaskProgressUpdated = (data: {
// Memoize socket event handlers
const handleTaskProgressUpdated = useCallback(
(data: {
task_id: string;
progress_value?: number;
weight?: number;
}) => {
if (!data || !taskGroups) return;
if (data.progress_value !== undefined) {
// Find the task in the task groups and update its progress
for (const group of taskGroups) {
const task = group.tasks.find(task => task.id === data.task_id);
const task = group.tasks?.find(task => task.id === data.task_id);
if (task) {
dispatch(
updateTaskProgress({
@@ -403,25 +365,76 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
}
}
}
},
[dispatch, taskGroups]
);
// Set up socket event listeners
useEffect(() => {
if (!socket) return;
const eventHandlers = {
[SocketEvents.QUICK_ASSIGNEES_UPDATE.toString()]: handleAssigneesUpdate,
[SocketEvents.TASK_LABELS_CHANGE.toString()]: handleLabelsChange,
[SocketEvents.CREATE_LABEL.toString()]: handleLabelsChange,
[SocketEvents.TASK_STATUS_CHANGE.toString()]: handleTaskStatusChange,
[SocketEvents.GET_TASK_PROGRESS.toString()]: handleTaskProgress,
[SocketEvents.TASK_PRIORITY_CHANGE.toString()]: handlePriorityChange,
[SocketEvents.TASK_END_DATE_CHANGE.toString()]: handleEndDateChange,
[SocketEvents.TASK_NAME_CHANGE.toString()]: handleTaskNameChange,
[SocketEvents.TASK_PHASE_CHANGE.toString()]: handlePhaseChange,
[SocketEvents.TASK_START_DATE_CHANGE.toString()]: handleStartDateChange,
[SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString()]: handleTaskSubscribersChange,
[SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString()]: handleEstimationChange,
[SocketEvents.TASK_DESCRIPTION_CHANGE.toString()]: handleTaskDescriptionChange,
[SocketEvents.QUICK_TASK.toString()]: handleNewTaskReceived,
[SocketEvents.TASK_PROGRESS_UPDATED.toString()]: handleTaskProgressUpdated,
};
socket.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated);
// Register all event handlers
Object.entries(eventHandlers).forEach(([event, handler]) => {
if (handler) {
socket.on(event, handler);
}
});
// Cleanup function
return () => {
socket.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated);
Object.entries(eventHandlers).forEach(([event, handler]) => {
if (handler) {
socket.off(event, handler);
}
});
};
}, [socket, dispatch, taskGroups]);
}, [
socket,
handleAssigneesUpdate,
handleLabelsChange,
handleTaskStatusChange,
handleTaskProgress,
handlePriorityChange,
handleEndDateChange,
handleTaskNameChange,
handlePhaseChange,
handleStartDateChange,
handleTaskSubscribersChange,
handleEstimationChange,
handleTaskDescriptionChange,
handleNewTaskReceived,
handleTaskProgressUpdated,
]);
// Memoize drag handlers
const handleDragStart = useCallback(({ active }: DragStartEvent) => {
setActiveId(active.id as string);
// Add smooth transition to the dragged item
const draggedElement = document.querySelector(`[data-id="${active.id}"]`);
if (draggedElement) {
(draggedElement as HTMLElement).style.transition = 'transform 0.2s ease';
}
}, []);
// Memoize drag handlers
const handleDragEnd = useCallback(
async ({ active, over }: DragEndEvent) => {
setActiveId(null);
@@ -440,10 +453,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
if (fromIndex === -1) return;
// Create a deep clone of the task to avoid reference issues
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
// Check if task dependencies allow the move
if (activeGroupId !== overGroupId) {
const canContinue = await checkTaskDependencyStatus(task.id, overGroupId);
if (!canContinue) {
@@ -455,7 +466,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
return;
}
// Update task properties based on target group
switch (groupBy) {
case IGroupBy.STATUS:
task.status = overGroupId;
@@ -468,35 +478,29 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
task.priority_color_dark = targetGroup.color_code_dark;
break;
case IGroupBy.PHASE:
// Check if ALPHA_CHANNEL is already added
const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL)
? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) // Remove ALPHA_CHANNEL
: targetGroup.color_code; // Use as is if not present
? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length)
: targetGroup.color_code;
task.phase_id = overGroupId;
task.phase_color = baseColor; // Set the cleaned color
task.phase_color = baseColor;
break;
}
}
const isTargetGroupEmpty = targetGroup.tasks.length === 0;
// Calculate toIndex - for empty groups, always add at index 0
const toIndex = isTargetGroupEmpty
? 0
: overTaskId
? targetGroup.tasks.findIndex(t => t.id === overTaskId)
: targetGroup.tasks.length;
// Calculate toPos similar to Angular implementation
const toPos = isTargetGroupEmpty
? -1
: targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
// Update Redux state
if (activeGroupId === overGroupId) {
// Same group - move within array
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(fromIndex, 1);
updatedTasks.splice(toIndex, 0, task);
@@ -514,7 +518,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
},
});
} else {
// Different groups - transfer between arrays
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
const updatedTargetTasks = [...targetGroup.tasks];
@@ -540,7 +543,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
});
}
// Emit socket event
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
project_id: projectId,
from_index: sourceGroup.tasks[fromIndex].sort_order,
@@ -549,13 +551,11 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
from_group: sourceGroup.id,
to_group: targetGroup.id,
group_by: groupBy,
task: sourceGroup.tasks[fromIndex], // Send original task to maintain references
task: sourceGroup.tasks[fromIndex],
team_id: currentSession?.team_id,
});
// Reset styles
setTimeout(resetTaskRowStyles, 0);
trackMixpanelEvent(evt_project_task_list_drag_and_move);
},
[
@@ -570,6 +570,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
]
);
// Memoize drag handlers
const handleDragOver = useCallback(
({ active, over }: DragEndEvent) => {
if (!over) return;
@@ -589,12 +590,9 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
if (fromIndex === -1 || toIndex === -1) return;
// Create a deep clone of the task to avoid reference issues
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
// Update Redux state
if (activeGroupId === overGroupId) {
// Same group - move within array
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(fromIndex, 1);
updatedTasks.splice(toIndex, 0, task);
@@ -612,10 +610,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
},
});
} else {
// Different groups - transfer between arrays
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
const updatedTargetTasks = [...targetGroup.tasks];
updatedTargetTasks.splice(toIndex, 0, task);
dispatch({
@@ -663,7 +659,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
// Handle animation cleanup after drag ends
useIsomorphicLayoutEffect(() => {
if (activeId === null) {
// Final cleanup after React updates DOM
const timeoutId = setTimeout(resetTaskRowStyles, 50);
return () => clearTimeout(timeoutId);
}

View File

@@ -81,6 +81,22 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
display: false,
position: 'top' as const,
},
tooltip: {
callbacks: {
label: function(context: any) {
const idx = context.dataIndex;
const member = jsonData[idx];
const hours = member?.utilized_hours || '0.00';
const percent = member?.utilization_percent || '0.00';
const overUnder = member?.over_under_utilized_hours || '0.00';
return [
`${context.dataset.label}: ${hours} h`,
`Utilization: ${percent}%`,
`Over/Under Utilized: ${overUnder} h`
];
}
}
}
},
backgroundColor: 'black',
indexAxis: 'y' as const,

View File

@@ -406,6 +406,9 @@ export interface IRPTTimeMember {
value?: number;
color_code: string;
logged_time?: string;
utilized_hours?: string;
utilization_percent?: string;
over_under_utilized_hours?: string;
}
export interface IMemberTaskStatGroupResonse {

View File

@@ -0,0 +1,37 @@
export enum ITaskRecurring {
Daily = 'daily',
Weekly = 'weekly',
Monthly = 'monthly',
EveryXDays = 'every_x_days',
EveryXWeeks = 'every_x_weeks',
EveryXMonths = 'every_x_months'
}
export interface ITaskRecurringSchedule {
created_at?: string;
day_of_month?: number | null;
date_of_month?: number | null;
days_of_week?: number[] | null;
id?: string; // UUID v4
interval_days?: number | null;
interval_months?: number | null;
interval_weeks?: number | null;
schedule_type?: ITaskRecurring;
week_of_month?: number | null;
}
export interface IRepeatOption {
value?: ITaskRecurring
label?: string
}
export interface ITaskRecurringScheduleData {
task_id?: string,
id?: string,
schedule_type?: string
}
export interface IRepeatOption {
value?: ITaskRecurring
label?: string
}

View File

@@ -64,6 +64,7 @@ export interface ITaskViewModel extends ITask {
timer_start_time?: number;
recurring?: boolean;
task_level?: number;
schedule_id?: string | null;
}
export interface ITaskTeamMember extends ITeamMember {