Merge branch 'feature/project-finance' of https://github.com/Worklenz/worklenz into feature/project-finance
This commit is contained in:
60
docs/recurring-tasks-user-guide.md
Normal file
60
docs/recurring-tasks-user-guide.md
Normal 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
104
docs/recurring-tasks.md
Normal 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.
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
|
||||
135
worklenz-backend/src/controllers/project-finance-controller.ts
Normal file
135
worklenz-backend/src/controllers/project-finance-controller.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getTasks(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const { project_id } = req.params;
|
||||
const { group_by = "status" } = req.query;
|
||||
|
||||
const q = `
|
||||
WITH task_data AS (
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.status_id,
|
||||
t.priority_id,
|
||||
tp.phase_id,
|
||||
(t.total_minutes / 3600.0) as estimated_hours,
|
||||
(COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0) as actual_hours,
|
||||
t.completed_at,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
t.billable,
|
||||
s.name as status_name,
|
||||
p.name as priority_name,
|
||||
ph.name as phase_name,
|
||||
(SELECT color_code FROM sys_task_status_categories WHERE id = s.category_id) as status_color,
|
||||
(SELECT color_code_dark FROM sys_task_status_categories WHERE id = s.category_id) as status_color_dark,
|
||||
(SELECT color_code FROM task_priorities WHERE id = t.priority_id) as priority_color,
|
||||
(SELECT color_code FROM project_phases WHERE id = tp.phase_id) as phase_color,
|
||||
(SELECT get_task_assignees(t.id)) as assignees,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'name', u.name,
|
||||
'avatar_url', u.avatar_url,
|
||||
'team_member_id', tm.id,
|
||||
'color_code', '#1890ff'
|
||||
)
|
||||
) FILTER (WHERE u.id IS NOT NULL) as members
|
||||
FROM tasks t
|
||||
LEFT JOIN task_statuses s ON t.status_id = s.id
|
||||
LEFT JOIN task_priorities p ON t.priority_id = p.id
|
||||
LEFT JOIN task_phase tp ON t.id = tp.task_id
|
||||
LEFT JOIN project_phases ph ON tp.phase_id = ph.id
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
LEFT JOIN project_members pm ON ta.project_member_id = pm.id
|
||||
LEFT JOIN team_members tm ON pm.team_member_id = tm.id
|
||||
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
LEFT JOIN job_titles jt ON tm.job_title_id = jt.id
|
||||
WHERE t.project_id = $1
|
||||
GROUP BY
|
||||
t.id,
|
||||
s.name,
|
||||
p.name,
|
||||
ph.name,
|
||||
tp.phase_id,
|
||||
s.category_id,
|
||||
t.priority_id
|
||||
)
|
||||
SELECT
|
||||
CASE
|
||||
WHEN $2 = 'status' THEN status_id
|
||||
WHEN $2 = 'priority' THEN priority_id
|
||||
WHEN $2 = 'phases' THEN phase_id
|
||||
END as group_id,
|
||||
CASE
|
||||
WHEN $2 = 'status' THEN status_name
|
||||
WHEN $2 = 'priority' THEN priority_name
|
||||
WHEN $2 = 'phases' THEN phase_name
|
||||
END as group_name,
|
||||
CASE
|
||||
WHEN $2 = 'status' THEN status_color
|
||||
WHEN $2 = 'priority' THEN priority_color
|
||||
WHEN $2 = 'phases' THEN phase_color
|
||||
END as color_code,
|
||||
CASE
|
||||
WHEN $2 = 'status' THEN status_color_dark
|
||||
WHEN $2 = 'priority' THEN priority_color
|
||||
WHEN $2 = 'phases' THEN phase_color
|
||||
END as color_code_dark,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'status_id', status_id,
|
||||
'priority_id', priority_id,
|
||||
'phase_id', phase_id,
|
||||
'estimated_hours', estimated_hours,
|
||||
'actual_hours', actual_hours,
|
||||
'completed_at', completed_at,
|
||||
'created_at', created_at,
|
||||
'updated_at', updated_at,
|
||||
'billable', billable,
|
||||
'assignees', assignees,
|
||||
'members', members
|
||||
)
|
||||
) as tasks
|
||||
FROM task_data
|
||||
GROUP BY
|
||||
CASE
|
||||
WHEN $2 = 'status' THEN status_id
|
||||
WHEN $2 = 'priority' THEN priority_id
|
||||
WHEN $2 = 'phases' THEN phase_id
|
||||
END,
|
||||
CASE
|
||||
WHEN $2 = 'status' THEN status_name
|
||||
WHEN $2 = 'priority' THEN priority_name
|
||||
WHEN $2 = 'phases' THEN phase_name
|
||||
END,
|
||||
CASE
|
||||
WHEN $2 = 'status' THEN status_color
|
||||
WHEN $2 = 'priority' THEN priority_color
|
||||
WHEN $2 = 'phases' THEN phase_color
|
||||
END,
|
||||
CASE
|
||||
WHEN $2 = 'status' THEN status_color_dark
|
||||
WHEN $2 = 'priority' THEN priority_color
|
||||
WHEN $2 = 'phases' THEN phase_color
|
||||
END
|
||||
ORDER BY group_name;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [project_id, group_by]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -60,6 +60,7 @@ import taskRecurringApiRouter from "./task-recurring-api-router";
|
||||
import customColumnsApiRouter from "./custom-columns-api-router";
|
||||
import ratecardApiRouter from "./ratecard-api-router";
|
||||
import projectRatecardApiRouter from "./project-ratecard-api-router";
|
||||
import projectFinanceApiRouter from "./project-finance-api-router";
|
||||
|
||||
const api = express.Router();
|
||||
|
||||
@@ -122,4 +123,6 @@ api.use("/task-recurring", taskRecurringApiRouter);
|
||||
|
||||
api.use("/custom-columns", customColumnsApiRouter);
|
||||
|
||||
api.use("/project-finance", projectFinanceApiRouter);
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import express from "express";
|
||||
|
||||
import ProjectfinanceController from "../../controllers/project-finance-controller";
|
||||
|
||||
const projectFinanceApiRouter = express.Router();
|
||||
|
||||
projectFinanceApiRouter.get("/project/:project_id/tasks", ProjectfinanceController.getTasks);
|
||||
|
||||
export default projectFinanceApiRouter;
|
||||
@@ -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>
|
||||
© 2025 Worklenz. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
© 2025 Worklenz. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
© 2025 Worklenz. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
© 2025 Worklenz. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
© 2025 Worklenz. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { API_BASE_URL } from "@/shared/constants";
|
||||
import { IServerResponse } from "@/types/common.types";
|
||||
import apiClient from "../api-client";
|
||||
import { IProjectFinanceGroup } from "@/types/project/project-finance.types";
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/project-finance`;
|
||||
|
||||
export const projectFinanceApiService = {
|
||||
getProjectTasks: async (
|
||||
projectId: string,
|
||||
groupBy: 'status' | 'priority' | 'phases' = 'status'
|
||||
): Promise<IServerResponse<IProjectFinanceGroup[]>> => {
|
||||
const response = await apiClient.get<IServerResponse<IProjectFinanceGroup[]>>(
|
||||
`${rootUrl}/project/${projectId}/tasks`,
|
||||
{
|
||||
params: { group_by: groupBy }
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
24
worklenz-frontend/src/components/HubSpot.tsx
Normal file
24
worklenz-frontend/src/components/HubSpot.tsx
Normal 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;
|
||||
@@ -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} />
|
||||
|
||||
{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;
|
||||
@@ -29,6 +29,7 @@ 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';
|
||||
|
||||
interface TaskDetailsFormProps {
|
||||
taskFormViewModel?: ITaskFormViewModel | null;
|
||||
@@ -175,6 +176,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,42 +1,41 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React from 'react';
|
||||
import FinanceTableWrapper from './finance-table/finance-table-wrapper';
|
||||
import { fetchData } from '../../../../../utils/fetchData';
|
||||
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
|
||||
|
||||
interface FinanceTabProps {
|
||||
groupType: 'status' | 'priority' | 'phases';
|
||||
taskGroups: IProjectFinanceGroup[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const FinanceTab = ({
|
||||
groupType,
|
||||
}: {
|
||||
groupType: 'status' | 'priority' | 'phases';
|
||||
}) => {
|
||||
// Save each table's list according to the groups
|
||||
const [statusTables, setStatusTables] = useState<any[]>([]);
|
||||
const [priorityTables, setPriorityTables] = useState<any[]>([]);
|
||||
const [activeTablesList, setActiveTablesList] = useState<any[]>([]);
|
||||
|
||||
// Fetch data for status tables
|
||||
useMemo(() => {
|
||||
fetchData('/finance-mock-data/finance-task-status.json', setStatusTables);
|
||||
}, []);
|
||||
|
||||
// Fetch data for priority tables
|
||||
useMemo(() => {
|
||||
fetchData(
|
||||
'/finance-mock-data/finance-task-priority.json',
|
||||
setPriorityTables
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Update activeTablesList based on groupType and fetched data
|
||||
useEffect(() => {
|
||||
if (groupType === 'status') {
|
||||
setActiveTablesList(statusTables);
|
||||
} else if (groupType === 'priority') {
|
||||
setActiveTablesList(priorityTables);
|
||||
}
|
||||
}, [groupType, priorityTables, statusTables]);
|
||||
taskGroups,
|
||||
loading
|
||||
}: FinanceTabProps) => {
|
||||
// Transform taskGroups into the format expected by FinanceTableWrapper
|
||||
const activeTablesList = taskGroups.map(group => ({
|
||||
id: group.group_id,
|
||||
name: group.group_name,
|
||||
color_code: group.color_code,
|
||||
color_code_dark: group.color_code_dark,
|
||||
tasks: group.tasks.map(task => ({
|
||||
taskId: task.id,
|
||||
task: task.name,
|
||||
hours: task.estimated_hours || 0,
|
||||
cost: 0, // TODO: Calculate based on rate and hours
|
||||
fixedCost: 0, // TODO: Add fixed cost field
|
||||
totalBudget: 0, // TODO: Calculate total budget
|
||||
totalActual: task.actual_hours || 0,
|
||||
variance: 0, // TODO: Calculate variance
|
||||
members: task.members || [],
|
||||
isbBillable: task.billable
|
||||
}))
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FinanceTableWrapper activeTablesList={activeTablesList} />
|
||||
<FinanceTableWrapper activeTablesList={activeTablesList} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { Card, Col, Row, Spin } from "antd";
|
||||
import { useThemeContext } from "../../../../../context/theme-context";
|
||||
import { FinanceTable } from "./finance-table";
|
||||
import { IFinanceTable } from "./finance-table.interface";
|
||||
import { IProjectFinanceGroup } from "../../../../../types/project/project-finance.types";
|
||||
|
||||
interface Props {
|
||||
activeTablesList: IProjectFinanceGroup[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const FinanceTableWrapper: React.FC<Props> = ({ activeTablesList, loading }) => {
|
||||
const { isDarkMode } = useThemeContext();
|
||||
|
||||
const getTableColor = (table: IProjectFinanceGroup) => {
|
||||
return isDarkMode ? table.color_code_dark : table.color_code;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="finance-table-wrapper">
|
||||
<Row gutter={[16, 16]}>
|
||||
{activeTablesList.map((table) => (
|
||||
<Col key={table.group_id} xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Card
|
||||
className="finance-table-card"
|
||||
style={{
|
||||
borderTop: `3px solid ${getTableColor(table)}`,
|
||||
}}
|
||||
>
|
||||
<div className="finance-table-header">
|
||||
<h3>{table.group_name}</h3>
|
||||
</div>
|
||||
<FinanceTable
|
||||
table={table as unknown as IFinanceTable}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,28 +9,43 @@ import { financeTableColumns } from '@/lib/project/project-view-finance-table-co
|
||||
import FinanceTable from './finance-table';
|
||||
import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer';
|
||||
|
||||
const FinanceTableWrapper = ({
|
||||
interface FinanceTableWrapperProps {
|
||||
activeTablesList: {
|
||||
id: string;
|
||||
name: string;
|
||||
color_code: string;
|
||||
color_code_dark: string;
|
||||
tasks: {
|
||||
taskId: string;
|
||||
task: string;
|
||||
hours: number;
|
||||
cost: number;
|
||||
fixedCost: number;
|
||||
totalBudget: number;
|
||||
totalActual: number;
|
||||
variance: number;
|
||||
members: any[];
|
||||
isbBillable: boolean;
|
||||
}[];
|
||||
}[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
|
||||
activeTablesList,
|
||||
}: {
|
||||
activeTablesList: any;
|
||||
loading
|
||||
}) => {
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
|
||||
//? this state for inside this state individualy in finance table only display the data of the last table's task when a task is clicked The selectedTask state does not synchronize across tables so thats why move the selectedTask state to a parent component
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('project-view-finance');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// function on task click
|
||||
const onTaskClick = (task: any) => {
|
||||
setSelectedTask(task);
|
||||
dispatch(toggleFinanceDrawer());
|
||||
};
|
||||
|
||||
// trigger the table scrolling
|
||||
useEffect(() => {
|
||||
const tableContainer = document.querySelector('.tasklist-container');
|
||||
const handleScroll = () => {
|
||||
@@ -39,22 +54,15 @@ const FinanceTableWrapper = ({
|
||||
}
|
||||
};
|
||||
|
||||
// add the scroll event listener
|
||||
tableContainer?.addEventListener('scroll', handleScroll);
|
||||
|
||||
// cleanup on unmount
|
||||
return () => {
|
||||
tableContainer?.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// get theme data from theme reducer
|
||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||
|
||||
// get tasklist and currently using currency from finance reducer
|
||||
const { currency } = useAppSelector((state) => state.financeReducer);
|
||||
|
||||
// totals of all the tasks
|
||||
const totals = activeTablesList.reduce(
|
||||
(
|
||||
acc: {
|
||||
@@ -135,7 +143,6 @@ const FinanceTableWrapper = ({
|
||||
}
|
||||
};
|
||||
|
||||
// layout styles for table and the columns
|
||||
const customColumnHeaderStyles = (key: string) =>
|
||||
`px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
|
||||
|
||||
@@ -233,7 +240,7 @@ const FinanceTableWrapper = ({
|
||||
)}
|
||||
</tr>
|
||||
|
||||
{activeTablesList.map((table: any, index: number) => (
|
||||
{activeTablesList.map((table, index) => (
|
||||
<FinanceTable
|
||||
key={index}
|
||||
table={table}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { themeWiseColor } from '../../../../../../utils/themeWiseColor';
|
||||
import { colors } from '../../../../../../styles/colors';
|
||||
import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns';
|
||||
import Avatars from '@/components/avatars/avatars';
|
||||
|
||||
type FinanceTableProps = {
|
||||
table: any;
|
||||
@@ -132,11 +133,7 @@ const FinanceTable = ({
|
||||
);
|
||||
case 'members':
|
||||
return (
|
||||
<Avatar.Group>
|
||||
{task.members.map((member: any) => (
|
||||
<CustomAvatar avatarName={member.name} size={26} />
|
||||
))}
|
||||
</Avatar.Group>
|
||||
task?.assignees && <Avatars members={task.assignees} />
|
||||
);
|
||||
case 'hours':
|
||||
return <Typography.Text>{task.hours}</Typography.Text>;
|
||||
|
||||
@@ -1,15 +1,53 @@
|
||||
import { Flex } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import ProjectViewFinanceHeader from './project-view-finance-header/project-view-finance-header';
|
||||
import FinanceTab from './finance-tab/finance-tab';
|
||||
import RatecardTab from './ratecard-tab/ratecard-tab';
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
|
||||
|
||||
type FinanceTabType = 'finance' | 'ratecard';
|
||||
type GroupTypes = 'status' | 'priority' | 'phases';
|
||||
|
||||
interface TaskGroup {
|
||||
group_id: string;
|
||||
group_name: string;
|
||||
tasks: any[];
|
||||
}
|
||||
|
||||
interface FinanceTabProps {
|
||||
groupType: GroupTypes;
|
||||
taskGroups: TaskGroup[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const ProjectViewFinance = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [activeTab, setActiveTab] = useState<FinanceTabType>('finance');
|
||||
const [activeGroup, setActiveGroup] = useState<GroupTypes>('status');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [taskGroups, setTaskGroups] = useState<IProjectFinanceGroup[]>([]);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await projectFinanceApiService.getProjectTasks(projectId, activeGroup);
|
||||
if (response.done) {
|
||||
setTaskGroups(response.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, [projectId, activeGroup]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||
@@ -21,7 +59,11 @@ const ProjectViewFinance = () => {
|
||||
/>
|
||||
|
||||
{activeTab === 'finance' ? (
|
||||
<FinanceTab groupType={activeGroup} />
|
||||
<FinanceTab
|
||||
groupType={activeGroup}
|
||||
taskGroups={taskGroups}
|
||||
loading={loading}
|
||||
/>
|
||||
) : (
|
||||
<RatecardTab />
|
||||
)}
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -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' }}>
|
||||
@@ -170,33 +178,11 @@ const ProjectView = () => {
|
||||
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>
|
||||
// }
|
||||
/>
|
||||
|
||||
{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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
45
worklenz-frontend/src/types/project/project-finance.types.ts
Normal file
45
worklenz-frontend/src/types/project/project-finance.types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export interface IProjectFinanceUser {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceJobTitle {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceMember {
|
||||
id: string;
|
||||
team_member_id: string;
|
||||
job_title_id: string;
|
||||
rate: number | null;
|
||||
user: IProjectFinanceUser;
|
||||
job_title: IProjectFinanceJobTitle;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status_id: string;
|
||||
priority_id: string;
|
||||
phase_id: string;
|
||||
estimated_hours: number;
|
||||
actual_hours: number;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
billable: boolean;
|
||||
assignees: any[]; // Using any[] since we don't have the assignee structure yet
|
||||
members: IProjectFinanceMember[];
|
||||
}
|
||||
|
||||
export interface IProjectFinanceGroup {
|
||||
group_id: string;
|
||||
group_name: string;
|
||||
color_code: string;
|
||||
color_code_dark: string;
|
||||
tasks: IProjectFinanceTask[];
|
||||
}
|
||||
|
||||
export type ProjectFinanceGroupType = 'status' | 'priority' | 'phases';
|
||||
@@ -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 {
|
||||
|
||||
37
worklenz-frontend/src/types/tasks/task-recurring-schedule.ts
Normal file
37
worklenz-frontend/src/types/tasks/task-recurring-schedule.ts
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user