Compare commits
47 Commits
feature/me
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46acb26c42 | ||
|
|
13a202cca4 | ||
|
|
926c058d1e | ||
|
|
1583221232 | ||
|
|
2de9b7f6b7 | ||
|
|
323b17185c | ||
|
|
6002ab7c50 | ||
|
|
bd77733935 | ||
|
|
09f44a5685 | ||
|
|
cfa0af24ae | ||
|
|
69f5009579 | ||
|
|
24fa837a39 | ||
|
|
5e4d78c6f5 | ||
|
|
837692e808 | ||
|
|
6ffdbc64d0 | ||
|
|
65af5f659e | ||
|
|
f38a7b4d56 | ||
|
|
f4ab7841fb | ||
|
|
3de4f69a62 | ||
|
|
102be2c24a | ||
|
|
3a39b25e64 | ||
|
|
32248f8424 | ||
|
|
80797e043c | ||
|
|
312c6b5be8 | ||
|
|
c18889a127 | ||
|
|
c1e923c703 | ||
|
|
f716971654 | ||
|
|
d7ca1d8bd2 | ||
|
|
8704b6a8c8 | ||
|
|
4687478704 | ||
|
|
2bdae400ac | ||
|
|
0cb0efe43e | ||
|
|
7e431d645a | ||
|
|
cef4bffd69 | ||
|
|
84c7428fed | ||
|
|
a568ee808f | ||
|
|
69b910f2a4 | ||
|
|
f9858fbd4b | ||
|
|
f3a7fd8be5 | ||
|
|
49bdd00dac | ||
|
|
2e985bd051 | ||
|
|
8e74f1ddb5 | ||
|
|
753e3be83f | ||
|
|
ebd0f66768 | ||
|
|
d333104f43 | ||
|
|
62548e5c37 | ||
|
|
75391641fd |
11
README.md
11
README.md
@@ -1,6 +1,6 @@
|
|||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
|
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
|
||||||
<img src="https://app.worklenz.com/assets/icons/icon-144x144.png" alt="Worklenz Logo" width="75">
|
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/icon-144x144.png" alt="Worklenz Logo" width="75">
|
||||||
</a>
|
</a>
|
||||||
<br>
|
<br>
|
||||||
Worklenz
|
Worklenz
|
||||||
@@ -315,6 +315,7 @@ docker-compose up -d
|
|||||||
docker-compose down
|
docker-compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## MinIO Integration
|
## MinIO Integration
|
||||||
|
|
||||||
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
|
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
|
||||||
@@ -403,6 +404,10 @@ This script generates properly configured environment files for both development
|
|||||||
- Frontend: http://localhost:5000
|
- Frontend: http://localhost:5000
|
||||||
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
|
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
|
||||||
|
|
||||||
|
4. Video Guide
|
||||||
|
|
||||||
|
For a visual walkthrough of the local Docker deployment process, check out our [step-by-step video guide](https://www.youtube.com/watch?v=AfwAKxJbqLg).
|
||||||
|
|
||||||
### Remote Server Deployment
|
### Remote Server Deployment
|
||||||
|
|
||||||
When deploying to a remote server:
|
When deploying to a remote server:
|
||||||
@@ -428,6 +433,10 @@ When deploying to a remote server:
|
|||||||
- Frontend: http://your-server-hostname:5000
|
- Frontend: http://your-server-hostname:5000
|
||||||
- Backend API: http://your-server-hostname:3000
|
- Backend API: http://your-server-hostname:3000
|
||||||
|
|
||||||
|
4. Video Guide
|
||||||
|
|
||||||
|
For a complete walkthrough of deploying Worklenz to a remote server, check out our [deployment video guide](https://www.youtube.com/watch?v=CAZGu2iOXQs&t=10s).
|
||||||
|
|
||||||
### Environment Configuration
|
### Environment Configuration
|
||||||
|
|
||||||
The Docker setup uses environment variables to configure the services:
|
The Docker setup uses environment variables to configure the services:
|
||||||
|
|||||||
@@ -16,24 +16,45 @@ Recurring tasks are tasks that repeat automatically on a schedule you choose. Th
|
|||||||
5. Save the task. It will now be created automatically based on your chosen schedule.
|
5. Save the task. It will now be created automatically based on your chosen schedule.
|
||||||
|
|
||||||
## Schedule Options
|
## Schedule Options
|
||||||
You can choose how often your task repeats. Here are the most common options:
|
You can choose how often your task repeats. Here are the available options:
|
||||||
|
|
||||||
- **Daily:** The task is created every day.
|
- **Daily:** The task is created every day.
|
||||||
- **Weekly:** The task is created once a week. You can pick the day (e.g., every Monday).
|
- **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 can pick the date (e.g., the 1st of every month).
|
- **Monthly:** The task is created once a month. You have two options:
|
||||||
- **Weekdays:** The task is created every Monday to Friday.
|
- **On a specific date:** Choose a date from 1 to 28 (limited to 28 to ensure consistency across all months)
|
||||||
- **Custom:** Set your own schedule, such as every 2 days, every 3 weeks, or only on certain days.
|
- **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
|
### Examples
|
||||||
- "Send team update" every Friday (weekly)
|
- "Send team update" every Friday (weekly)
|
||||||
- "Submit expense report" on the 1st of each month (monthly)
|
- "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)
|
- "Check backups" every day (daily)
|
||||||
- "Review project status" every Monday and Thursday (custom)
|
- "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
|
## Tips
|
||||||
- You can edit or stop a recurring task at any time.
|
- You can edit or stop a recurring task at any time.
|
||||||
- Assign team members and labels to recurring tasks for better organization.
|
- Assign team members and labels to recurring tasks for better organization.
|
||||||
- Check your task list regularly to see newly created recurring tasks.
|
- 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?
|
## Need Help?
|
||||||
If you have questions or need help setting up recurring tasks, contact your workspace admin or support team.
|
If you have questions or need help setting up recurring tasks, contact your workspace admin or support team.
|
||||||
@@ -17,6 +17,51 @@ The recurring tasks cron job automates the creation of tasks based on predefined
|
|||||||
3. Checks if a task for the next occurrence already exists.
|
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.
|
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
|
## Database Interactions
|
||||||
- **Templates and Schedules:**
|
- **Templates and Schedules:**
|
||||||
- Templates are stored in `task_recurring_templates`.
|
- Templates are stored in `task_recurring_templates`.
|
||||||
@@ -27,6 +72,7 @@ The recurring tasks cron job automates the creation of tasks based on predefined
|
|||||||
- Assigns team members and labels by calling appropriate functions/controllers.
|
- Assigns team members and labels by calling appropriate functions/controllers.
|
||||||
- **State Tracking:**
|
- **State Tracking:**
|
||||||
- Updates `last_checked_at` and `last_created_task_end_date` in the schedule after processing.
|
- 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
|
## Task Creation Process
|
||||||
1. **Fetch Templates:** Retrieve all templates and their associated schedules.
|
1. **Fetch Templates:** Retrieve all templates and their associated schedules.
|
||||||
@@ -41,10 +87,12 @@ The recurring tasks cron job automates the creation of tasks based on predefined
|
|||||||
- **Cron Expression:** Modify the `TIME` constant in the code to change the schedule.
|
- **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 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.
|
- **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
|
## Error Handling
|
||||||
- Errors are logged using the `log_error` utility.
|
- Errors are logged using the `log_error` utility.
|
||||||
- The job continues processing other templates even if one fails.
|
- The job continues processing other templates even if one fails.
|
||||||
|
- Failed task creations are not retried automatically.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
- Source: `src/cron_jobs/recurring-tasks.ts`
|
- Source: `src/cron_jobs/recurring-tasks.ts`
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ cat > worklenz-backend/.env << EOL
|
|||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
SESSION_NAME=worklenz.sid
|
SESSION_NAME=worklenz.sid
|
||||||
SESSION_SECRET=change_me_in_production
|
SESSION_SECRET=$(openssl rand -base64 48)
|
||||||
COOKIE_SECRET=change_me_in_production
|
COOKIE_SECRET=$(openssl rand -base64 48)
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
SOCKET_IO_CORS=${FRONTEND_URL}
|
SOCKET_IO_CORS=${FRONTEND_URL}
|
||||||
@@ -123,7 +123,7 @@ SLACK_WEBHOOK=
|
|||||||
COMMIT_BUILD_IMMEDIATELY=true
|
COMMIT_BUILD_IMMEDIATELY=true
|
||||||
|
|
||||||
# JWT Secret
|
# JWT Secret
|
||||||
JWT_SECRET=change_me_in_production
|
JWT_SECRET=$(openssl rand -base64 48)
|
||||||
EOL
|
EOL
|
||||||
|
|
||||||
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")
|
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")
|
||||||
@@ -138,4 +138,4 @@ echo "Frontend URL: ${FRONTEND_URL}"
|
|||||||
echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000"
|
echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000"
|
||||||
echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000"
|
echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000"
|
||||||
echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}"
|
echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}"
|
||||||
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"
|
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"
|
||||||
|
|||||||
@@ -78,4 +78,8 @@ GOOGLE_CAPTCHA_SECRET_KEY=your_captcha_secret_key
|
|||||||
GOOGLE_CAPTCHA_PASS_SCORE=0.8
|
GOOGLE_CAPTCHA_PASS_SCORE=0.8
|
||||||
|
|
||||||
# Email Cronjobs
|
# Email Cronjobs
|
||||||
ENABLE_EMAIL_CRONJOBS=true
|
ENABLE_EMAIL_CRONJOBS=true
|
||||||
|
|
||||||
|
# RECURRING_JOBS
|
||||||
|
ENABLE_RECURRING_JOBS=true
|
||||||
|
RECURRING_JOBS_INTERVAL="0 11 */1 * 1-5"
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
module.exports = function (grunt) {
|
|
||||||
|
|
||||||
// Project configuration.
|
|
||||||
grunt.initConfig({
|
|
||||||
pkg: grunt.file.readJSON("package.json"),
|
|
||||||
clean: {
|
|
||||||
dist: "build"
|
|
||||||
},
|
|
||||||
compress: require("./grunt/grunt-compress"),
|
|
||||||
copy: {
|
|
||||||
main: {
|
|
||||||
files: [
|
|
||||||
{expand: true, cwd: "src", src: ["public/**"], dest: "build"},
|
|
||||||
{expand: true, cwd: "src", src: ["views/**"], dest: "build"},
|
|
||||||
{expand: true, cwd: "landing-page-assets", src: ["**"], dest: "build/public/assets"},
|
|
||||||
{expand: true, cwd: "src", src: ["shared/sample-data.json"], dest: "build", filter: "isFile"},
|
|
||||||
{expand: true, cwd: "src", src: ["shared/templates/**"], dest: "build", filter: "isFile"},
|
|
||||||
{expand: true, cwd: "src", src: ["shared/postgresql-error-codes.json"], dest: "build", filter: "isFile"},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
packages: {
|
|
||||||
files: [
|
|
||||||
{expand: true, cwd: "", src: [".env"], dest: "build", filter: "isFile"},
|
|
||||||
{expand: true, cwd: "", src: [".gitignore"], dest: "build", filter: "isFile"},
|
|
||||||
{expand: true, cwd: "", src: ["release"], dest: "build", filter: "isFile"},
|
|
||||||
{expand: true, cwd: "", src: ["jest.config.js"], dest: "build", filter: "isFile"},
|
|
||||||
{expand: true, cwd: "", src: ["package.json"], dest: "build", filter: "isFile"},
|
|
||||||
{expand: true, cwd: "", src: ["package-lock.json"], dest: "build", filter: "isFile"},
|
|
||||||
{expand: true, cwd: "", src: ["common_modules/**"], dest: "build"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sync: {
|
|
||||||
main: {
|
|
||||||
files: [
|
|
||||||
{cwd: "src", src: ["views/**", "public/**"], dest: "build/"}, // makes all src relative to cwd
|
|
||||||
],
|
|
||||||
verbose: true,
|
|
||||||
failOnError: true,
|
|
||||||
compareUsing: "md5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
uglify: {
|
|
||||||
all: {
|
|
||||||
files: [{
|
|
||||||
expand: true,
|
|
||||||
cwd: "build",
|
|
||||||
src: "**/*.js",
|
|
||||||
dest: "build"
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
controllers: {
|
|
||||||
files: [{
|
|
||||||
expand: true,
|
|
||||||
cwd: "build",
|
|
||||||
src: "controllers/*.js",
|
|
||||||
dest: "build"
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
routes: {
|
|
||||||
files: [{
|
|
||||||
expand: true,
|
|
||||||
cwd: "build",
|
|
||||||
src: "routes/**/*.js",
|
|
||||||
dest: "build"
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
assets: {
|
|
||||||
files: [{
|
|
||||||
expand: true,
|
|
||||||
cwd: "build",
|
|
||||||
src: "public/assets/**/*.js",
|
|
||||||
dest: "build"
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shell: {
|
|
||||||
tsc: {
|
|
||||||
command: "tsc --build tsconfig.prod.json"
|
|
||||||
},
|
|
||||||
esbuild: {
|
|
||||||
// command: "esbuild `find src -type f -name '*.ts'` --platform=node --minify=false --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=build"
|
|
||||||
command: "node esbuild && node cli/esbuild-patch"
|
|
||||||
},
|
|
||||||
tsc_dev: {
|
|
||||||
command: "tsc --build tsconfig.json"
|
|
||||||
},
|
|
||||||
swagger: {
|
|
||||||
command: "node ./cli/swagger"
|
|
||||||
},
|
|
||||||
inline_queries: {
|
|
||||||
command: "node ./cli/inline-queries"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
scripts: {
|
|
||||||
files: ["src/**/*.ts"],
|
|
||||||
tasks: ["shell:tsc_dev"],
|
|
||||||
options: {
|
|
||||||
debounceDelay: 250,
|
|
||||||
spawn: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
other: {
|
|
||||||
files: ["src/**/*.pug", "landing-page-assets/**"],
|
|
||||||
tasks: ["sync"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
grunt.registerTask("clean", ["clean"]);
|
|
||||||
grunt.registerTask("copy", ["copy:main"]);
|
|
||||||
grunt.registerTask("swagger", ["shell:swagger"]);
|
|
||||||
grunt.registerTask("build:tsc", ["shell:tsc"]);
|
|
||||||
grunt.registerTask("build", ["clean", "shell:tsc", "copy:main", "compress"]);
|
|
||||||
grunt.registerTask("build:es", ["clean", "shell:esbuild", "copy:main", "uglify:assets", "compress"]);
|
|
||||||
grunt.registerTask("build:strict", ["clean", "shell:tsc", "copy:packages", "uglify:all", "copy:main", "compress"]);
|
|
||||||
grunt.registerTask("dev", ["clean", "copy:main", "shell:tsc_dev", "shell:inline_queries", "watch"]);
|
|
||||||
|
|
||||||
// Load the plugin that provides the "uglify" task.
|
|
||||||
grunt.loadNpmTasks("grunt-contrib-watch");
|
|
||||||
grunt.loadNpmTasks("grunt-contrib-clean");
|
|
||||||
grunt.loadNpmTasks("grunt-contrib-copy");
|
|
||||||
grunt.loadNpmTasks("grunt-contrib-uglify");
|
|
||||||
grunt.loadNpmTasks("grunt-contrib-compress");
|
|
||||||
grunt.loadNpmTasks("grunt-shell");
|
|
||||||
grunt.loadNpmTasks("grunt-sync");
|
|
||||||
|
|
||||||
// Default task(s).
|
|
||||||
grunt.registerTask("default", []);
|
|
||||||
};
|
|
||||||
@@ -3351,15 +3351,15 @@ BEGIN
|
|||||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||||
FROM (SELECT team_member_id,
|
FROM (SELECT team_member_id,
|
||||||
project_member_id,
|
project_member_id,
|
||||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id),
|
COALESCE((SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id), '') as name,
|
||||||
(SELECT email_notifications_enabled
|
COALESCE((SELECT email_notifications_enabled
|
||||||
FROM notification_settings
|
FROM notification_settings
|
||||||
WHERE team_id = tm.team_id
|
WHERE team_id = tm.team_id
|
||||||
AND notification_settings.user_id = u.id) AS email_notifications_enabled,
|
AND notification_settings.user_id = u.id), false) AS email_notifications_enabled,
|
||||||
u.avatar_url,
|
COALESCE(u.avatar_url, '') as avatar_url,
|
||||||
u.id AS user_id,
|
u.id AS user_id,
|
||||||
u.email,
|
COALESCE(u.email, '') as email,
|
||||||
u.socket_id AS socket_id,
|
COALESCE(u.socket_id, '') as socket_id,
|
||||||
tm.team_id AS team_id
|
tm.team_id AS team_id
|
||||||
FROM tasks_assignees
|
FROM tasks_assignees
|
||||||
INNER JOIN team_members tm ON tm.id = tasks_assignees.team_member_id
|
INNER JOIN team_members tm ON tm.id = tasks_assignees.team_member_id
|
||||||
@@ -4066,14 +4066,14 @@ DECLARE
|
|||||||
_schedule_id JSON;
|
_schedule_id JSON;
|
||||||
_task_completed_at TIMESTAMPTZ;
|
_task_completed_at TIMESTAMPTZ;
|
||||||
BEGIN
|
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
|
FROM task_statuses
|
||||||
WHERE id = (SELECT status_id FROM tasks WHERE id = _task_id)
|
WHERE id = (SELECT status_id FROM tasks WHERE id = _task_id)
|
||||||
INTO _previous_status_name;
|
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)
|
IF (_previous_status_name != _new_status_name)
|
||||||
THEN
|
THEN
|
||||||
@@ -4081,14 +4081,22 @@ BEGIN
|
|||||||
|
|
||||||
SELECT get_task_complete_info(_task_id, _status_id) INTO _task_info;
|
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, ' ⟶ ',
|
_message = CONCAT(_updater_name, ' transitioned "', _task_name, '" from ', _previous_status_name, ' ⟶ ',
|
||||||
_new_status_name);
|
_new_status_name);
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
SELECT completed_at FROM tasks WHERE id = _task_id INTO _task_completed_at;
|
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)
|
SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON)
|
||||||
FROM (SELECT is_done, is_doing, is_todo
|
FROM (SELECT is_done, is_doing, is_todo
|
||||||
@@ -4097,7 +4105,7 @@ BEGIN
|
|||||||
INTO _status_category;
|
INTO _status_category;
|
||||||
|
|
||||||
RETURN JSON_BUILD_OBJECT(
|
RETURN JSON_BUILD_OBJECT(
|
||||||
'message', _message,
|
'message', COALESCE(_message, ''),
|
||||||
'project_id', (SELECT project_id FROM tasks WHERE id = _task_id),
|
'project_id', (SELECT project_id FROM tasks WHERE id = _task_id),
|
||||||
'parent_done', (CASE
|
'parent_done', (CASE
|
||||||
WHEN EXISTS(SELECT 1
|
WHEN EXISTS(SELECT 1
|
||||||
@@ -4105,14 +4113,14 @@ BEGIN
|
|||||||
WHERE tasks_with_status_view.task_id = _task_id
|
WHERE tasks_with_status_view.task_id = _task_id
|
||||||
AND is_done IS TRUE) THEN 1
|
AND is_done IS TRUE) THEN 1
|
||||||
ELSE 0 END),
|
ELSE 0 END),
|
||||||
'color_code', (_task_info ->> 'color_code')::TEXT,
|
'color_code', COALESCE((_task_info ->> 'color_code')::TEXT, ''),
|
||||||
'color_code_dark', (_task_info ->> 'color_code_dark')::TEXT,
|
'color_code_dark', COALESCE((_task_info ->> 'color_code_dark')::TEXT, ''),
|
||||||
'total_tasks', (_task_info ->> 'total_tasks')::INT,
|
'total_tasks', COALESCE((_task_info ->> 'total_tasks')::INT, 0),
|
||||||
'total_completed', (_task_info ->> 'total_completed')::INT,
|
'total_completed', COALESCE((_task_info ->> 'total_completed')::INT, 0),
|
||||||
'members', (_task_info ->> 'members')::JSON,
|
'members', COALESCE((_task_info ->> 'members')::JSON, '[]'::JSON),
|
||||||
'completed_at', _task_completed_at,
|
'completed_at', _task_completed_at,
|
||||||
'status_category', _status_category,
|
'status_category', COALESCE(_status_category, '{}'::JSON),
|
||||||
'schedule_id', _schedule_id
|
'schedule_id', COALESCE(_schedule_id, 'null'::JSON)
|
||||||
);
|
);
|
||||||
END
|
END
|
||||||
$$;
|
$$;
|
||||||
@@ -6148,3 +6156,219 @@ BEGIN
|
|||||||
RETURN v_new_id;
|
RETURN v_new_id;
|
||||||
END;
|
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;
|
||||||
|
$$;
|
||||||
|
|||||||
2443
worklenz-backend/package-lock.json
generated
2443
worklenz-backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,16 +11,30 @@
|
|||||||
"repository": "GITHUB_REPO_HERE",
|
"repository": "GITHUB_REPO_HERE",
|
||||||
"author": "worklenz.com",
|
"author": "worklenz.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./build/bin/www",
|
"test": "jest",
|
||||||
"tcs": "grunt build:tsc",
|
"start": "node build/bin/www.js",
|
||||||
"build": "grunt build",
|
"dev": "npm run build:dev && npm run watch",
|
||||||
"watch": "grunt watch",
|
"build": "npm run clean && npm run compile && npm run copy && npm run compress",
|
||||||
"dev": "grunt dev",
|
"build:dev": "npm run clean && npm run compile:dev && npm run copy",
|
||||||
"es": "esbuild `find src -type f -name '*.ts'` --platform=node --minify=true --watch=true --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=dist",
|
"build:prod": "npm run clean && npm run compile:prod && npm run copy && npm run minify && npm run compress",
|
||||||
"copy": "grunt copy",
|
"clean": "rimraf build",
|
||||||
|
"compile": "tsc --build tsconfig.prod.json",
|
||||||
|
"compile:dev": "tsc --build tsconfig.json",
|
||||||
|
"compile:prod": "tsc --build tsconfig.prod.json",
|
||||||
|
"copy": "npm run copy:assets && npm run copy:views && npm run copy:config && npm run copy:shared",
|
||||||
|
"copy:assets": "npx cpx2 \"src/public/**\" build/public",
|
||||||
|
"copy:views": "npx cpx2 \"src/views/**\" build/views",
|
||||||
|
"copy:config": "npx cpx2 \".env\" build && npx cpx2 \"package.json\" build",
|
||||||
|
"copy:shared": "npx cpx2 \"src/shared/postgresql-error-codes.json\" build/shared && npx cpx2 \"src/shared/sample-data.json\" build/shared && npx cpx2 \"src/shared/templates/**\" build/shared/templates",
|
||||||
|
"watch": "concurrently \"npm run watch:ts\" \"npm run watch:assets\"",
|
||||||
|
"watch:ts": "tsc --build tsconfig.json --watch",
|
||||||
|
"watch:assets": "npx cpx2 \"src/{public,views}/**\" build --watch",
|
||||||
|
"minify": "terser build/**/*.js --compress --mangle --output-dir build",
|
||||||
|
"compress": "node scripts/compress.js",
|
||||||
|
"swagger": "node ./cli/swagger",
|
||||||
|
"inline-queries": "node ./cli/inline-queries",
|
||||||
"sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties",
|
"sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties",
|
||||||
"tsc": "tsc",
|
"tsc": "tsc",
|
||||||
"test": "jest --setupFiles dotenv/config",
|
|
||||||
"test:watch": "jest --watch --setupFiles dotenv/config"
|
"test:watch": "jest --watch --setupFiles dotenv/config"
|
||||||
},
|
},
|
||||||
"jestSonar": {
|
"jestSonar": {
|
||||||
@@ -45,6 +59,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cron": "^2.4.0",
|
"cron": "^2.4.0",
|
||||||
"crypto-js": "^4.1.1",
|
"crypto-js": "^4.1.1",
|
||||||
|
"csrf-sync": "^4.2.1",
|
||||||
"csurf": "^1.11.0",
|
"csurf": "^1.11.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
@@ -120,26 +135,22 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
"@typescript-eslint/parser": "^5.62.0",
|
"@typescript-eslint/parser": "^5.62.0",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
|
"cpx2": "^8.0.0",
|
||||||
"esbuild": "^0.17.19",
|
"esbuild": "^0.17.19",
|
||||||
"esbuild-envfile-plugin": "^1.0.5",
|
"esbuild-envfile-plugin": "^1.0.5",
|
||||||
"esbuild-node-externals": "^1.8.0",
|
"esbuild-node-externals": "^1.8.0",
|
||||||
"eslint": "^8.45.0",
|
"eslint": "^8.45.0",
|
||||||
"eslint-plugin-security": "^1.7.1",
|
"eslint-plugin-security": "^1.7.1",
|
||||||
"fs-extra": "^10.1.0",
|
"fs-extra": "^10.1.0",
|
||||||
"grunt": "^1.6.1",
|
|
||||||
"grunt-contrib-clean": "^2.0.1",
|
|
||||||
"grunt-contrib-compress": "^2.0.0",
|
|
||||||
"grunt-contrib-copy": "^1.0.0",
|
|
||||||
"grunt-contrib-uglify": "^5.2.2",
|
|
||||||
"grunt-contrib-watch": "^1.1.0",
|
|
||||||
"grunt-shell": "^4.0.0",
|
|
||||||
"grunt-sync": "^0.8.2",
|
|
||||||
"highcharts": "^11.1.0",
|
"highcharts": "^11.1.0",
|
||||||
"jest": "^28.1.3",
|
"jest": "^28.1.3",
|
||||||
"jest-sonar-reporter": "^2.0.0",
|
"jest-sonar-reporter": "^2.0.0",
|
||||||
"ncp": "^2.0.0",
|
"ncp": "^2.0.0",
|
||||||
"nodeman": "^1.1.2",
|
"nodeman": "^1.1.2",
|
||||||
|
"rimraf": "^6.0.1",
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
|
"terser": "^5.40.0",
|
||||||
"ts-jest": "^28.0.8",
|
"ts-jest": "^28.0.8",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"tslint": "^6.1.3",
|
"tslint": "^6.1.3",
|
||||||
|
|||||||
53
worklenz-backend/scripts/compress.js
Normal file
53
worklenz-backend/scripts/compress.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { createGzip } = require('zlib');
|
||||||
|
const { pipeline } = require('stream');
|
||||||
|
|
||||||
|
async function compressFile(inputPath, outputPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const gzip = createGzip();
|
||||||
|
const source = fs.createReadStream(inputPath);
|
||||||
|
const destination = fs.createWriteStream(outputPath);
|
||||||
|
|
||||||
|
pipeline(source, gzip, destination, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compressDirectory(dir) {
|
||||||
|
const files = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(dir, file.name);
|
||||||
|
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
await compressDirectory(fullPath);
|
||||||
|
} else if (file.name.endsWith('.js') || file.name.endsWith('.css')) {
|
||||||
|
const gzPath = fullPath + '.gz';
|
||||||
|
await compressFile(fullPath, gzPath);
|
||||||
|
console.log(`Compressed: ${fullPath} -> ${gzPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const buildDir = path.join(__dirname, '../build');
|
||||||
|
if (fs.existsSync(buildDir)) {
|
||||||
|
await compressDirectory(buildDir);
|
||||||
|
console.log('Compression complete!');
|
||||||
|
} else {
|
||||||
|
console.log('Build directory not found. Run build first.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Compression failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -6,7 +6,7 @@ import logger from "morgan";
|
|||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import compression from "compression";
|
import compression from "compression";
|
||||||
import passport from "passport";
|
import passport from "passport";
|
||||||
import csurf from "csurf";
|
import { csrfSync } from "csrf-sync";
|
||||||
import rateLimit from "express-rate-limit";
|
import rateLimit from "express-rate-limit";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import flash from "connect-flash";
|
import flash from "connect-flash";
|
||||||
@@ -112,17 +112,13 @@ function isLoggedIn(req: Request, _res: Response, next: NextFunction) {
|
|||||||
return req.user ? next() : next(createError(401));
|
return req.user ? next() : next(createError(401));
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF configuration
|
// CSRF configuration using csrf-sync for session-based authentication
|
||||||
const csrfProtection = csurf({
|
const {
|
||||||
cookie: {
|
invalidCsrfTokenError,
|
||||||
key: "XSRF-TOKEN",
|
generateToken,
|
||||||
path: "/",
|
csrfSynchronisedProtection,
|
||||||
httpOnly: false,
|
} = csrfSync({
|
||||||
secure: isProduction(), // Only secure in production
|
getTokenFromRequest: (req: Request) => req.headers["x-csrf-token"] as string || (req.body && req.body["_csrf"])
|
||||||
sameSite: isProduction() ? "none" : "lax", // Different settings for dev vs prod
|
|
||||||
domain: isProduction() ? ".worklenz.com" : undefined // Only set domain in production
|
|
||||||
},
|
|
||||||
ignoreMethods: ["HEAD", "OPTIONS"]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply CSRF selectively (exclude webhooks and public routes)
|
// Apply CSRF selectively (exclude webhooks and public routes)
|
||||||
@@ -135,38 +131,25 @@ app.use((req, res, next) => {
|
|||||||
) {
|
) {
|
||||||
next();
|
next();
|
||||||
} else {
|
} else {
|
||||||
csrfProtection(req, res, next);
|
csrfSynchronisedProtection(req, res, next);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set CSRF token cookie
|
// Set CSRF token method on request object for compatibility
|
||||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
if (req.csrfToken) {
|
// Add csrfToken method to request object for compatibility
|
||||||
const token = req.csrfToken();
|
if (!req.csrfToken && generateToken) {
|
||||||
res.cookie("XSRF-TOKEN", token, {
|
req.csrfToken = (overwrite?: boolean) => generateToken(req, overwrite);
|
||||||
httpOnly: false,
|
|
||||||
secure: isProduction(),
|
|
||||||
sameSite: isProduction() ? "none" : "lax",
|
|
||||||
domain: isProduction() ? ".worklenz.com" : undefined,
|
|
||||||
path: "/"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// CSRF token refresh endpoint
|
// CSRF token refresh endpoint
|
||||||
app.get("/csrf-token", (req: Request, res: Response) => {
|
app.get("/csrf-token", (req: Request, res: Response) => {
|
||||||
if (req.csrfToken) {
|
try {
|
||||||
const token = req.csrfToken();
|
const token = generateToken(req);
|
||||||
res.cookie("XSRF-TOKEN", token, {
|
res.status(200).json({ done: true, message: "CSRF token refreshed", token });
|
||||||
httpOnly: false,
|
} catch (error) {
|
||||||
secure: isProduction(),
|
|
||||||
sameSite: isProduction() ? "none" : "lax",
|
|
||||||
domain: isProduction() ? ".worklenz.com" : undefined,
|
|
||||||
path: "/"
|
|
||||||
});
|
|
||||||
res.status(200).json({ done: true, message: "CSRF token refreshed" });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ done: false, message: "Failed to generate CSRF token" });
|
res.status(500).json({ done: false, message: "Failed to generate CSRF token" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -219,7 +202,7 @@ if (isInternalServer()) {
|
|||||||
|
|
||||||
// CSRF error handler
|
// CSRF error handler
|
||||||
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
|
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
|
||||||
if (err.code === "EBADCSRFTOKEN") {
|
if (err === invalidCsrfTokenError) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
done: false,
|
done: false,
|
||||||
message: "Invalid CSRF token",
|
message: "Invalid CSRF token",
|
||||||
|
|||||||
@@ -35,8 +35,18 @@ export default class AuthController extends WorklenzControllerBase {
|
|||||||
const auth_error = errors.length > 0 ? errors[0] : null;
|
const auth_error = errors.length > 0 ? errors[0] : null;
|
||||||
const message = messages.length > 0 ? messages[0] : null;
|
const message = messages.length > 0 ? messages[0] : null;
|
||||||
|
|
||||||
const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
|
// Determine title based on authentication status and strategy
|
||||||
const title = req.query.strategy ? midTitle : null;
|
let title = null;
|
||||||
|
if (req.query.strategy) {
|
||||||
|
if (auth_error) {
|
||||||
|
// Show failure title only when there's an actual error
|
||||||
|
title = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
|
||||||
|
} else if (req.isAuthenticated() && message) {
|
||||||
|
// Show success title when authenticated and there's a success message
|
||||||
|
title = req.query.strategy === "login" ? "Login Successful!" : "Signup Successful!";
|
||||||
|
}
|
||||||
|
// If no error and not authenticated, don't show any title (this might be a redirect without completion)
|
||||||
|
}
|
||||||
|
|
||||||
if (req.user)
|
if (req.user)
|
||||||
req.user.build_v = FileConstants.getRelease();
|
req.user.build_v = FileConstants.getRelease();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||||
import {getColor} from "../shared/utils";
|
import { getColor } from "../shared/utils";
|
||||||
import {PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
|
import { PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
|
||||||
import moment from "moment/moment";
|
import moment from "moment/moment";
|
||||||
|
|
||||||
export const GroupBy = {
|
export const GroupBy = {
|
||||||
@@ -32,23 +32,14 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static updateTaskViewModel(task: any) {
|
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
|
// For parent tasks (with subtasks), always use calculated progress from subtasks
|
||||||
if (task.sub_tasks_count > 0) {
|
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
|
// Ensure progress matches complete_ratio for consistency
|
||||||
task.progress = task.complete_ratio || 0;
|
task.progress = task.complete_ratio || 0;
|
||||||
|
|
||||||
// Important: Parent tasks should not have manual progress
|
// Important: Parent tasks should not have manual progress
|
||||||
// If they somehow do, reset it
|
// If they somehow do, reset it
|
||||||
if (task.manual_progress) {
|
if (task.manual_progress) {
|
||||||
console.log(` WARNING: Parent task ${task.id} had manual_progress set to true, resetting`);
|
|
||||||
task.manual_progress = false;
|
task.manual_progress = false;
|
||||||
task.progress_value = null;
|
task.progress_value = null;
|
||||||
}
|
}
|
||||||
@@ -58,28 +49,24 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
|||||||
// For manually set progress, use that value directly
|
// For manually set progress, use that value directly
|
||||||
task.progress = parseInt(task.progress_value);
|
task.progress = parseInt(task.progress_value);
|
||||||
task.complete_ratio = 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
|
// For tasks with no subtasks and no manual progress, calculate based on time
|
||||||
else {
|
else {
|
||||||
task.progress = task.total_minutes_spent && task.total_minutes
|
task.progress = task.total_minutes_spent && task.total_minutes
|
||||||
? ~~(task.total_minutes_spent / task.total_minutes * 100)
|
? ~~(task.total_minutes_spent / task.total_minutes * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Set complete_ratio to match progress
|
// Set complete_ratio to match progress
|
||||||
task.complete_ratio = task.progress;
|
task.complete_ratio = task.progress;
|
||||||
|
|
||||||
console.log(` Calculated time-based progress: progress=${task.progress}, complete_ratio=${task.complete_ratio}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure numeric values
|
// Ensure numeric values
|
||||||
task.progress = parseInt(task.progress) || 0;
|
task.progress = parseInt(task.progress) || 0;
|
||||||
task.complete_ratio = parseInt(task.complete_ratio) || 0;
|
task.complete_ratio = parseInt(task.complete_ratio) || 0;
|
||||||
|
|
||||||
task.overdue = task.total_minutes < task.total_minutes_spent;
|
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.comments_count = Number(task.comments_count) ? +task.comments_count : 0;
|
||||||
task.attachments_count = Number(task.attachments_count) ? +task.attachments_count : 0;
|
task.attachments_count = Number(task.attachments_count) ? +task.attachments_count : 0;
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
try {
|
try {
|
||||||
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
|
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
|
||||||
const [data] = result.rows;
|
const [data] = result.rows;
|
||||||
console.log("data", data);
|
|
||||||
if (data && data.info && data.info.ratio !== undefined) {
|
if (data && data.info && data.info.ratio !== undefined) {
|
||||||
data.info.ratio = +((data.info.ratio || 0).toFixed());
|
data.info.ratio = +((data.info.ratio || 0).toFixed());
|
||||||
return data.info;
|
return data.info;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {startDailyDigestJob} from "./daily-digest-job";
|
import {startDailyDigestJob} from "./daily-digest-job";
|
||||||
import {startNotificationsJob} from "./notifications-job";
|
import {startNotificationsJob} from "./notifications-job";
|
||||||
import {startProjectDigestJob} from "./project-digest-job";
|
import {startProjectDigestJob} from "./project-digest-job";
|
||||||
import { startRecurringTasksJob } from "./recurring-tasks";
|
import {startRecurringTasksJob} from "./recurring-tasks";
|
||||||
|
|
||||||
export function startCronJobs() {
|
export function startCronJobs() {
|
||||||
startNotificationsJob();
|
startNotificationsJob();
|
||||||
startDailyDigestJob();
|
startDailyDigestJob();
|
||||||
startProjectDigestJob();
|
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.
|
// 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 = "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_FORMAT = "YYYY-MM-DD";
|
||||||
// const TIME = "0 0 * * *"; // Runs at midnight every day
|
// const TIME = "0 0 * * *"; // Runs at midnight every day
|
||||||
|
|
||||||
const log = (value: any) => console.log("recurring-task-cron-job:", value);
|
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() {
|
async function onRecurringTaskJobTick() {
|
||||||
try {
|
try {
|
||||||
log("(cron) Recurring tasks job started.");
|
log("(cron) Recurring tasks job started.");
|
||||||
@@ -33,65 +111,44 @@ async function onRecurringTaskJobTick() {
|
|||||||
? moment(template.last_task_end_date)
|
? moment(template.last_task_end_date)
|
||||||
: moment(template.created_at);
|
: 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);
|
let nextEndDate = calculateNextEndDate(template, lastTaskEndDate);
|
||||||
|
const endDatesToCreate: moment.Moment[] = [];
|
||||||
|
|
||||||
// Find the next future occurrence
|
// Find all future occurrences within the limit
|
||||||
while (nextEndDate.isSameOrBefore(now)) {
|
while (nextEndDate.isSameOrBefore(futureLimit)) {
|
||||||
|
if (nextEndDate.isAfter(now)) {
|
||||||
|
endDatesToCreate.push(moment(nextEndDate));
|
||||||
|
}
|
||||||
nextEndDate = calculateNextEndDate(template, nextEndDate);
|
nextEndDate = calculateNextEndDate(template, nextEndDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only create a task if it's within the future limit
|
// Batch create tasks for all future dates
|
||||||
if (nextEndDate.isSameOrBefore(futureLimit)) {
|
if (endDatesToCreate.length > 0) {
|
||||||
const existingTaskQuery = `
|
const createdTasks = await createBatchTasks(template, endDatesToCreate);
|
||||||
SELECT id FROM tasks
|
createdTaskCount += createdTasks.length;
|
||||||
WHERE schedule_id = $1 AND end_date::DATE = $2::DATE;
|
|
||||||
|
// 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)]);
|
await db.query(updateScheduleQuery, [
|
||||||
|
moment().format(TIME_FORMAT),
|
||||||
if (existingTaskResult.rows.length === 0) {
|
endDatesToCreate[endDatesToCreate.length - 1].format(TIME_FORMAT),
|
||||||
const createTaskQuery = `SELECT create_quick_task($1::json) as task;`;
|
template.schedule_id
|
||||||
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`);
|
|
||||||
}
|
|
||||||
} else {
|
} 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.`);
|
log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`);
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import { isProduction } from "../shared/utils";
|
|||||||
const pgSession = require("connect-pg-simple")(session);
|
const pgSession = require("connect-pg-simple")(session);
|
||||||
|
|
||||||
export default session({
|
export default session({
|
||||||
name: process.env.SESSION_NAME,
|
name: process.env.SESSION_NAME || "worklenz.sid",
|
||||||
secret: process.env.SESSION_SECRET || "development-secret-key",
|
secret: process.env.SESSION_SECRET || "development-secret-key",
|
||||||
proxy: false,
|
proxy: true,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: true,
|
saveUninitialized: false,
|
||||||
rolling: true,
|
rolling: true,
|
||||||
store: new pgSession({
|
store: new pgSession({
|
||||||
pool: db.pool,
|
pool: db.pool,
|
||||||
@@ -18,10 +18,9 @@ export default session({
|
|||||||
}),
|
}),
|
||||||
cookie: {
|
cookie: {
|
||||||
path: "/",
|
path: "/",
|
||||||
// secure: isProduction(),
|
secure: isProduction(), // Use secure cookies in production
|
||||||
// httpOnly: isProduction(),
|
httpOnly: true,
|
||||||
// sameSite: "none",
|
sameSite: "lax", // Standard setting for same-origin requests
|
||||||
// domain: isProduction() ? ".worklenz.com" : undefined,
|
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
|
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3,13 +3,16 @@ import { Strategy as LocalStrategy } from "passport-local";
|
|||||||
import { log_error } from "../../shared/utils";
|
import { log_error } from "../../shared/utils";
|
||||||
import db from "../../config/db";
|
import db from "../../config/db";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
import { ERROR_KEY, SUCCESS_KEY } from "./passport-constants";
|
||||||
|
|
||||||
async function handleLogin(req: Request, email: string, password: string, done: any) {
|
async function handleLogin(req: Request, email: string, password: string, done: any) {
|
||||||
console.log("Login attempt for:", email);
|
// Clear any existing flash messages
|
||||||
|
(req.session as any).flash = {};
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
console.log("Missing credentials");
|
const errorMsg = "Please enter both email and password";
|
||||||
return done(null, false, { message: "Please enter both email and password" });
|
req.flash(ERROR_KEY, errorMsg);
|
||||||
|
return done(null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -19,23 +22,27 @@ async function handleLogin(req: Request, email: string, password: string, done:
|
|||||||
AND google_id IS NULL
|
AND google_id IS NULL
|
||||||
AND is_deleted IS FALSE;`;
|
AND is_deleted IS FALSE;`;
|
||||||
const result = await db.query(q, [email]);
|
const result = await db.query(q, [email]);
|
||||||
console.log("User query result count:", result.rowCount);
|
|
||||||
|
|
||||||
const [data] = result.rows;
|
const [data] = result.rows;
|
||||||
|
|
||||||
if (!data?.password) {
|
if (!data?.password) {
|
||||||
console.log("No account found");
|
const errorMsg = "No account found with this email";
|
||||||
return done(null, false, { message: "No account found with this email" });
|
req.flash(ERROR_KEY, errorMsg);
|
||||||
|
return done(null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordMatch = bcrypt.compareSync(password, data.password);
|
const passwordMatch = bcrypt.compareSync(password, data.password);
|
||||||
console.log("Password match:", passwordMatch);
|
|
||||||
|
|
||||||
if (passwordMatch && email === data.email) {
|
if (passwordMatch && email === data.email) {
|
||||||
delete data.password;
|
delete data.password;
|
||||||
return done(null, data, {message: "User successfully logged in"});
|
const successMsg = "User successfully logged in";
|
||||||
|
req.flash(SUCCESS_KEY, successMsg);
|
||||||
|
return done(null, data);
|
||||||
}
|
}
|
||||||
return done(null, false, { message: "Incorrect email or password" });
|
|
||||||
|
const errorMsg = "Incorrect email or password";
|
||||||
|
req.flash(ERROR_KEY, errorMsg);
|
||||||
|
return done(null, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Login error:", error);
|
console.error("Login error:", error);
|
||||||
log_error(error, req.body);
|
log_error(error, req.body);
|
||||||
|
|||||||
@@ -2,31 +2,30 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title></title>
|
<title>Password Changed | Worklenz</title>
|
||||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
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] {
|
.main-container {
|
||||||
color: inherit !important;
|
background: #fff;
|
||||||
text-decoration: inherit !important
|
border-radius: 18px;
|
||||||
}
|
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||||
|
margin: 40px auto 0 auto;
|
||||||
#MessageViewBody a {
|
max-width: 500px;
|
||||||
color: inherit;
|
padding: 0 0 20px 0;
|
||||||
text-decoration: none
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
line-height: inherit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -42,33 +41,48 @@
|
|||||||
mso-hide: all;
|
mso-hide: all;
|
||||||
display: none;
|
display: none;
|
||||||
max-height: 0;
|
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) {
|
@media (max-width: 525px) {
|
||||||
|
.main-container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop_hide table.icons-inner {
|
.desktop_hide table.icons-inner {
|
||||||
display: inline-block !important
|
display: inline-block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner {
|
.icons-inner {
|
||||||
text-align: center
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner td {
|
.icons-inner td {
|
||||||
margin: 0 auto
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-content {
|
.row-content {
|
||||||
width: 95% !important
|
width: 95% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
display: none
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack .column {
|
.stack .column {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
@@ -76,135 +90,145 @@
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0
|
font-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop_hide,
|
.desktop_hide,
|
||||||
.desktop_hide table {
|
.desktop_hide table {
|
||||||
display: table !important;
|
display: table !important;
|
||||||
max-height: none !important
|
max-height: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none;">
|
<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"
|
<div class="main-container">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||||
width="100%">
|
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||||
<tbody>
|
width="100%">
|
||||||
<tr>
|
<tbody>
|
||||||
<td>
|
<tr>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
<td>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||||
<tbody>
|
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
<tr>
|
<tbody>
|
||||||
<td>
|
<tr>
|
||||||
|
<td>
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20"
|
<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;
|
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">
|
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>
|
<tr>
|
||||||
<td class="column column-1"
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
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"
|
<div align="left" class="alignment" style="line-height:10px">
|
||||||
width="100%">
|
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
||||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
target="_blank"><img
|
||||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
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%">
|
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>
|
<tr>
|
||||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||||
<div align="center" class="alignment" style="line-height:10px"><img
|
<!--[if vml]>
|
||||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-password-changed.png"
|
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
style="display:block;height:auto;border:0;width:100px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||||
width="100">
|
<![endif]-->
|
||||||
</div>
|
<!--[if !vml]><!-->
|
||||||
</td>
|
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation"
|
||||||
</tr>
|
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||||
<tr>
|
</table>
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
</td>
|
</tbody>
|
||||||
</tr>
|
</table>
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
</tbody>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
</table>
|
||||||
<tbody>
|
<div class="footer">
|
||||||
<tr>
|
If you have any questions, contact us at <a href="mailto:support@worklenz.com"
|
||||||
<td>
|
style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation"
|
© 2025 Worklenz. All rights reserved.
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
</div>
|
||||||
<tbody>
|
</div>
|
||||||
<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 -->
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,31 +2,30 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title></title>
|
<title>Reset Your Password | Worklenz</title>
|
||||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
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] {
|
.main-container {
|
||||||
color: inherit !important;
|
background: #fff;
|
||||||
text-decoration: inherit !important
|
border-radius: 18px;
|
||||||
}
|
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||||
|
margin: 40px auto 0 auto;
|
||||||
#MessageViewBody a {
|
max-width: 500px;
|
||||||
color: inherit;
|
padding: 0 0 20px 0;
|
||||||
text-decoration: none
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
line-height: inherit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -42,33 +41,68 @@
|
|||||||
mso-hide: all;
|
mso-hide: all;
|
||||||
display: none;
|
display: none;
|
||||||
max-height: 0;
|
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) {
|
@media (max-width: 525px) {
|
||||||
|
.main-container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop_hide table.icons-inner {
|
.desktop_hide table.icons-inner {
|
||||||
display: inline-block !important
|
display: inline-block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner {
|
.icons-inner {
|
||||||
text-align: center
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner td {
|
.icons-inner td {
|
||||||
margin: 0 auto
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-content {
|
.row-content {
|
||||||
width: 95% !important
|
width: 95% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
display: none
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack .column {
|
.stack .column {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
@@ -76,179 +110,137 @@
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0
|
font-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop_hide,
|
.desktop_hide,
|
||||||
.desktop_hide table {
|
.desktop_hide table {
|
||||||
display: table !important;
|
display: table !important;
|
||||||
max-height: none !important
|
max-height: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
<body>
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
<div class="main-container">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="background:transparent;" width="100%">
|
||||||
width="100%">
|
<tbody>
|
||||||
<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>
|
<tr>
|
||||||
<td>
|
<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%">
|
||||||
<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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="column column-1"
|
<td>
|
||||||
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"
|
<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">
|
||||||
width="100%">
|
<tr>
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
<div align="left" class="alignment" style="line-height:10px">
|
||||||
<tr>
|
<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>
|
||||||
<td class="pad">
|
</div>
|
||||||
<h1
|
</td>
|
||||||
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;">
|
</tr>
|
||||||
<span class="tinyMce-placeholder">Reset your password on Worklenz</span>
|
</table>
|
||||||
</h1>
|
<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">
|
||||||
</td>
|
<tbody>
|
||||||
</tr>
|
<tr>
|
||||||
<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%">
|
||||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
<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%">
|
||||||
<div align="center" class="alignment" style="line-height:10px"><img
|
<tr>
|
||||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png"
|
<td class="pad">
|
||||||
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
<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;">
|
||||||
width="180">
|
<span class="tinyMce-placeholder">Reset your password</span>
|
||||||
</div>
|
</h1>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
<tr>
|
||||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
<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">
|
||||||
width="100%">
|
</div>
|
||||||
<tr>
|
</td>
|
||||||
<td class="pad">
|
</tr>
|
||||||
<div
|
</table>
|
||||||
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">
|
<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%">
|
||||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
<tr>
|
||||||
</div>
|
<td class="pad">
|
||||||
</td>
|
<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">
|
||||||
</tr>
|
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
||||||
</table>
|
</div>
|
||||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
</td>
|
||||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
</tr>
|
||||||
width="100%">
|
</table>
|
||||||
<tr>
|
<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%">
|
||||||
<td class="pad">
|
<tr>
|
||||||
<div
|
<td class="pad">
|
||||||
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">
|
<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 requested to reset your password
|
<p style="margin:0;margin-bottom:10px">We received a request to reset your Worklenz account password.</p>
|
||||||
</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>
|
||||||
<p style="margin:0;margin-bottom:10px">To reset your password, click the following link and follow the instructions.</p>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
</table>
|
||||||
</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%">
|
||||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
<tr>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
<td class="pad">
|
||||||
width="100%">
|
<div align="center" class="alignment">
|
||||||
<tr>
|
<a href="https://[VAR_HOSTNAME]/auth/verify-reset-email/[VAR_USER_ID]/[VAR_HASH]" class="modern-btn">
|
||||||
<td class="pad">
|
Reset my password
|
||||||
<div align="center" class="alignment">
|
</a>
|
||||||
<a href="https://[VAR_HOSTNAME]/auth/verify-reset-email/[VAR_USER_ID]/[VAR_HASH]">
|
</div>
|
||||||
<div
|
<div style="color:#b0b8c9;font-size:14px;text-align:center;margin-top:10px;">
|
||||||
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">
|
<p style="margin:0;">For your security, this link will expire in 1 hour.</p>
|
||||||
<span
|
</div>
|
||||||
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
|
</td>
|
||||||
dir="ltr" style="word-break: break-word; line-height: 28px;">Reset my password</span></span>
|
</tr>
|
||||||
</div>
|
</table>
|
||||||
</a>
|
</td>
|
||||||
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
</tr>
|
||||||
</div>
|
</tbody>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
</tbody>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
<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%">
|
||||||
</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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="column column-1"
|
<td>
|
||||||
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"
|
<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">
|
||||||
width="100%">
|
<tbody>
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
<tr>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
<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%">
|
||||||
<tr>
|
<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%">
|
||||||
<td class="pad"
|
<tr>
|
||||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
<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"
|
<table cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
<tr>
|
||||||
width="100%">
|
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||||
<tr>
|
<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">
|
||||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
</table>
|
||||||
<!--[if vml]>
|
</td>
|
||||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
</tr>
|
||||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
</table>
|
||||||
<![endif]-->
|
</td>
|
||||||
<!--[if !vml]><!-->
|
</tr>
|
||||||
<table cellpadding="0"
|
</table>
|
||||||
cellspacing="0"
|
</td>
|
||||||
class="icons-inner" role="presentation"
|
</tr>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
<div class="footer">
|
||||||
</tr>
|
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>
|
||||||
</tbody>
|
© 2025 Worklenz. All rights reserved.
|
||||||
</table><!-- End -->
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,31 +2,30 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title></title>
|
<title>Join Your Team on Worklenz</title>
|
||||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
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] {
|
.main-container {
|
||||||
color: inherit !important;
|
background: #fff;
|
||||||
text-decoration: inherit !important
|
border-radius: 18px;
|
||||||
}
|
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||||
|
margin: 40px auto 0 auto;
|
||||||
#MessageViewBody a {
|
max-width: 500px;
|
||||||
color: inherit;
|
padding: 0 0 20px 0;
|
||||||
text-decoration: none
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
line-height: inherit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -42,33 +41,68 @@
|
|||||||
mso-hide: all;
|
mso-hide: all;
|
||||||
display: none;
|
display: none;
|
||||||
max-height: 0;
|
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) {
|
@media (max-width: 525px) {
|
||||||
|
.main-container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop_hide table.icons-inner {
|
.desktop_hide table.icons-inner {
|
||||||
display: inline-block !important
|
display: inline-block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner {
|
.icons-inner {
|
||||||
text-align: center
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner td {
|
.icons-inner td {
|
||||||
margin: 0 auto
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-content {
|
.row-content {
|
||||||
width: 95% !important
|
width: 95% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
display: none
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack .column {
|
.stack .column {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
@@ -76,181 +110,134 @@
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0
|
font-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop_hide,
|
.desktop_hide,
|
||||||
.desktop_hide table {
|
.desktop_hide table {
|
||||||
display: table !important;
|
display: table !important;
|
||||||
max-height: none !important
|
max-height: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
<body>
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
<div class="main-container">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="background:transparent;" width="100%">
|
||||||
width="100%">
|
<tbody>
|
||||||
<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>
|
<tr>
|
||||||
<td>
|
<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%">
|
||||||
<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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="column column-1"
|
<td>
|
||||||
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"
|
<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">
|
||||||
width="100%">
|
<tr>
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
<div align="left" class="alignment" style="line-height:10px">
|
||||||
<tr>
|
<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>
|
||||||
<td class="pad">
|
</div>
|
||||||
<h1
|
</td>
|
||||||
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;">
|
</tr>
|
||||||
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
</table>
|
||||||
</h1>
|
<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">
|
||||||
</td>
|
<tbody>
|
||||||
</tr>
|
<tr>
|
||||||
<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%">
|
||||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
<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%">
|
||||||
<div align="center" class="alignment" style="line-height:10px"><img
|
<tr>
|
||||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png"
|
<td class="pad">
|
||||||
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
<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;">
|
||||||
width="180">
|
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
||||||
</div>
|
</h1>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
<tr>
|
||||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
<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">
|
||||||
width="100%">
|
</div>
|
||||||
<tr>
|
</td>
|
||||||
<td class="pad">
|
</tr>
|
||||||
<div
|
</table>
|
||||||
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">
|
<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%">
|
||||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
<tr>
|
||||||
</div>
|
<td class="pad">
|
||||||
</td>
|
<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">
|
||||||
</tr>
|
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
||||||
</table>
|
</div>
|
||||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
</td>
|
||||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
</tr>
|
||||||
width="100%">
|
</table>
|
||||||
<tr>
|
<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%">
|
||||||
<td class="pad">
|
<tr>
|
||||||
<div
|
<td class="pad">
|
||||||
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">
|
<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
|
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team on Worklenz!</p>
|
||||||
on Worklenz!
|
<p>Sign in to your Worklenz account to continue.</p>
|
||||||
</p>
|
</div>
|
||||||
<p>Sign in to your Worklenz account to continue.</p>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
<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%">
|
||||||
</table>
|
<tr>
|
||||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
<td class="pad">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
<div align="center" class="alignment">
|
||||||
width="100%">
|
<a href="https://[VAR_HOSTNAME]/auth/login?team=[VAR_TEAM_ID]&user=[VAR_USER_ID]&project=[PROJECT_ID]" class="modern-btn">
|
||||||
<tr>
|
Go to Worklenz
|
||||||
<td class="pad">
|
</a>
|
||||||
<div align="center" class="alignment">
|
</div>
|
||||||
<a href="https://[VAR_HOSTNAME]/auth/login?team=[VAR_TEAM_ID]&user=[VAR_USER_ID]&project=[PROJECT_ID]">
|
</td>
|
||||||
<div
|
</tr>
|
||||||
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">
|
</table>
|
||||||
<span
|
</td>
|
||||||
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
|
</tr>
|
||||||
dir="ltr" style="word-break: break-word; line-height: 28px;">Go to
|
</tbody>
|
||||||
Worklenz</span></span>
|
</table>
|
||||||
</div>
|
</td>
|
||||||
</a>
|
</tr>
|
||||||
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
</tbody>
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
<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%">
|
||||||
</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>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="column column-1"
|
<td>
|
||||||
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"
|
<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">
|
||||||
width="100%">
|
<tbody>
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
<tr>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
<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%">
|
||||||
<tr>
|
<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%">
|
||||||
<td class="pad"
|
<tr>
|
||||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
<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"
|
<table cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
<tr>
|
||||||
width="100%">
|
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||||
<tr>
|
<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">
|
||||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
</table>
|
||||||
<!--[if vml]>
|
</td>
|
||||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
</tr>
|
||||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
</table>
|
||||||
<![endif]-->
|
</td>
|
||||||
<!--[if !vml]><!-->
|
</tr>
|
||||||
<table cellpadding="0"
|
</table>
|
||||||
cellspacing="0"
|
</td>
|
||||||
class="icons-inner" role="presentation"
|
</tr>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
<div class="footer">
|
||||||
</tr>
|
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>
|
||||||
</tbody>
|
© 2025 Worklenz. All rights reserved.
|
||||||
</table><!-- End -->
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,31 +2,30 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title></title>
|
<title>Join Your Team on Worklenz</title>
|
||||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
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] {
|
.main-container {
|
||||||
color: inherit !important;
|
background: #fff;
|
||||||
text-decoration: inherit !important
|
border-radius: 18px;
|
||||||
}
|
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||||
|
margin: 40px auto 0 auto;
|
||||||
#MessageViewBody a {
|
max-width: 500px;
|
||||||
color: inherit;
|
padding: 0 0 20px 0;
|
||||||
text-decoration: none
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
line-height: inherit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -42,33 +41,68 @@
|
|||||||
mso-hide: all;
|
mso-hide: all;
|
||||||
display: none;
|
display: none;
|
||||||
max-height: 0;
|
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) {
|
@media (max-width: 525px) {
|
||||||
|
.main-container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop_hide table.icons-inner {
|
.desktop_hide table.icons-inner {
|
||||||
display: inline-block !important
|
display: inline-block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner {
|
.icons-inner {
|
||||||
text-align: center
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner td {
|
.icons-inner td {
|
||||||
margin: 0 auto
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-content {
|
.row-content {
|
||||||
width: 95% !important
|
width: 95% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
display: none
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack .column {
|
.stack .column {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
@@ -76,180 +110,174 @@
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0
|
font-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop_hide,
|
.desktop_hide,
|
||||||
.desktop_hide table {
|
.desktop_hide table {
|
||||||
display: table !important;
|
display: table !important;
|
||||||
max-height: none !important
|
max-height: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
<body>
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
<div class="main-container">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||||
width="100%">
|
style="background:transparent;"
|
||||||
<tbody>
|
width="100%">
|
||||||
<tr>
|
<tbody>
|
||||||
<td>
|
<tr>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
<td>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||||
<tbody>
|
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
<tr>
|
<tbody>
|
||||||
<td>
|
<tr>
|
||||||
|
<td>
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
<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;
|
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||||
padding-bottom: 20px;" width="220">
|
padding-bottom: 20px;" width="220">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
<div align="left" class="alignment" style="line-height:10px">
|
<div align="left" class="alignment" style="line-height:10px">
|
||||||
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
||||||
target="_blank"><img
|
target="_blank"><img
|
||||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
|
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>
|
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||||
</div>
|
</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>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||||
</table>
|
role="presentation"
|
||||||
</td>
|
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
|
||||||
</tr>
|
width="475">
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
<tr>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
<td class="column column-1"
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
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%">
|
width="100%">
|
||||||
<tbody>
|
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
|
||||||
<tr>
|
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
<td>
|
<tr>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
<td class="pad">
|
||||||
role="presentation"
|
<h1
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
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;">
|
||||||
<tbody>
|
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
||||||
<tr>
|
</h1>
|
||||||
<td class="column column-1"
|
</td>
|
||||||
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"
|
</tr>
|
||||||
width="100%">
|
<tr>
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
<div align="center" class="alignment" style="line-height:10px"><img
|
||||||
<tr>
|
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-unregistered-team-member.png"
|
||||||
<td class="pad"
|
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
width="180">
|
||||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
</div>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
</td>
|
||||||
width="100%">
|
</tr>
|
||||||
<tr>
|
</table>
|
||||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||||
<!--[if vml]>
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
width="100%">
|
||||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
<tr>
|
||||||
<![endif]-->
|
<td class="pad">
|
||||||
<!--[if !vml]><!-->
|
<div
|
||||||
<table cellpadding="0"
|
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">
|
||||||
cellspacing="0"
|
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
||||||
class="icons-inner" role="presentation"
|
</div>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||||
</table>
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||||
</td>
|
width="100%">
|
||||||
</tr>
|
<tr>
|
||||||
</table>
|
<td class="pad">
|
||||||
</td>
|
<div
|
||||||
</tr>
|
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">
|
||||||
</tbody>
|
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team
|
||||||
</table>
|
on Worklenz!</p>
|
||||||
</td>
|
<p>Create an account in Worklenz to continue.</p>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
||||||
</tbody>
|
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
||||||
</table><!-- End -->
|
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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,31 +2,30 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title></title>
|
<title>Welcome to Worklenz</title>
|
||||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
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] {
|
.main-container {
|
||||||
color: inherit !important;
|
background: #fff;
|
||||||
text-decoration: inherit !important
|
border-radius: 18px;
|
||||||
}
|
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||||
|
margin: 40px auto 0 auto;
|
||||||
#MessageViewBody a {
|
max-width: 500px;
|
||||||
color: inherit;
|
padding: 0 0 20px 0;
|
||||||
text-decoration: none
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
line-height: inherit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -42,33 +41,68 @@
|
|||||||
mso-hide: all;
|
mso-hide: all;
|
||||||
display: none;
|
display: none;
|
||||||
max-height: 0;
|
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) {
|
@media (max-width: 525px) {
|
||||||
|
.main-container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop_hide table.icons-inner {
|
.desktop_hide table.icons-inner {
|
||||||
display: inline-block !important
|
display: inline-block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner {
|
.icons-inner {
|
||||||
text-align: center
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icons-inner td {
|
.icons-inner td {
|
||||||
margin: 0 auto
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-content {
|
.row-content {
|
||||||
width: 95% !important
|
width: 95% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
display: none
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack .column {
|
.stack .column {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile_hide {
|
.mobile_hide {
|
||||||
@@ -76,179 +110,173 @@
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0
|
font-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop_hide,
|
.desktop_hide,
|
||||||
.desktop_hide table {
|
.desktop_hide table {
|
||||||
display: table !important;
|
display: table !important;
|
||||||
max-height: none !important
|
max-height: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
<body>
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
<div class="main-container">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||||
width="100%">
|
style="mso-table-lspace:0;mso-table-rspace:0;background:transparent;"
|
||||||
<tbody>
|
width="100%">
|
||||||
<tr>
|
<tbody>
|
||||||
<td>
|
<tr>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
<td>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||||
<tbody>
|
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
<tr>
|
<tbody>
|
||||||
<td>
|
<tr>
|
||||||
|
<td>
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
<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;
|
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||||
padding-bottom: 20px;" width="220">
|
padding-bottom: 20px;" width="220">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
<div align="left" class="alignment" style="line-height:10px">
|
<div align="left" class="alignment" style="line-height:10px">
|
||||||
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
||||||
target="_blank"><img
|
target="_blank"><img
|
||||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
|
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>
|
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||||
</div>
|
</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>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||||
</table>
|
role="presentation"
|
||||||
</td>
|
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
|
||||||
</tr>
|
width="475">
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
<tr>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
<td class="column column-1"
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
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%">
|
width="100%">
|
||||||
<tbody>
|
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
|
||||||
<tr>
|
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
<td>
|
<tr>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
<td class="pad">
|
||||||
role="presentation"
|
<h1
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
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;">
|
||||||
<tbody>
|
<span class="tinyMce-placeholder">Let's get started with Worklenz.</span>
|
||||||
<tr>
|
</h1>
|
||||||
<td class="column column-1"
|
</td>
|
||||||
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"
|
</tr>
|
||||||
width="100%">
|
<tr>
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
<div align="center" class="alignment" style="line-height:10px"><img
|
||||||
<tr>
|
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-signup.png"
|
||||||
<td class="pad"
|
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
width="180">
|
||||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
</div>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
</td>
|
||||||
width="100%">
|
</tr>
|
||||||
<tr>
|
</table>
|
||||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||||
<!--[if vml]>
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
width="100%">
|
||||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
<tr>
|
||||||
<![endif]-->
|
<td class="pad">
|
||||||
<!--[if !vml]><!-->
|
<div
|
||||||
<table cellpadding="0"
|
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">
|
||||||
cellspacing="0"
|
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
||||||
class="icons-inner" role="presentation"
|
</div>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||||
</table>
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||||
</td>
|
width="100%">
|
||||||
</tr>
|
<tr>
|
||||||
</table>
|
<td class="pad">
|
||||||
</td>
|
<div
|
||||||
</tr>
|
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">
|
||||||
</tbody>
|
<p style="margin:0;margin-bottom:10px">Thanks for joining Worklenz!</p>
|
||||||
</table>
|
<p style="margin:0"> We're excited to have you on board. </p>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
||||||
</tr>
|
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
||||||
</tbody>
|
width="100%">
|
||||||
</table><!-- End -->
|
<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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ COPY . .
|
|||||||
RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \
|
RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \
|
||||||
echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js
|
echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js
|
||||||
|
|
||||||
RUN npm run build
|
RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS production
|
FROM node:22-alpine AS production
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,17 @@
|
|||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="./src/index.tsx"></script>
|
<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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -47,5 +47,6 @@
|
|||||||
"weightedProgress": "Weighted Progress",
|
"weightedProgress": "Weighted Progress",
|
||||||
"weightedProgressTooltip": "Calculate progress based on subtask weights",
|
"weightedProgressTooltip": "Calculate progress based on subtask weights",
|
||||||
"timeProgress": "Time-based Progress",
|
"timeProgress": "Time-based Progress",
|
||||||
"timeProgressTooltip": "Calculate progress based on estimated time"
|
"timeProgressTooltip": "Calculate progress based on estimated time",
|
||||||
|
"enterProjectKey": "Enter project key"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"hide-start-date": "Hide Start Date",
|
"hide-start-date": "Hide Start Date",
|
||||||
"show-start-date": "Show Start Date",
|
"show-start-date": "Show Start Date",
|
||||||
"hours": "Hours",
|
"hours": "Hours",
|
||||||
"minutes": "Minutes"
|
"minutes": "Minutes",
|
||||||
|
"recurring": "Recurring"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"title": "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",
|
"taskWeight": "Task Weight",
|
||||||
"taskWeightTooltip": "Set the weight of this subtask (percentage)",
|
"taskWeightTooltip": "Set the weight of this subtask (percentage)",
|
||||||
"taskWeightRequired": "Please enter a task weight",
|
"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": {
|
"labels": {
|
||||||
"labelInputPlaceholder": "Search or create",
|
"labelInputPlaceholder": "Search or create",
|
||||||
|
|||||||
@@ -47,5 +47,6 @@
|
|||||||
"weightedProgress": "Progreso Ponderado",
|
"weightedProgress": "Progreso Ponderado",
|
||||||
"weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas",
|
"weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas",
|
||||||
"timeProgress": "Progreso Basado en Tiempo",
|
"timeProgress": "Progreso Basado en Tiempo",
|
||||||
"timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado"
|
"timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado",
|
||||||
|
"enterProjectKey": "Ingresa la clave del proyecto"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"hide-start-date": "Ocultar fecha de inicio",
|
"hide-start-date": "Ocultar fecha de inicio",
|
||||||
"show-start-date": "Mostrar fecha de inicio",
|
"show-start-date": "Mostrar fecha de inicio",
|
||||||
"hours": "Horas",
|
"hours": "Horas",
|
||||||
"minutes": "Minutos"
|
"minutes": "Minutos",
|
||||||
|
"recurring": "Recurrente"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"title": "Descripción",
|
"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",
|
"taskWeight": "Peso de la Tarea",
|
||||||
"taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)",
|
"taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)",
|
||||||
"taskWeightRequired": "Por favor, introduce un peso para la tarea",
|
"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": {
|
"labels": {
|
||||||
"labelInputPlaceholder": "Buscar o crear",
|
"labelInputPlaceholder": "Buscar o crear",
|
||||||
|
|||||||
@@ -47,5 +47,6 @@
|
|||||||
"weightedProgress": "Progresso Ponderado",
|
"weightedProgress": "Progresso Ponderado",
|
||||||
"weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas",
|
"weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas",
|
||||||
"timeProgress": "Progresso Baseado em Tempo",
|
"timeProgress": "Progresso Baseado em Tempo",
|
||||||
"timeProgressTooltip": "Calcular o progresso com base no tempo estimado"
|
"timeProgressTooltip": "Calcular o progresso com base no tempo estimado",
|
||||||
|
"enterProjectKey": "Insira a chave do projeto"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"hide-start-date": "Ocultar data de início",
|
"hide-start-date": "Ocultar data de início",
|
||||||
"show-start-date": "Mostrar data de início",
|
"show-start-date": "Mostrar data de início",
|
||||||
"hours": "Horas",
|
"hours": "Horas",
|
||||||
"minutes": "Minutos"
|
"minutes": "Minutos",
|
||||||
|
"recurring": "Recorrente"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"title": "Descrição",
|
"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",
|
"taskWeight": "Peso da Tarefa",
|
||||||
"taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)",
|
"taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)",
|
||||||
"taskWeightRequired": "Por favor, insira um peso para a tarefa",
|
"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": {
|
"labels": {
|
||||||
"labelInputPlaceholder": "Pesquisar ou criar",
|
"labelInputPlaceholder": "Pesquisar ou criar",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import router from './app/routes';
|
|||||||
// Hooks & Utils
|
// Hooks & Utils
|
||||||
import { useAppSelector } from './hooks/useAppSelector';
|
import { useAppSelector } from './hooks/useAppSelector';
|
||||||
import { initMixpanel } from './utils/mixpanelInit';
|
import { initMixpanel } from './utils/mixpanelInit';
|
||||||
|
import { initializeCsrfToken } from './api/api-client';
|
||||||
|
|
||||||
// Types & Constants
|
// Types & Constants
|
||||||
import { Language } from './features/i18n/localesSlice';
|
import { Language } from './features/i18n/localesSlice';
|
||||||
@@ -35,6 +36,13 @@ const App: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
});
|
});
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
|
// Initialize CSRF token on app startup
|
||||||
|
useEffect(() => {
|
||||||
|
initializeCsrfToken().catch(error => {
|
||||||
|
logger.error('Failed to initialize CSRF token:', error);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<SuspenseFallback />}>
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
<ThemeWrapper>
|
<ThemeWrapper>
|
||||||
|
|||||||
@@ -4,27 +4,36 @@ import alertService from '@/services/alerts/alertService';
|
|||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import config from '@/config/env';
|
import config from '@/config/env';
|
||||||
|
|
||||||
export const getCsrfToken = (): string | null => {
|
// Store CSRF token in memory (since csrf-sync uses session-based tokens)
|
||||||
const match = document.cookie.split('; ').find(cookie => cookie.startsWith('XSRF-TOKEN='));
|
let csrfToken: string | null = null;
|
||||||
|
|
||||||
if (!match) {
|
export const getCsrfToken = (): string | null => {
|
||||||
return null;
|
return csrfToken;
|
||||||
}
|
|
||||||
return decodeURIComponent(match.split('=')[1]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to refresh CSRF token if needed
|
// Function to refresh CSRF token from server
|
||||||
export const refreshCsrfToken = async (): Promise<string | null> => {
|
export const refreshCsrfToken = async (): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
// Make a GET request to the server to get a fresh CSRF token
|
// Make a GET request to the server to get a fresh CSRF token
|
||||||
await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
|
const response = await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
|
||||||
return getCsrfToken();
|
if (response.data && response.data.token) {
|
||||||
|
csrfToken = response.data.token;
|
||||||
|
return csrfToken;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh CSRF token:', error);
|
console.error('Failed to refresh CSRF token:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialize CSRF token on app load
|
||||||
|
export const initializeCsrfToken = async (): Promise<void> => {
|
||||||
|
if (!csrfToken) {
|
||||||
|
await refreshCsrfToken();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: config.apiUrl,
|
baseURL: config.apiUrl,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
@@ -36,12 +45,16 @@ const apiClient = axios.create({
|
|||||||
|
|
||||||
// Request interceptor
|
// Request interceptor
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
config => {
|
async config => {
|
||||||
const token = getCsrfToken();
|
// Ensure we have a CSRF token before making requests
|
||||||
if (token) {
|
if (!csrfToken) {
|
||||||
config.headers['X-CSRF-Token'] = token;
|
await refreshCsrfToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (csrfToken) {
|
||||||
|
config.headers['X-CSRF-Token'] = csrfToken;
|
||||||
} else {
|
} else {
|
||||||
console.warn('No CSRF token found');
|
console.warn('No CSRF token available');
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
@@ -84,7 +97,7 @@ apiClient.interceptors.response.use(
|
|||||||
(typeof errorResponse.data === 'object' &&
|
(typeof errorResponse.data === 'object' &&
|
||||||
errorResponse.data !== null &&
|
errorResponse.data !== null &&
|
||||||
'message' in errorResponse.data &&
|
'message' in errorResponse.data &&
|
||||||
errorResponse.data.message === 'Invalid CSRF token' ||
|
(errorResponse.data.message === 'invalid csrf token' || errorResponse.data.message === 'Invalid CSRF token') ||
|
||||||
(error as any).code === 'EBADCSRFTOKEN')) {
|
(error as any).code === 'EBADCSRFTOKEN')) {
|
||||||
alertService.error('Security Error', 'Invalid security token. Refreshing your session...');
|
alertService.error('Security Error', 'Invalid security token. Refreshing your session...');
|
||||||
|
|
||||||
@@ -94,7 +107,7 @@ apiClient.interceptors.response.use(
|
|||||||
// Update the token in the failed request
|
// Update the token in the failed request
|
||||||
error.config.headers['X-CSRF-Token'] = newToken;
|
error.config.headers['X-CSRF-Token'] = newToken;
|
||||||
// Retry the original request with the new token
|
// Retry the original request with the new token
|
||||||
return axios(error.config);
|
return apiClient(error.config);
|
||||||
} else {
|
} else {
|
||||||
// If token refresh failed, redirect to login
|
// If token refresh failed, redirect to login
|
||||||
window.location.href = '/auth/login';
|
window.location.href = '/auth/login';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { toQueryString } from '@/utils/toQueryString';
|
|||||||
import { IHomeTasksModel, IHomeTasksConfig } from '@/types/home/home-page.types';
|
import { IHomeTasksModel, IHomeTasksConfig } from '@/types/home/home-page.types';
|
||||||
import { IMyTask } from '@/types/home/my-tasks.types';
|
import { IMyTask } from '@/types/home/my-tasks.types';
|
||||||
import { IProject } from '@/types/project/project.types';
|
import { IProject } from '@/types/project/project.types';
|
||||||
import { getCsrfToken } from '../api-client';
|
import { getCsrfToken, refreshCsrfToken } from '../api-client';
|
||||||
import config from '@/config/env';
|
import config from '@/config/env';
|
||||||
|
|
||||||
const rootUrl = '/home';
|
const rootUrl = '/home';
|
||||||
@@ -14,9 +14,18 @@ const api = createApi({
|
|||||||
reducerPath: 'homePageApi',
|
reducerPath: 'homePageApi',
|
||||||
baseQuery: fetchBaseQuery({
|
baseQuery: fetchBaseQuery({
|
||||||
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
||||||
prepareHeaders: headers => {
|
prepareHeaders: async headers => {
|
||||||
headers.set('X-CSRF-Token', getCsrfToken() || '');
|
// Get CSRF token, refresh if needed
|
||||||
|
let token = getCsrfToken();
|
||||||
|
if (!token) {
|
||||||
|
token = await refreshCsrfToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers.set('X-CSRF-Token', token);
|
||||||
|
}
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
|
return headers;
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { IProjectCategory } from '@/types/project/projectCategory.types';
|
|||||||
import { IProjectsViewModel } from '@/types/project/projectsViewModel.types';
|
import { IProjectsViewModel } from '@/types/project/projectsViewModel.types';
|
||||||
import { IServerResponse } from '@/types/common.types';
|
import { IServerResponse } from '@/types/common.types';
|
||||||
import { IProjectMembersViewModel } from '@/types/projectMember.types';
|
import { IProjectMembersViewModel } from '@/types/projectMember.types';
|
||||||
import { getCsrfToken } from '../api-client';
|
import { getCsrfToken, refreshCsrfToken } from '../api-client';
|
||||||
import config from '@/config/env';
|
import config from '@/config/env';
|
||||||
|
|
||||||
const rootUrl = '/projects';
|
const rootUrl = '/projects';
|
||||||
@@ -14,9 +14,18 @@ export const projectsApi = createApi({
|
|||||||
reducerPath: 'projectsApi',
|
reducerPath: 'projectsApi',
|
||||||
baseQuery: fetchBaseQuery({
|
baseQuery: fetchBaseQuery({
|
||||||
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
||||||
prepareHeaders: headers => {
|
prepareHeaders: async headers => {
|
||||||
headers.set('X-CSRF-Token', getCsrfToken() || '');
|
// Get CSRF token, refresh if needed
|
||||||
|
let token = getCsrfToken();
|
||||||
|
if (!token) {
|
||||||
|
token = await refreshCsrfToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers.set('X-CSRF-Token', token);
|
||||||
|
}
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
|
return headers;
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,16 @@ import { ITaskLogViewModel } from "@/types/tasks/task-log-view.types";
|
|||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/task-time-log`;
|
const rootUrl = `${API_BASE_URL}/task-time-log`;
|
||||||
|
|
||||||
|
export interface IRunningTimer {
|
||||||
|
task_id: string;
|
||||||
|
start_time: string;
|
||||||
|
task_name: string;
|
||||||
|
project_id: string;
|
||||||
|
project_name: string;
|
||||||
|
parent_task_id?: string;
|
||||||
|
parent_task_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const taskTimeLogsApiService = {
|
export const taskTimeLogsApiService = {
|
||||||
getByTask: async (id: string) : Promise<IServerResponse<ITaskLogViewModel[]>> => {
|
getByTask: async (id: string) : Promise<IServerResponse<ITaskLogViewModel[]>> => {
|
||||||
const response = await apiClient.get(`${rootUrl}/task/${id}`);
|
const response = await apiClient.get(`${rootUrl}/task/${id}`);
|
||||||
@@ -26,6 +36,11 @@ export const taskTimeLogsApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getRunningTimers: async (): Promise<IServerResponse<IRunningTimer[]>> => {
|
||||||
|
const response = await apiClient.get(`${rootUrl}/running-timers`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
exportToExcel(taskId: string) {
|
exportToExcel(taskId: string) {
|
||||||
window.location.href = `${import.meta.env.VITE_API_URL}${API_BASE_URL}/task-time-log/export/${taskId}`;
|
window.location.href = `${import.meta.env.VITE_API_URL}${API_BASE_URL}/task-time-log/export/${taskId}`;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type EmptyListPlaceholderProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EmptyListPlaceholder = ({
|
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,
|
imageHeight = 60,
|
||||||
text,
|
text,
|
||||||
}: EmptyListPlaceholderProps) => {
|
}: 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;
|
||||||
@@ -21,10 +21,10 @@ const HomeTasksStatusDropdown = ({ task, teamId }: HomeTasksStatusDropdownProps)
|
|||||||
const { socket, connected } = useSocket();
|
const { socket, connected } = useSocket();
|
||||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||||
const {
|
const {
|
||||||
refetch
|
refetch
|
||||||
} = useGetMyTasksQuery(homeTasksConfig, {
|
} = useGetMyTasksQuery(homeTasksConfig, {
|
||||||
skip: true // Skip automatic queries entirely
|
skip: false, // Ensure this query runs
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState<ITaskStatus | undefined>(undefined);
|
const [selectedStatus, setSelectedStatus] = useState<ITaskStatus | undefined>(undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ const HomeTasksDatePicker = ({ record }: HomeTasksDatePickerProps) => {
|
|||||||
const { t } = useTranslation('home');
|
const { t } = useTranslation('home');
|
||||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||||
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
|
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
|
||||||
skip: true // Skip automatic queries entirely
|
skip: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use useMemo to avoid re-renders when record.end_date is the same
|
// Use useMemo to avoid re-renders when record.end_date is the same
|
||||||
const initialDate = useMemo(() =>
|
const initialDate = useMemo(() =>
|
||||||
record.end_date ? dayjs(record.end_date) : null
|
record.end_date ? dayjs(record.end_date) : null
|
||||||
, [record.end_date]);
|
, [record.end_date]);
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(initialDate);
|
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(initialDate);
|
||||||
|
|
||||||
// Update selected date when record changes
|
// Update selected date when record changes
|
||||||
|
|||||||
@@ -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,8 @@ import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billa
|
|||||||
import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progress';
|
import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progress';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
|
import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/task-drawer-recurring-config';
|
||||||
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
|
|
||||||
interface TaskDetailsFormProps {
|
interface TaskDetailsFormProps {
|
||||||
taskFormViewModel?: ITaskFormViewModel | null;
|
taskFormViewModel?: ITaskFormViewModel | null;
|
||||||
@@ -44,29 +46,32 @@ const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps)
|
|||||||
const { project } = useAppSelector(state => state.projectReducer);
|
const { project } = useAppSelector(state => state.projectReducer);
|
||||||
const hasSubTasks = task?.sub_tasks_count > 0;
|
const hasSubTasks = task?.sub_tasks_count > 0;
|
||||||
const isSubTask = !!task?.parent_task_id;
|
const isSubTask = !!task?.parent_task_id;
|
||||||
|
|
||||||
// Add more aggressive logging and checks
|
|
||||||
logger.debug(`Task ${task.id} status: hasSubTasks=${hasSubTasks}, isSubTask=${isSubTask}, modes: time=${project?.use_time_progress}, manual=${project?.use_manual_progress}, weighted=${project?.use_weighted_progress}`);
|
|
||||||
|
|
||||||
// STRICT RULE: Never show progress input for parent tasks with subtasks
|
// STRICT RULE: Never show progress input for parent tasks with subtasks
|
||||||
// This is the most important check and must be done first
|
// This is the most important check and must be done first
|
||||||
if (hasSubTasks) {
|
if (hasSubTasks) {
|
||||||
logger.debug(`Task ${task.id} has ${task.sub_tasks_count} subtasks. Hiding progress input.`);
|
logger.debug(`Task ${task.id} has ${task.sub_tasks_count} subtasks. Hiding progress input.`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only for tasks without subtasks, determine which input to show based on project mode
|
// Only for tasks without subtasks, determine which input to show based on project mode
|
||||||
if (project?.use_time_progress) {
|
if (project?.use_time_progress) {
|
||||||
// In time-based mode, show progress input ONLY for tasks without subtasks
|
// In time-based mode, show progress input ONLY for tasks without subtasks
|
||||||
return <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
|
return (
|
||||||
|
<TaskDrawerProgress task={{ ...task, sub_tasks_count: hasSubTasks ? 1 : 0 }} form={form} />
|
||||||
|
);
|
||||||
} else if (project?.use_manual_progress) {
|
} else if (project?.use_manual_progress) {
|
||||||
// In manual mode, show progress input ONLY for tasks without subtasks
|
// In manual mode, show progress input ONLY for tasks without subtasks
|
||||||
return <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
|
return (
|
||||||
|
<TaskDrawerProgress task={{ ...task, sub_tasks_count: hasSubTasks ? 1 : 0 }} form={form} />
|
||||||
|
);
|
||||||
} else if (project?.use_weighted_progress && isSubTask) {
|
} else if (project?.use_weighted_progress && isSubTask) {
|
||||||
// In weighted mode, show weight input for subtasks
|
// In weighted mode, show weight input for subtasks
|
||||||
return <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
|
return (
|
||||||
|
<TaskDrawerProgress task={{ ...task, sub_tasks_count: hasSubTasks ? 1 : 0 }} form={form} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,7 +152,13 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
|||||||
|
|
||||||
<Form.Item name="assignees" label={t('taskInfoTab.details.assignees')}>
|
<Form.Item name="assignees" label={t('taskInfoTab.details.assignees')}>
|
||||||
<Flex gap={4} align="center">
|
<Flex gap={4} align="center">
|
||||||
<Avatars members={taskFormViewModel?.task?.assignee_names || []} />
|
<Avatars
|
||||||
|
members={
|
||||||
|
taskFormViewModel?.task?.assignee_names ||
|
||||||
|
(taskFormViewModel?.task?.names as unknown as InlineMember[]) ||
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
/>
|
||||||
<TaskDrawerAssigneeSelector
|
<TaskDrawerAssigneeSelector
|
||||||
task={(taskFormViewModel?.task as ITaskViewModel) || null}
|
task={(taskFormViewModel?.task as ITaskViewModel) || null}
|
||||||
/>
|
/>
|
||||||
@@ -159,10 +170,7 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
|||||||
<TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} />
|
<TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} />
|
||||||
|
|
||||||
{taskFormViewModel?.task && (
|
{taskFormViewModel?.task && (
|
||||||
<ConditionalProgressInput
|
<ConditionalProgressInput task={taskFormViewModel?.task as ITaskViewModel} form={form} />
|
||||||
task={taskFormViewModel?.task as ITaskViewModel}
|
|
||||||
form={form}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Form.Item name="priority" label={t('taskInfoTab.details.priority')}>
|
<Form.Item name="priority" label={t('taskInfoTab.details.priority')}>
|
||||||
@@ -175,6 +183,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
|||||||
<TaskDrawerBillable task={taskFormViewModel?.task as ITaskViewModel} />
|
<TaskDrawerBillable task={taskFormViewModel?.task as ITaskViewModel} />
|
||||||
</Form.Item>
|
</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')}>
|
<Form.Item name="notify" label={t('taskInfoTab.details.notify')}>
|
||||||
<NotifyMemberSelector task={taskFormViewModel?.task as ITaskViewModel} t={t} />
|
<NotifyMemberSelector task={taskFormViewModel?.task as ITaskViewModel} t={t} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
|||||||
const { socket, connected } = useSocket();
|
const { socket, connected } = useSocket();
|
||||||
const { clearTaskFromUrl } = useTaskDrawerUrlSync();
|
const { clearTaskFromUrl } = useTaskDrawerUrlSync();
|
||||||
const isDeleting = useRef(false);
|
const isDeleting = useRef(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
||||||
const [taskName, setTaskName] = useState<string>(taskFormViewModel?.task?.name ?? '');
|
const [taskName, setTaskName] = useState<string>(taskFormViewModel?.task?.name ?? '');
|
||||||
@@ -88,6 +89,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInputBlur = () => {
|
const handleInputBlur = () => {
|
||||||
|
setIsEditing(false);
|
||||||
if (
|
if (
|
||||||
!selectedTaskId ||
|
!selectedTaskId ||
|
||||||
!connected ||
|
!connected ||
|
||||||
@@ -113,21 +115,39 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
|||||||
return (
|
return (
|
||||||
<Flex gap={12} align="center" style={{ marginBlockEnd: 6 }}>
|
<Flex gap={12} align="center" style={{ marginBlockEnd: 6 }}>
|
||||||
<Flex style={{ position: 'relative', width: '100%' }}>
|
<Flex style={{ position: 'relative', width: '100%' }}>
|
||||||
<Input
|
{isEditing ? (
|
||||||
ref={inputRef}
|
<Input
|
||||||
size="large"
|
ref={inputRef}
|
||||||
value={taskName}
|
size="large"
|
||||||
onChange={e => onTaskNameChange(e)}
|
value={taskName}
|
||||||
onBlur={handleInputBlur}
|
onChange={e => onTaskNameChange(e)}
|
||||||
placeholder={t('taskHeader.taskNamePlaceholder')}
|
onBlur={handleInputBlur}
|
||||||
className="task-name-input"
|
placeholder={t('taskHeader.taskNamePlaceholder')}
|
||||||
style={{
|
className="task-name-input"
|
||||||
width: '100%',
|
style={{
|
||||||
border: 'none',
|
width: '100%',
|
||||||
}}
|
border: 'none',
|
||||||
showCount={false}
|
}}
|
||||||
maxLength={250}
|
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>
|
</Flex>
|
||||||
|
|
||||||
<TaskDrawerStatusDropdown
|
<TaskDrawerStatusDropdown
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { ITaskViewModel } from '@/types/tasks/task.types';
|
|||||||
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
|
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
|
||||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||||
import { Select } from 'antd';
|
import { Select } from 'antd';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
interface TaskDrawerStatusDropdownProps {
|
interface TaskDrawerStatusDropdownProps {
|
||||||
statuses: ITaskStatus[];
|
statuses: ITaskStatus[];
|
||||||
@@ -21,7 +21,7 @@ interface TaskDrawerStatusDropdownProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TaskDrawerStatusDropdown = ({ statuses, task, teamId }: TaskDrawerStatusDropdownProps) => {
|
const TaskDrawerStatusDropdown = ({ statuses, task, teamId }: TaskDrawerStatusDropdownProps) => {
|
||||||
const { socket, connected } = useSocket();
|
const { socket } = useSocket();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
const { tab } = useTabSearchParam();
|
const { tab } = useTabSearchParam();
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ import alertService from '@/services/alerts/alertService';
|
|||||||
|
|
||||||
interface ITaskAssignee {
|
interface ITaskAssignee {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
team_member_id: string;
|
team_member_id: string;
|
||||||
@@ -437,7 +437,7 @@ const TaskListBulkActionsBar = () => {
|
|||||||
placement="top"
|
placement="top"
|
||||||
arrow
|
arrow
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
destroyPopupOnHide
|
destroyOnHidden
|
||||||
onOpenChange={value => {
|
onOpenChange={value => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
setSelectedLabels([]);
|
setSelectedLabels([]);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Col, ConfigProvider, Flex, Menu, MenuProps, Alert } from 'antd';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members';
|
import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members';
|
||||||
import HelpButton from './help/HelpButton';
|
|
||||||
import InviteButton from './invite/InviteButton';
|
import InviteButton from './invite/InviteButton';
|
||||||
import MobileMenuButton from './mobileMenu/MobileMenuButton';
|
import MobileMenuButton from './mobileMenu/MobileMenuButton';
|
||||||
import NavbarLogo from './navbar-logo';
|
import NavbarLogo from './navbar-logo';
|
||||||
@@ -22,6 +21,7 @@ import { useAuthService } from '@/hooks/useAuth';
|
|||||||
import { authApiService } from '@/api/auth/auth.api.service';
|
import { authApiService } from '@/api/auth/auth.api.service';
|
||||||
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
|
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
|
import TimerButton from './timers/timer-button';
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const [current, setCurrent] = useState<string>('home');
|
const [current, setCurrent] = useState<string>('home');
|
||||||
@@ -90,6 +90,7 @@ const Navbar = () => {
|
|||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<Col
|
<Col
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -101,14 +102,6 @@ const Navbar = () => {
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && (
|
|
||||||
<Alert
|
|
||||||
message={daysUntilExpiry > 0 ? `Your license will expire in ${daysUntilExpiry} days` : `Your license has expired ${Math.abs(daysUntilExpiry)} days ago`}
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
style={{ width: '100%', marginTop: 12 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Flex
|
<Flex
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -152,7 +145,7 @@ const Navbar = () => {
|
|||||||
<Flex align="center">
|
<Flex align="center">
|
||||||
<SwitchTeamButton />
|
<SwitchTeamButton />
|
||||||
<NotificationButton />
|
<NotificationButton />
|
||||||
<HelpButton />
|
<TimerButton />
|
||||||
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
|
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
275
worklenz-frontend/src/features/navbar/timers/timer-button.tsx
Normal file
275
worklenz-frontend/src/features/navbar/timers/timer-button.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { ClockCircleOutlined, StopOutlined } from '@ant-design/icons';
|
||||||
|
import { Badge, Button, Dropdown, List, Tooltip, Typography, Space, Divider, theme } from 'antd';
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { taskTimeLogsApiService, IRunningTimer } from '@/api/tasks/task-time-logs.api.service';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const { useToken } = theme;
|
||||||
|
|
||||||
|
const TimerButton = () => {
|
||||||
|
const [runningTimers, setRunningTimers] = useState<IRunningTimer[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [currentTimes, setCurrentTimes] = useState<Record<string, string>>({});
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const { t } = useTranslation('navbar');
|
||||||
|
const { token } = useToken();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { socket } = useSocket();
|
||||||
|
|
||||||
|
const fetchRunningTimers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await taskTimeLogsApiService.getRunningTimers();
|
||||||
|
if (response.done) {
|
||||||
|
setRunningTimers(response.body || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching running timers:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateCurrentTimes = () => {
|
||||||
|
const newTimes: Record<string, string> = {};
|
||||||
|
runningTimers.forEach(timer => {
|
||||||
|
const startTime = moment(timer.start_time);
|
||||||
|
const now = moment();
|
||||||
|
const duration = moment.duration(now.diff(startTime));
|
||||||
|
const hours = Math.floor(duration.asHours());
|
||||||
|
const minutes = duration.minutes();
|
||||||
|
const seconds = duration.seconds();
|
||||||
|
newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
setCurrentTimes(newTimes);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRunningTimers();
|
||||||
|
|
||||||
|
// Set up polling to refresh timers every 30 seconds
|
||||||
|
const pollInterval = setInterval(() => {
|
||||||
|
fetchRunningTimers();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => clearInterval(pollInterval);
|
||||||
|
}, [fetchRunningTimers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (runningTimers.length > 0) {
|
||||||
|
updateCurrentTimes();
|
||||||
|
const interval = setInterval(updateCurrentTimes, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [runningTimers]);
|
||||||
|
|
||||||
|
// Listen for timer start/stop events and project updates to refresh the count
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
const handleTimerStart = (data: string) => {
|
||||||
|
try {
|
||||||
|
const { id } = typeof data === 'string' ? JSON.parse(data) : data;
|
||||||
|
if (id) {
|
||||||
|
// Refresh the running timers list when a new timer is started
|
||||||
|
fetchRunningTimers();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing timer start event:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimerStop = (data: string) => {
|
||||||
|
try {
|
||||||
|
const { id } = typeof data === 'string' ? JSON.parse(data) : data;
|
||||||
|
if (id) {
|
||||||
|
// Refresh the running timers list when a timer is stopped
|
||||||
|
fetchRunningTimers();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing timer stop event:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProjectUpdates = () => {
|
||||||
|
// Refresh timers when project updates are available
|
||||||
|
fetchRunningTimers();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
|
||||||
|
socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
|
||||||
|
socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
|
||||||
|
socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
|
||||||
|
socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
|
||||||
|
};
|
||||||
|
}, [socket, fetchRunningTimers]);
|
||||||
|
|
||||||
|
const hasRunningTimers = () => {
|
||||||
|
return runningTimers.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timerCount = () => {
|
||||||
|
return runningTimers.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopTimer = (taskId: string) => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId }));
|
||||||
|
dispatch(updateTaskTimeTracking({ taskId, timeTracking: null }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropdownContent = (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 350,
|
||||||
|
maxHeight: 400,
|
||||||
|
overflow: 'auto',
|
||||||
|
backgroundColor: token.colorBgElevated,
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
boxShadow: token.boxShadowSecondary,
|
||||||
|
border: `1px solid ${token.colorBorderSecondary}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{runningTimers.length === 0 ? (
|
||||||
|
<div style={{ padding: 16, textAlign: 'center' }}>
|
||||||
|
<Text type="secondary">No running timers</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<List
|
||||||
|
dataSource={runningTimers}
|
||||||
|
renderItem={(timer) => (
|
||||||
|
<List.Item
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||||
|
<Text strong style={{ fontSize: 14, color: token.colorText }}>
|
||||||
|
{timer.task_name}
|
||||||
|
</Text>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
backgroundColor: token.colorPrimaryBg,
|
||||||
|
color: token.colorPrimary,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: token.borderRadiusSM,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginTop: 2
|
||||||
|
}}>
|
||||||
|
{timer.project_name}
|
||||||
|
</div>
|
||||||
|
{timer.parent_task_name && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
Parent: {timer.parent_task_name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
Started: {moment(timer.start_time).format('HH:mm')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: token.colorPrimary,
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentTimes[timer.task_id] || '00:00:00'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<StopOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleStopTimer(timer.task_id);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
backgroundColor: token.colorErrorBg,
|
||||||
|
borderColor: token.colorError,
|
||||||
|
color: token.colorError,
|
||||||
|
fontWeight: 500
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{runningTimers.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider style={{ margin: 0, borderColor: token.colorBorderSecondary }} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
backgroundColor: token.colorFillQuaternary,
|
||||||
|
borderBottomLeftRadius: token.borderRadius,
|
||||||
|
borderBottomRightRadius: token.borderRadius
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{runningTimers.length} timer{runningTimers.length !== 1 ? 's' : ''} running
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
popupRender={() => dropdownContent}
|
||||||
|
trigger={['click']}
|
||||||
|
placement="bottomRight"
|
||||||
|
open={dropdownOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDropdownOpen(open);
|
||||||
|
if (open) {
|
||||||
|
fetchRunningTimers();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title="Running Timers">
|
||||||
|
<Button
|
||||||
|
style={{ height: '62px', width: '60px' }}
|
||||||
|
type="text"
|
||||||
|
icon={
|
||||||
|
hasRunningTimers() ? (
|
||||||
|
<Badge count={timerCount()}>
|
||||||
|
<ClockCircleOutlined style={{ fontSize: 20 }} />
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<ClockCircleOutlined style={{ fontSize: 20 }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimerButton;
|
||||||
@@ -105,6 +105,15 @@ const taskDrawerSlice = createSlice({
|
|||||||
}>) => {
|
}>) => {
|
||||||
state.timeLogEditing = action.payload;
|
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 => {
|
extraReducers: builder => {
|
||||||
builder.addCase(fetchTask.pending, state => {
|
builder.addCase(fetchTask.pending, state => {
|
||||||
@@ -133,5 +142,6 @@ export const {
|
|||||||
setTaskLabels,
|
setTaskLabels,
|
||||||
setTaskSubscribers,
|
setTaskSubscribers,
|
||||||
setTimeLogEditing,
|
setTimeLogEditing,
|
||||||
|
setTaskRecurringSchedule
|
||||||
} = taskDrawerSlice.actions;
|
} = taskDrawerSlice.actions;
|
||||||
export default taskDrawerSlice.reducer;
|
export default taskDrawerSlice.reducer;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-respon
|
|||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
|
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule';
|
||||||
|
|
||||||
export enum IGroupBy {
|
export enum IGroupBy {
|
||||||
STATUS = 'status',
|
STATUS = 'status',
|
||||||
@@ -1006,6 +1007,15 @@ const taskSlice = createSlice({
|
|||||||
column.pinned = isVisible;
|
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 => {
|
extraReducers: builder => {
|
||||||
@@ -1165,6 +1175,7 @@ export const {
|
|||||||
updateSubTasks,
|
updateSubTasks,
|
||||||
updateCustomColumnValue,
|
updateCustomColumnValue,
|
||||||
updateCustomColumnPinned,
|
updateCustomColumnPinned,
|
||||||
|
updateRecurringChange
|
||||||
} = taskSlice.actions;
|
} = taskSlice.actions;
|
||||||
|
|
||||||
export default taskSlice.reducer;
|
export default taskSlice.reducer;
|
||||||
|
|||||||
69
worklenz-frontend/src/hooks/useFilterDataLoader.ts
Normal file
69
worklenz-frontend/src/hooks/useFilterDataLoader.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||||
|
import {
|
||||||
|
fetchLabelsByProject,
|
||||||
|
fetchTaskAssignees,
|
||||||
|
} from '@/features/tasks/tasks.slice';
|
||||||
|
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage filter data loading independently of main task list loading
|
||||||
|
* This ensures filter data loading doesn't block the main UI skeleton
|
||||||
|
*/
|
||||||
|
export const useFilterDataLoader = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const { priorities } = useAppSelector(state => ({
|
||||||
|
priorities: state.priorityReducer.priorities,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { projectId } = useAppSelector(state => ({
|
||||||
|
projectId: state.projectReducer.projectId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Load filter data asynchronously
|
||||||
|
const loadFilterData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Load priorities if not already loaded (usually fast/cached)
|
||||||
|
if (!priorities.length) {
|
||||||
|
dispatch(fetchPriorities());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load project-specific data in parallel without blocking
|
||||||
|
if (projectId) {
|
||||||
|
// These dispatch calls are fire-and-forget
|
||||||
|
// They will update the UI when ready, but won't block initial render
|
||||||
|
dispatch(fetchLabelsByProject(projectId));
|
||||||
|
dispatch(fetchTaskAssignees(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load team members for member filters
|
||||||
|
dispatch(getTeamMembers({
|
||||||
|
index: 0,
|
||||||
|
size: 100,
|
||||||
|
field: null,
|
||||||
|
order: null,
|
||||||
|
search: null,
|
||||||
|
all: true
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading filter data:', error);
|
||||||
|
// Don't throw - filter loading errors shouldn't break the main UI
|
||||||
|
}
|
||||||
|
}, [dispatch, priorities.length, projectId]);
|
||||||
|
|
||||||
|
// Load filter data on mount and when dependencies change
|
||||||
|
useEffect(() => {
|
||||||
|
// Use setTimeout to ensure this runs after the main component render
|
||||||
|
// This prevents filter loading from blocking the initial render
|
||||||
|
const timeoutId = setTimeout(loadFilterData, 0);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [loadFilterData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadFilterData,
|
||||||
|
};
|
||||||
|
};
|
||||||
146
worklenz-frontend/src/hooks/useTaskDragAndDrop.ts
Normal file
146
worklenz-frontend/src/hooks/useTaskDragAndDrop.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useMemo, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverEvent,
|
||||||
|
DragStartEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
KeyboardSensor,
|
||||||
|
TouchSensor,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { updateTaskStatus } from '@/features/tasks/tasks.slice';
|
||||||
|
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||||
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
|
|
||||||
|
export const useTaskDragAndDrop = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// Memoize the selector to prevent unnecessary rerenders
|
||||||
|
const taskGroups = useAppSelector(state => state.taskReducer.taskGroups);
|
||||||
|
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
|
||||||
|
|
||||||
|
// Memoize sensors configuration for better performance
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 250,
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
// Add visual feedback for drag start
|
||||||
|
const { active } = event;
|
||||||
|
if (active) {
|
||||||
|
document.body.style.cursor = 'grabbing';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||||
|
// Handle drag over logic if needed
|
||||||
|
// This can be used for visual feedback during drag
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
// Reset cursor
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!active || !over || !taskGroups) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeId = active.id as string;
|
||||||
|
const overId = over.id as string;
|
||||||
|
|
||||||
|
// Find the task being dragged
|
||||||
|
let draggedTask: IProjectTask | null = null;
|
||||||
|
let sourceGroupId: string | null = null;
|
||||||
|
|
||||||
|
for (const group of taskGroups) {
|
||||||
|
const task = group.tasks?.find((t: IProjectTask) => t.id === activeId);
|
||||||
|
if (task) {
|
||||||
|
draggedTask = task;
|
||||||
|
sourceGroupId = group.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draggedTask || !sourceGroupId) {
|
||||||
|
console.warn('Could not find dragged task');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine target group
|
||||||
|
let targetGroupId: string | null = null;
|
||||||
|
|
||||||
|
// Check if dropped on a group container
|
||||||
|
const targetGroup = taskGroups.find((group: ITaskListGroup) => group.id === overId);
|
||||||
|
if (targetGroup) {
|
||||||
|
targetGroupId = targetGroup.id;
|
||||||
|
} else {
|
||||||
|
// Check if dropped on another task
|
||||||
|
for (const group of taskGroups) {
|
||||||
|
const targetTask = group.tasks?.find((t: IProjectTask) => t.id === overId);
|
||||||
|
if (targetTask) {
|
||||||
|
targetGroupId = group.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetGroupId || targetGroupId === sourceGroupId) {
|
||||||
|
return; // No change needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update task status based on group change
|
||||||
|
const targetGroupData = taskGroups.find((group: ITaskListGroup) => group.id === targetGroupId);
|
||||||
|
if (targetGroupData && groupBy === 'status') {
|
||||||
|
const updatePayload: any = {
|
||||||
|
task_id: draggedTask.id,
|
||||||
|
status_id: targetGroupData.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (draggedTask.parent_task_id) {
|
||||||
|
updatePayload.parent_task = draggedTask.parent_task_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(updateTaskStatus(updatePayload));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling drag end:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[taskGroups, groupBy, dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize the drag and drop configuration
|
||||||
|
const dragAndDropConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
sensors,
|
||||||
|
onDragStart: handleDragStart,
|
||||||
|
onDragOver: handleDragOver,
|
||||||
|
onDragEnd: handleDragEnd,
|
||||||
|
}),
|
||||||
|
[sensors, handleDragStart, handleDragOver, handleDragEnd]
|
||||||
|
);
|
||||||
|
|
||||||
|
return dragAndDropConfig;
|
||||||
|
};
|
||||||
343
worklenz-frontend/src/hooks/useTaskSocketHandlers.ts
Normal file
343
worklenz-frontend/src/hooks/useTaskSocketHandlers.ts
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
import alertService from '@/services/alerts/alertService';
|
||||||
|
|
||||||
|
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
|
||||||
|
import { ILabelsChangeResponse } from '@/types/tasks/taskList.types';
|
||||||
|
import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types';
|
||||||
|
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
|
||||||
|
import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-response';
|
||||||
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
|
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchTaskAssignees,
|
||||||
|
updateTaskAssignees,
|
||||||
|
fetchLabelsByProject,
|
||||||
|
updateTaskLabel,
|
||||||
|
updateTaskStatus,
|
||||||
|
updateTaskPriority,
|
||||||
|
updateTaskEndDate,
|
||||||
|
updateTaskEstimation,
|
||||||
|
updateTaskName,
|
||||||
|
updateTaskPhase,
|
||||||
|
updateTaskStartDate,
|
||||||
|
updateTaskDescription,
|
||||||
|
updateSubTasks,
|
||||||
|
updateTaskProgress,
|
||||||
|
} from '@/features/tasks/tasks.slice';
|
||||||
|
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||||
|
import {
|
||||||
|
setStartDate,
|
||||||
|
setTaskAssignee,
|
||||||
|
setTaskEndDate,
|
||||||
|
setTaskLabels,
|
||||||
|
setTaskPriority,
|
||||||
|
setTaskStatus,
|
||||||
|
setTaskSubscribers,
|
||||||
|
} from '@/features/task-drawer/task-drawer.slice';
|
||||||
|
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||||
|
|
||||||
|
export const useTaskSocketHandlers = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { socket } = useSocket();
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
|
||||||
|
const { loadingAssignees, taskGroups } = useAppSelector((state: any) => state.taskReducer);
|
||||||
|
const { projectId } = useAppSelector((state: any) => state.projectReducer);
|
||||||
|
|
||||||
|
// Memoize socket event handlers
|
||||||
|
const handleAssigneesUpdate = useCallback(
|
||||||
|
(data: ITaskAssigneesUpdateResponse) => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const updatedAssignees = data.assignees?.map(assignee => ({
|
||||||
|
...assignee,
|
||||||
|
selected: true,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
const groupId = taskGroups?.find((group: ITaskListGroup) =>
|
||||||
|
group.tasks?.some(
|
||||||
|
(task: IProjectTask) =>
|
||||||
|
task.id === data.id ||
|
||||||
|
(task.sub_tasks && task.sub_tasks.some((subtask: IProjectTask) => subtask.id === data.id))
|
||||||
|
)
|
||||||
|
)?.id;
|
||||||
|
|
||||||
|
if (groupId) {
|
||||||
|
dispatch(
|
||||||
|
updateTaskAssignees({
|
||||||
|
groupId,
|
||||||
|
taskId: data.id,
|
||||||
|
assignees: updatedAssignees,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
setTaskAssignee({
|
||||||
|
...data,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentSession?.team_id && !loadingAssignees) {
|
||||||
|
dispatch(fetchTaskAssignees(currentSession.team_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[taskGroups, dispatch, currentSession?.team_id, loadingAssignees]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLabelsChange = useCallback(
|
||||||
|
async (labels: ILabelsChangeResponse) => {
|
||||||
|
if (!labels) return;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
dispatch(updateTaskLabel(labels)),
|
||||||
|
dispatch(setTaskLabels(labels)),
|
||||||
|
dispatch(fetchLabels()),
|
||||||
|
projectId && dispatch(fetchLabelsByProject(projectId)),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[dispatch, projectId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTaskStatusChange = useCallback(
|
||||||
|
(response: ITaskListStatusChangeResponse) => {
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
if (response.completed_deps === false) {
|
||||||
|
alertService.error(
|
||||||
|
'Task is not completed',
|
||||||
|
'Please complete the task dependencies before proceeding'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(updateTaskStatus(response));
|
||||||
|
dispatch(deselectAll());
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTaskProgress = useCallback(
|
||||||
|
(data: {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
complete_ratio: number;
|
||||||
|
completed_count: number;
|
||||||
|
total_tasks_count: number;
|
||||||
|
parent_task: string;
|
||||||
|
}) => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
updateTaskProgress({
|
||||||
|
taskId: data.parent_task || data.id,
|
||||||
|
progress: data.complete_ratio,
|
||||||
|
totalTasksCount: data.total_tasks_count,
|
||||||
|
completedCount: data.completed_count,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePriorityChange = useCallback(
|
||||||
|
(response: ITaskListPriorityChangeResponse) => {
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
dispatch(updateTaskPriority(response));
|
||||||
|
dispatch(setTaskPriority(response));
|
||||||
|
dispatch(deselectAll());
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEndDateChange = useCallback(
|
||||||
|
(task: {
|
||||||
|
id: string;
|
||||||
|
parent_task: string | null;
|
||||||
|
end_date: string;
|
||||||
|
}) => {
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const taskWithProgress = {
|
||||||
|
...task,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask;
|
||||||
|
|
||||||
|
dispatch(updateTaskEndDate({ task: taskWithProgress }));
|
||||||
|
dispatch(setTaskEndDate(taskWithProgress));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTaskNameChange = useCallback(
|
||||||
|
(data: { id: string; parent_task: string; name: string }) => {
|
||||||
|
if (!data) return;
|
||||||
|
dispatch(updateTaskName(data));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePhaseChange = useCallback(
|
||||||
|
(data: ITaskPhaseChangeResponse) => {
|
||||||
|
if (!data) return;
|
||||||
|
dispatch(updateTaskPhase(data));
|
||||||
|
dispatch(deselectAll());
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStartDateChange = useCallback(
|
||||||
|
(task: {
|
||||||
|
id: string;
|
||||||
|
parent_task: string | null;
|
||||||
|
start_date: string;
|
||||||
|
}) => {
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const taskWithProgress = {
|
||||||
|
...task,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask;
|
||||||
|
|
||||||
|
dispatch(updateTaskStartDate({ task: taskWithProgress }));
|
||||||
|
dispatch(setStartDate(taskWithProgress));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTaskSubscribersChange = useCallback(
|
||||||
|
(data: InlineMember[]) => {
|
||||||
|
if (!data) return;
|
||||||
|
dispatch(setTaskSubscribers(data));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEstimationChange = useCallback(
|
||||||
|
(task: {
|
||||||
|
id: string;
|
||||||
|
parent_task: string | null;
|
||||||
|
estimation: number;
|
||||||
|
}) => {
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const taskWithProgress = {
|
||||||
|
...task,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask;
|
||||||
|
|
||||||
|
dispatch(updateTaskEstimation({ task: taskWithProgress }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTaskDescriptionChange = useCallback(
|
||||||
|
(data: {
|
||||||
|
id: string;
|
||||||
|
parent_task: string;
|
||||||
|
description: string;
|
||||||
|
}) => {
|
||||||
|
if (!data) return;
|
||||||
|
dispatch(updateTaskDescription(data));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNewTaskReceived = useCallback(
|
||||||
|
(data: IProjectTask) => {
|
||||||
|
if (!data) return;
|
||||||
|
if (data.parent_task_id) {
|
||||||
|
dispatch(updateSubTasks(data));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTaskProgressUpdated = useCallback(
|
||||||
|
(data: {
|
||||||
|
task_id: string;
|
||||||
|
progress_value?: number;
|
||||||
|
weight?: number;
|
||||||
|
}) => {
|
||||||
|
if (!data || !taskGroups) return;
|
||||||
|
|
||||||
|
if (data.progress_value !== undefined) {
|
||||||
|
for (const group of taskGroups) {
|
||||||
|
const task = group.tasks?.find((task: IProjectTask) => task.id === data.task_id);
|
||||||
|
if (task) {
|
||||||
|
dispatch(
|
||||||
|
updateTaskProgress({
|
||||||
|
taskId: data.task_id,
|
||||||
|
progress: data.progress_value,
|
||||||
|
totalTasksCount: task.total_tasks_count || 0,
|
||||||
|
completedCount: task.completed_count || 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, taskGroups]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register socket event listeners
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
const eventHandlers = [
|
||||||
|
{ event: SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handler: handleAssigneesUpdate },
|
||||||
|
{ event: SocketEvents.TASK_LABELS_CHANGE.toString(), handler: handleLabelsChange },
|
||||||
|
{ event: SocketEvents.TASK_STATUS_CHANGE.toString(), handler: handleTaskStatusChange },
|
||||||
|
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgress },
|
||||||
|
{ event: SocketEvents.TASK_PRIORITY_CHANGE.toString(), handler: handlePriorityChange },
|
||||||
|
{ event: SocketEvents.TASK_END_DATE_CHANGE.toString(), handler: handleEndDateChange },
|
||||||
|
{ event: SocketEvents.TASK_NAME_CHANGE.toString(), handler: handleTaskNameChange },
|
||||||
|
{ event: SocketEvents.TASK_PHASE_CHANGE.toString(), handler: handlePhaseChange },
|
||||||
|
{ event: SocketEvents.TASK_START_DATE_CHANGE.toString(), handler: handleStartDateChange },
|
||||||
|
{ event: SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handler: handleTaskSubscribersChange },
|
||||||
|
{ event: SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handler: handleEstimationChange },
|
||||||
|
{ event: SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handler: handleTaskDescriptionChange },
|
||||||
|
{ event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived },
|
||||||
|
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Register all event listeners
|
||||||
|
eventHandlers.forEach(({ event, handler }) => {
|
||||||
|
socket.on(event, handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
eventHandlers.forEach(({ event, handler }) => {
|
||||||
|
socket.off(event, handler);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
socket,
|
||||||
|
handleAssigneesUpdate,
|
||||||
|
handleLabelsChange,
|
||||||
|
handleTaskStatusChange,
|
||||||
|
handleTaskProgress,
|
||||||
|
handlePriorityChange,
|
||||||
|
handleEndDateChange,
|
||||||
|
handleTaskNameChange,
|
||||||
|
handlePhaseChange,
|
||||||
|
handleStartDateChange,
|
||||||
|
handleTaskSubscribersChange,
|
||||||
|
handleEstimationChange,
|
||||||
|
handleTaskDescriptionChange,
|
||||||
|
handleNewTaskReceived,
|
||||||
|
handleTaskProgressUpdated,
|
||||||
|
]);
|
||||||
|
};
|
||||||
@@ -58,9 +58,9 @@ html.light body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial,
|
font-family:
|
||||||
"Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
-apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial, "Noto Sans",
|
||||||
"Noto Color Emoji" !important;
|
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* helper classes */
|
/* helper classes */
|
||||||
@@ -145,3 +145,4 @@ Not supports in Firefox and IE */
|
|||||||
tr:hover .action-buttons {
|
tr:hover .action-buttons {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { colors } from '../styles/colors';
|
|||||||
import { verifyAuthentication } from '@/features/auth/authSlice';
|
import { verifyAuthentication } from '@/features/auth/authSlice';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import TawkTo from '@/components/TawkTo';
|
import HubSpot from '@/components/HubSpot';
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
@@ -68,9 +68,6 @@ const MainLayout = () => {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</Col>
|
</Col>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
{import.meta.env.VITE_APP_ENV === 'production' && (
|
|
||||||
<TawkTo propertyId="67ecc524f62fbf190db18bde" widgetId="1inqe45sq" />
|
|
||||||
)}
|
|
||||||
</Layout>
|
</Layout>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,14 +108,14 @@ export const settingsItems: SettingMenuItems[] = [
|
|||||||
element: React.createElement(CategoriesSettings),
|
element: React.createElement(CategoriesSettings),
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// key: 'project-templates',
|
key: 'project-templates',
|
||||||
// name: 'project-templates',
|
name: 'project-templates',
|
||||||
// endpoint: 'project-templates',
|
endpoint: 'project-templates',
|
||||||
// icon: React.createElement(FileZipOutlined),
|
icon: React.createElement(FileZipOutlined),
|
||||||
// element: React.createElement(ProjectTemplatesSettings),
|
element: React.createElement(ProjectTemplatesSettings),
|
||||||
// adminOnly: true,
|
adminOnly: true,
|
||||||
// },
|
},
|
||||||
{
|
{
|
||||||
key: 'task-templates',
|
key: 'task-templates',
|
||||||
name: 'task-templates',
|
name: 'task-templates',
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ const RecentAndFavouriteProjectList = () => {
|
|||||||
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
||||||
{projectsData?.body?.length === 0 ? (
|
{projectsData?.body?.length === 0 ? (
|
||||||
<Empty
|
<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 }}
|
imageStyle={{ height: 60 }}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleSelectTask = useCallback((task : IMyTask) => {
|
const handleSelectTask = useCallback((task: IMyTask) => {
|
||||||
dispatch(setSelectedTaskId(task.id || ''));
|
dispatch(setSelectedTaskId(task.id || ''));
|
||||||
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
|
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
|
||||||
dispatch(setProjectId(task.project_id || ''));
|
dispatch(setProjectId(task.project_id || ''));
|
||||||
@@ -155,7 +155,7 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
return (
|
return (
|
||||||
<Tooltip title={record.project_name}>
|
<Tooltip title={record.project_name}>
|
||||||
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth:120 }} ellipsis={{ tooltip: true }}>
|
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth: 120 }} ellipsis={{ tooltip: true }}>
|
||||||
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
|
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
|
||||||
{record.project_name}
|
{record.project_name}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
@@ -259,7 +259,7 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
<Skeleton active />
|
<Skeleton active />
|
||||||
) : data?.body.total === 0 ? (
|
) : data?.body.total === 0 ? (
|
||||||
<EmptyListPlaceholder
|
<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."
|
text=" No tasks to show."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -271,10 +271,10 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
columns={columns as TableProps<IMyTask>['columns']}
|
columns={columns as TableProps<IMyTask>['columns']}
|
||||||
size="middle"
|
size="middle"
|
||||||
rowClassName={() => 'custom-row-height'}
|
rowClassName={() => 'custom-row-height'}
|
||||||
loading={homeTasksFetching && !skipAutoRefetch}
|
loading={homeTasksFetching && skipAutoRefetch}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ marginTop: 16, textAlign: 'right', display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ marginTop: 16, textAlign: 'right', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<Pagination
|
<Pagination
|
||||||
current={currentPage}
|
current={currentPage}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ const TodoList = () => {
|
|||||||
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
||||||
{data?.body.length === 0 ? (
|
{data?.body.length === 0 ? (
|
||||||
<EmptyListPlaceholder
|
<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')}
|
text={t('home:todoList.noTasks')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ const ProjectViewMembers = () => {
|
|||||||
>
|
>
|
||||||
{members?.total === 0 ? (
|
{members?.total === 0 ? (
|
||||||
<EmptyListPlaceholder
|
<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}
|
imageHeight={120}
|
||||||
text={t('emptyText')}
|
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 { PushpinFilled, PushpinOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd';
|
import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd';
|
||||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
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 [pinnedTab, setPinnedTab] = useState<string>(searchParams.get('pinned_tab') || '');
|
||||||
const [taskid, setTaskId] = useState<string>(searchParams.get('task') || '');
|
const [taskid, setTaskId] = useState<string>(searchParams.get('task') || '');
|
||||||
|
|
||||||
|
const resetProjectData = useCallback(() => {
|
||||||
|
dispatch(setProjectId(null));
|
||||||
|
dispatch(resetStatuses());
|
||||||
|
dispatch(deselectAll());
|
||||||
|
dispatch(resetTaskListData());
|
||||||
|
dispatch(resetBoardData());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
dispatch(setProjectId(projectId));
|
dispatch(setProjectId(projectId));
|
||||||
@@ -59,9 +67,13 @@ const ProjectView = () => {
|
|||||||
dispatch(setSelectedTaskId(taskid || ''));
|
dispatch(setSelectedTaskId(taskid || ''));
|
||||||
dispatch(setShowTaskDrawer(true));
|
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;
|
if (!itemKey || !projectId) return;
|
||||||
|
|
||||||
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
|
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
|
||||||
@@ -88,9 +100,9 @@ const ProjectView = () => {
|
|||||||
}).toString(),
|
}).toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, [projectId, activeTab, navigate]);
|
||||||
|
|
||||||
const handleTabChange = (key: string) => {
|
const handleTabChange = useCallback((key: string) => {
|
||||||
setActiveTab(key);
|
setActiveTab(key);
|
||||||
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
|
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
|
||||||
navigate({
|
navigate({
|
||||||
@@ -100,9 +112,9 @@ const ProjectView = () => {
|
|||||||
pinned_tab: pinnedTab,
|
pinned_tab: pinnedTab,
|
||||||
}).toString(),
|
}).toString(),
|
||||||
});
|
});
|
||||||
};
|
}, [dispatch, location.pathname, navigate, pinnedTab]);
|
||||||
|
|
||||||
const tabMenuItems = tabItems.map(item => ({
|
const tabMenuItems = useMemo(() => tabItems.map(item => ({
|
||||||
key: item.key,
|
key: item.key,
|
||||||
label: (
|
label: (
|
||||||
<Flex align="center" style={{ color: colors.skyBlue }}>
|
<Flex align="center" style={{ color: colors.skyBlue }}>
|
||||||
@@ -144,21 +156,17 @@ const ProjectView = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
),
|
),
|
||||||
children: item.element,
|
children: item.element,
|
||||||
}));
|
})), [pinnedTab, pinToDefaultTab]);
|
||||||
|
|
||||||
const resetProjectData = () => {
|
const portalElements = useMemo(() => (
|
||||||
dispatch(setProjectId(null));
|
<>
|
||||||
dispatch(resetStatuses());
|
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
|
||||||
dispatch(deselectAll());
|
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
|
||||||
dispatch(resetTaskListData());
|
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
|
||||||
dispatch(resetBoardData());
|
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
|
||||||
};
|
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
|
||||||
|
</>
|
||||||
useEffect(() => {
|
), []);
|
||||||
return () => {
|
|
||||||
resetProjectData();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBlockStart: 80, marginBlockEnd: 24, minHeight: '80vh' }}>
|
<div style={{ marginBlockStart: 80, marginBlockEnd: 24, minHeight: '80vh' }}>
|
||||||
@@ -169,34 +177,12 @@ const ProjectView = () => {
|
|||||||
onChange={handleTabChange}
|
onChange={handleTabChange}
|
||||||
items={tabMenuItems}
|
items={tabMenuItems}
|
||||||
tabBarStyle={{ paddingInline: 0 }}
|
tabBarStyle={{ paddingInline: 0 }}
|
||||||
destroyInactiveTabPane={true}
|
destroyOnHidden={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')}
|
{portalElements}
|
||||||
{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')}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProjectView;
|
export default React.memo(ProjectView);
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
|
import Flex from 'antd/es/flex';
|
||||||
|
import Badge from 'antd/es/badge';
|
||||||
|
import Button from 'antd/es/button';
|
||||||
|
import Dropdown from 'antd/es/dropdown';
|
||||||
|
import Input from 'antd/es/input';
|
||||||
|
import Typography from 'antd/es/typography';
|
||||||
|
import { MenuProps } from 'antd/es/menu';
|
||||||
|
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { colors } from '@/styles/colors';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
|
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||||
|
import Collapsible from '@/components/collapsible/collapsible';
|
||||||
|
import TaskListTable from '../../task-list-table/task-list-table';
|
||||||
|
import { IGroupBy, updateTaskGroupColor } from '@/features/tasks/tasks.slice';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||||
|
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||||
|
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||||
|
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||||
|
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||||
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
|
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
|
||||||
|
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||||
|
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
|
||||||
|
interface TaskGroupProps {
|
||||||
|
taskGroup: ITaskListGroup;
|
||||||
|
groupBy: string;
|
||||||
|
color: string;
|
||||||
|
activeId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskGroup: React.FC<TaskGroupProps> = ({
|
||||||
|
taskGroup,
|
||||||
|
groupBy,
|
||||||
|
color,
|
||||||
|
activeId
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||||
|
const isProjectManager = useIsProjectManager();
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
|
const [groupName, setGroupName] = useState(taskGroup.name || '');
|
||||||
|
|
||||||
|
const { projectId } = useAppSelector((state: any) => state.projectReducer);
|
||||||
|
const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
|
||||||
|
|
||||||
|
// Memoize droppable configuration
|
||||||
|
const { setNodeRef } = useDroppable({
|
||||||
|
id: taskGroup.id,
|
||||||
|
data: {
|
||||||
|
type: 'group',
|
||||||
|
groupId: taskGroup.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Memoize task count
|
||||||
|
const taskCount = useMemo(() => taskGroup.tasks?.length || 0, [taskGroup.tasks]);
|
||||||
|
|
||||||
|
// Memoize dropdown items
|
||||||
|
const dropdownItems: MenuProps['items'] = useMemo(() => {
|
||||||
|
if (groupBy !== IGroupBy.STATUS || !isProjectManager) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'rename',
|
||||||
|
label: t('renameText'),
|
||||||
|
icon: <EditOutlined />,
|
||||||
|
onClick: () => setIsRenaming(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'change-category',
|
||||||
|
label: t('changeCategoryText'),
|
||||||
|
icon: <RetweetOutlined />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'todo',
|
||||||
|
label: t('todoText'),
|
||||||
|
onClick: () => handleStatusCategoryChange('0'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'doing',
|
||||||
|
label: t('doingText'),
|
||||||
|
onClick: () => handleStatusCategoryChange('1'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'done',
|
||||||
|
label: t('doneText'),
|
||||||
|
onClick: () => handleStatusCategoryChange('2'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [groupBy, isProjectManager, t]);
|
||||||
|
|
||||||
|
const handleStatusCategoryChange = async (category: string) => {
|
||||||
|
if (!projectId || !taskGroup.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await statusApiService.updateStatus({
|
||||||
|
id: taskGroup.id,
|
||||||
|
category_id: category,
|
||||||
|
project_id: projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(fetchStatuses());
|
||||||
|
trackMixpanelEvent(evt_project_board_column_setting_click, {
|
||||||
|
column_id: taskGroup.id,
|
||||||
|
action: 'change_category',
|
||||||
|
category,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating status category:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (!projectId || !taskGroup.id || !groupName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (groupBy === IGroupBy.STATUS) {
|
||||||
|
await statusApiService.updateStatus({
|
||||||
|
id: taskGroup.id,
|
||||||
|
name: groupName.trim(),
|
||||||
|
project_id: projectId,
|
||||||
|
});
|
||||||
|
dispatch(fetchStatuses());
|
||||||
|
} else if (groupBy === IGroupBy.PHASE) {
|
||||||
|
const phaseData: ITaskPhase = {
|
||||||
|
id: taskGroup.id,
|
||||||
|
name: groupName.trim(),
|
||||||
|
project_id: projectId,
|
||||||
|
color_code: taskGroup.color_code,
|
||||||
|
};
|
||||||
|
await phasesApiService.updatePhase(phaseData);
|
||||||
|
dispatch(fetchPhasesByProjectId(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRenaming(false);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error renaming group:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorChange = async (newColor: string) => {
|
||||||
|
if (!projectId || !taskGroup.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseColor = newColor.endsWith(ALPHA_CHANNEL)
|
||||||
|
? newColor.slice(0, -ALPHA_CHANNEL.length)
|
||||||
|
: newColor;
|
||||||
|
|
||||||
|
if (groupBy === IGroupBy.PHASE) {
|
||||||
|
const phaseData: ITaskPhase = {
|
||||||
|
id: taskGroup.id,
|
||||||
|
name: taskGroup.name || '',
|
||||||
|
project_id: projectId,
|
||||||
|
color_code: baseColor,
|
||||||
|
};
|
||||||
|
await phasesApiService.updatePhase(phaseData);
|
||||||
|
dispatch(fetchPhasesByProjectId(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(updateTaskGroupColor({
|
||||||
|
groupId: taskGroup.id,
|
||||||
|
color: baseColor,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating group color:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef}>
|
||||||
|
<Flex vertical>
|
||||||
|
{/* Group Header */}
|
||||||
|
<Flex style={{ transform: 'translateY(6px)' }}>
|
||||||
|
<Button
|
||||||
|
className="custom-collapse-button"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
border: 'none',
|
||||||
|
borderBottomLeftRadius: isExpanded ? 0 : 4,
|
||||||
|
borderBottomRightRadius: isExpanded ? 0 : 4,
|
||||||
|
color: colors.darkGray,
|
||||||
|
minWidth: 200,
|
||||||
|
}}
|
||||||
|
icon={<RightOutlined rotate={isExpanded ? 90 : 0} />}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
{isRenaming ? (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
value={groupName}
|
||||||
|
onChange={e => setGroupName(e.target.value)}
|
||||||
|
onBlur={handleRename}
|
||||||
|
onPressEnter={handleRename}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography.Text style={{ fontSize: 14, fontWeight: 600 }}>
|
||||||
|
{taskGroup.name} ({taskCount})
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{dropdownItems.length > 0 && !isRenaming && (
|
||||||
|
<Dropdown menu={{ items: dropdownItems }} trigger={['click']}>
|
||||||
|
<Button icon={<EllipsisOutlined />} className="borderless-icon-btn" />
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* Task List */}
|
||||||
|
<Collapsible isOpen={isExpanded}>
|
||||||
|
<TaskListTable
|
||||||
|
taskList={taskGroup.tasks || []}
|
||||||
|
tableId={taskGroup.id}
|
||||||
|
groupBy={groupBy}
|
||||||
|
color={color}
|
||||||
|
activeId={activeId}
|
||||||
|
/>
|
||||||
|
</Collapsible>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(TaskGroup);
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import Flex from 'antd/es/flex';
|
import Flex from 'antd/es/flex';
|
||||||
import Skeleton from 'antd/es/skeleton';
|
import Skeleton from 'antd/es/skeleton';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import TaskListFilters from './task-list-filters/task-list-filters';
|
import TaskListFilters from './task-list-filters/task-list-filters';
|
||||||
import TaskGroupWrapper from './task-list-table/task-group-wrapper/task-group-wrapper';
|
import TaskGroupWrapperOptimized from './task-group-wrapper-optimized';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice';
|
import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice';
|
||||||
@@ -17,64 +17,99 @@ const ProjectViewTaskList = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { projectView } = useTabSearchParam();
|
const { projectView } = useTabSearchParam();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
// Add local loading state to immediately show skeleton
|
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
// Split selectors to prevent unnecessary rerenders
|
||||||
const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector(
|
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||||
state => state.taskReducer
|
const taskGroups = useAppSelector(state => state.taskReducer.taskGroups);
|
||||||
);
|
const loadingGroups = useAppSelector(state => state.taskReducer.loadingGroups);
|
||||||
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
|
||||||
state => state.taskStatusReducer
|
const archived = useAppSelector(state => state.taskReducer.archived);
|
||||||
);
|
const fields = useAppSelector(state => state.taskReducer.fields);
|
||||||
const { loadingPhases } = useAppSelector(state => state.phaseReducer);
|
const search = useAppSelector(state => state.taskReducer.search);
|
||||||
const { loadingColumns } = useAppSelector(state => state.taskReducer);
|
|
||||||
|
|
||||||
|
const statusCategories = useAppSelector(state => state.taskStatusReducer.statusCategories);
|
||||||
|
const loadingStatusCategories = useAppSelector(state => state.taskStatusReducer.loading);
|
||||||
|
|
||||||
|
const loadingPhases = useAppSelector(state => state.phaseReducer.loadingPhases);
|
||||||
|
|
||||||
|
// Single source of truth for loading state - EXCLUDE labels loading from skeleton
|
||||||
|
// Labels loading should not block the main task list display
|
||||||
|
const isLoading = useMemo(() =>
|
||||||
|
loadingGroups || loadingPhases || loadingStatusCategories || !initialLoadComplete,
|
||||||
|
[loadingGroups, loadingPhases, loadingStatusCategories, initialLoadComplete]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize the empty state check
|
||||||
|
const isEmptyState = useMemo(() =>
|
||||||
|
taskGroups && taskGroups.length === 0 && !isLoading,
|
||||||
|
[taskGroups, isLoading]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle view type changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Set default view to list if projectView is not list or board
|
|
||||||
if (projectView !== 'list' && projectView !== 'board') {
|
if (projectView !== 'list' && projectView !== 'board') {
|
||||||
searchParams.set('tab', 'tasks-list');
|
const newParams = new URLSearchParams(searchParams);
|
||||||
searchParams.set('pinned_tab', 'tasks-list');
|
newParams.set('tab', 'tasks-list');
|
||||||
setSearchParams(searchParams);
|
newParams.set('pinned_tab', 'tasks-list');
|
||||||
|
setSearchParams(newParams);
|
||||||
}
|
}
|
||||||
}, [projectView, searchParams, setSearchParams]);
|
}, [projectView, setSearchParams, searchParams]);
|
||||||
|
|
||||||
|
// Batch initial data fetching - core data only
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Set loading state based on all loading conditions
|
const fetchInitialData = async () => {
|
||||||
setIsLoading(loadingGroups || loadingColumns || loadingPhases || loadingStatusCategories);
|
if (!projectId || !groupBy || initialLoadComplete) return;
|
||||||
}, [loadingGroups, loadingColumns, loadingPhases, loadingStatusCategories]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
try {
|
||||||
const loadData = async () => {
|
// Batch only essential API calls for initial load
|
||||||
if (projectId && groupBy) {
|
// Filter data (labels, assignees, etc.) will load separately and not block the UI
|
||||||
const promises = [];
|
await Promise.allSettled([
|
||||||
|
dispatch(fetchTaskListColumns(projectId)),
|
||||||
if (!loadingColumns) promises.push(dispatch(fetchTaskListColumns(projectId)));
|
dispatch(fetchPhasesByProjectId(projectId)),
|
||||||
if (!loadingPhases) promises.push(dispatch(fetchPhasesByProjectId(projectId)));
|
dispatch(fetchStatusesCategories()),
|
||||||
if (!loadingGroups && projectView === 'list') {
|
]);
|
||||||
promises.push(dispatch(fetchTaskGroups(projectId)));
|
setInitialLoadComplete(true);
|
||||||
}
|
} catch (error) {
|
||||||
if (!statusCategories.length) {
|
console.error('Error fetching initial data:', error);
|
||||||
promises.push(dispatch(fetchStatusesCategories()));
|
setInitialLoadComplete(true); // Still mark as complete to prevent infinite loading
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for all data to load
|
|
||||||
await Promise.all(promises);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
fetchInitialData();
|
||||||
}, [dispatch, projectId, groupBy, fields, search, archived]);
|
}, [projectId, groupBy, dispatch, initialLoadComplete]);
|
||||||
|
|
||||||
|
// Fetch task groups with dependency on initial load completion
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dispatch(fetchTaskGroups(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching task groups:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTasks();
|
||||||
|
}, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]);
|
||||||
|
|
||||||
|
// Memoize the task groups to prevent unnecessary re-renders
|
||||||
|
const memoizedTaskGroups = useMemo(() => taskGroups || [], [taskGroups]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||||
|
{/* Filters load independently and don't block the main content */}
|
||||||
<TaskListFilters position="list" />
|
<TaskListFilters position="list" />
|
||||||
|
|
||||||
{(taskGroups && taskGroups.length === 0 && !isLoading) ? (
|
{isEmptyState ? (
|
||||||
<Empty description="No tasks group found" />
|
<Empty description="No tasks group found" />
|
||||||
) : (
|
) : (
|
||||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||||
<TaskGroupWrapper taskGroups={taskGroups} groupBy={groupBy} />
|
<TaskGroupWrapperOptimized
|
||||||
|
taskGroups={memoizedTaskGroups}
|
||||||
|
groupBy={groupBy}
|
||||||
|
/>
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import Flex from 'antd/es/flex';
|
||||||
|
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
pointerWithin,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
|
||||||
|
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
|
||||||
|
import TaskListTableWrapper from './task-list-table/task-list-table-wrapper/task-list-table-wrapper';
|
||||||
|
import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
|
||||||
|
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||||
|
|
||||||
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
|
import { useTaskDragAndDrop } from '@/hooks/useTaskDragAndDrop';
|
||||||
|
|
||||||
|
interface TaskGroupWrapperOptimizedProps {
|
||||||
|
taskGroups: ITaskListGroup[];
|
||||||
|
groupBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOptimizedProps) => {
|
||||||
|
const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
|
||||||
|
|
||||||
|
// Use extracted hooks
|
||||||
|
useTaskSocketHandlers();
|
||||||
|
const {
|
||||||
|
activeId,
|
||||||
|
sensors,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragEnd,
|
||||||
|
handleDragOver,
|
||||||
|
resetTaskRowStyles,
|
||||||
|
} = useTaskDragAndDrop({ taskGroups, groupBy });
|
||||||
|
|
||||||
|
// Memoize task groups with colors
|
||||||
|
const taskGroupsWithColors = useMemo(() =>
|
||||||
|
taskGroups?.map(taskGroup => ({
|
||||||
|
...taskGroup,
|
||||||
|
displayColor: themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code,
|
||||||
|
})) || [],
|
||||||
|
[taskGroups, themeMode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add drag styles
|
||||||
|
useEffect(() => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.task-row[data-is-dragging="true"] {
|
||||||
|
opacity: 0.5 !important;
|
||||||
|
transform: rotate(5deg) !important;
|
||||||
|
z-index: 1000 !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
.task-row {
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.head.removeChild(style);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle animation cleanup after drag ends
|
||||||
|
useIsomorphicLayoutEffect(() => {
|
||||||
|
if (activeId === null) {
|
||||||
|
const timeoutId = setTimeout(resetTaskRowStyles, 50);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [activeId, resetTaskRowStyles]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={pointerWithin}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
>
|
||||||
|
<Flex gap={24} vertical>
|
||||||
|
{taskGroupsWithColors.map(taskGroup => (
|
||||||
|
<TaskListTableWrapper
|
||||||
|
key={taskGroup.id}
|
||||||
|
taskList={taskGroup.tasks}
|
||||||
|
tableId={taskGroup.id}
|
||||||
|
name={taskGroup.name}
|
||||||
|
groupBy={groupBy}
|
||||||
|
statusCategory={taskGroup.category_id}
|
||||||
|
color={taskGroup.displayColor}
|
||||||
|
activeId={activeId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
|
||||||
|
|
||||||
|
{createPortal(
|
||||||
|
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
||||||
|
document.body,
|
||||||
|
'task-template-drawer'
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(TaskGroupWrapperOptimized);
|
||||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
|
||||||
import {
|
import {
|
||||||
fetchLabelsByProject,
|
fetchLabelsByProject,
|
||||||
fetchTaskAssignees,
|
fetchTaskAssignees,
|
||||||
@@ -33,23 +34,49 @@ const TaskListFilters: React.FC<TaskListFiltersProps> = ({ position }) => {
|
|||||||
const { projectView } = useTabSearchParam();
|
const { projectView } = useTabSearchParam();
|
||||||
|
|
||||||
const priorities = useAppSelector(state => state.priorityReducer.priorities);
|
const priorities = useAppSelector(state => state.priorityReducer.priorities);
|
||||||
|
|
||||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||||
const archived = useAppSelector(state => state.taskReducer.archived);
|
const archived = useAppSelector(state => state.taskReducer.archived);
|
||||||
|
|
||||||
const handleShowArchivedChange = () => dispatch(toggleArchived());
|
const handleShowArchivedChange = () => dispatch(toggleArchived());
|
||||||
|
|
||||||
|
// Load filter data asynchronously and non-blocking
|
||||||
|
// This runs independently of the main task list loading
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchInitialData = async () => {
|
const loadFilterData = async () => {
|
||||||
if (!priorities.length) await dispatch(fetchPriorities());
|
try {
|
||||||
if (projectId) {
|
// Load priorities first (usually cached/fast)
|
||||||
await dispatch(fetchLabelsByProject(projectId));
|
if (!priorities.length) {
|
||||||
await dispatch(fetchTaskAssignees(projectId));
|
dispatch(fetchPriorities());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load project-specific filter data in parallel, but don't await
|
||||||
|
// This allows the main task list to load while filters are still loading
|
||||||
|
if (projectId) {
|
||||||
|
// Fire and forget - these will update the UI when ready
|
||||||
|
dispatch(fetchLabelsByProject(projectId));
|
||||||
|
dispatch(fetchTaskAssignees(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load team members (usually needed for member filters)
|
||||||
|
dispatch(getTeamMembers({
|
||||||
|
index: 0,
|
||||||
|
size: 100,
|
||||||
|
field: null,
|
||||||
|
order: null,
|
||||||
|
search: null,
|
||||||
|
all: true
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading filter data:', error);
|
||||||
|
// Don't throw - filter loading errors shouldn't break the main UI
|
||||||
}
|
}
|
||||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchInitialData();
|
// Use setTimeout to ensure this runs after the main component render
|
||||||
|
// This prevents filter loading from blocking the initial render
|
||||||
|
const timeoutId = setTimeout(loadFilterData, 0);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
}, [dispatch, priorities.length, projectId]);
|
}, [dispatch, priorities.length, projectId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
|
|||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import Flex from 'antd/es/flex';
|
import Flex from 'antd/es/flex';
|
||||||
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
|
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
|
||||||
@@ -87,16 +87,22 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees);
|
const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees);
|
||||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||||
|
|
||||||
const sensors = useSensors(
|
// Move useSensors to top level and memoize its configuration
|
||||||
useSensor(PointerSensor, {
|
const sensorConfig = useMemo(
|
||||||
|
() => ({
|
||||||
activationConstraint: { distance: 8 },
|
activationConstraint: { distance: 8 },
|
||||||
})
|
}),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pointerSensor = useSensor(PointerSensor, sensorConfig);
|
||||||
|
const sensors = useSensors(pointerSensor);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGroups(taskGroups);
|
setGroups(taskGroups);
|
||||||
}, [taskGroups]);
|
}, [taskGroups]);
|
||||||
|
|
||||||
|
// Memoize resetTaskRowStyles to prevent unnecessary re-renders
|
||||||
const resetTaskRowStyles = useCallback(() => {
|
const resetTaskRowStyles = useCallback(() => {
|
||||||
document.querySelectorAll<HTMLElement>('.task-row').forEach(row => {
|
document.querySelectorAll<HTMLElement>('.task-row').forEach(row => {
|
||||||
row.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
|
row.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
|
||||||
@@ -106,21 +112,18 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Socket handler for assignee updates
|
// Memoize socket event handlers
|
||||||
useEffect(() => {
|
const handleAssigneesUpdate = useCallback(
|
||||||
if (!socket) return;
|
(data: ITaskAssigneesUpdateResponse) => {
|
||||||
|
|
||||||
const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
|
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
const updatedAssignees = data.assignees.map(assignee => ({
|
const updatedAssignees = data.assignees?.map(assignee => ({
|
||||||
...assignee,
|
...assignee,
|
||||||
selected: true,
|
selected: true,
|
||||||
}));
|
})) || [];
|
||||||
|
|
||||||
// Find the group that contains the task or its subtasks
|
const groupId = groups?.find(group =>
|
||||||
const groupId = groups.find(group =>
|
group.tasks?.some(
|
||||||
group.tasks.some(
|
|
||||||
task =>
|
task =>
|
||||||
task.id === data.id ||
|
task.id === data.id ||
|
||||||
(task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id))
|
(task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id))
|
||||||
@@ -136,47 +139,41 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
dispatch(setTaskAssignee(data));
|
dispatch(
|
||||||
|
setTaskAssignee({
|
||||||
|
...data,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask)
|
||||||
|
);
|
||||||
|
|
||||||
if (currentSession?.team_id && !loadingAssignees) {
|
if (currentSession?.team_id && !loadingAssignees) {
|
||||||
dispatch(fetchTaskAssignees(currentSession.team_id));
|
dispatch(fetchTaskAssignees(currentSession.team_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[groups, dispatch, currentSession?.team_id, loadingAssignees]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
// Memoize socket event handlers
|
||||||
return () => {
|
const handleLabelsChange = useCallback(
|
||||||
socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
async (labels: ILabelsChangeResponse) => {
|
||||||
};
|
if (!labels) return;
|
||||||
}, [socket, currentSession?.team_id, loadingAssignees, groups, dispatch]);
|
|
||||||
|
|
||||||
// Socket handler for label updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleLabelsChange = async (labels: ILabelsChangeResponse) => {
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
dispatch(updateTaskLabel(labels)),
|
dispatch(updateTaskLabel(labels)),
|
||||||
dispatch(setTaskLabels(labels)),
|
dispatch(setTaskLabels(labels)),
|
||||||
dispatch(fetchLabels()),
|
dispatch(fetchLabels()),
|
||||||
projectId && dispatch(fetchLabelsByProject(projectId)),
|
projectId && dispatch(fetchLabelsByProject(projectId)),
|
||||||
]);
|
]);
|
||||||
};
|
},
|
||||||
|
[dispatch, projectId]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
|
// Memoize socket event handlers
|
||||||
socket.on(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
|
const handleTaskStatusChange = useCallback(
|
||||||
|
(response: ITaskListStatusChangeResponse) => {
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
return () => {
|
|
||||||
socket.off(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
|
|
||||||
socket.off(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
|
|
||||||
};
|
|
||||||
}, [socket, dispatch, projectId]);
|
|
||||||
|
|
||||||
// Socket handler for status updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleTaskStatusChange = (response: ITaskListStatusChangeResponse) => {
|
|
||||||
if (response.completed_deps === false) {
|
if (response.completed_deps === false) {
|
||||||
alertService.error(
|
alertService.error(
|
||||||
'Task is not completed',
|
'Task is not completed',
|
||||||
@@ -186,11 +183,14 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispatch(updateTaskStatus(response));
|
dispatch(updateTaskStatus(response));
|
||||||
// dispatch(setTaskStatus(response));
|
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAll());
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTaskProgress = (data: {
|
// Memoize socket event handlers
|
||||||
|
const handleTaskProgress = useCallback(
|
||||||
|
(data: {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
complete_ratio: number;
|
complete_ratio: number;
|
||||||
@@ -198,6 +198,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
total_tasks_count: number;
|
total_tasks_count: number;
|
||||||
parent_task: string;
|
parent_task: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
updateTaskProgress({
|
updateTaskProgress({
|
||||||
taskId: data.parent_task || data.id,
|
taskId: data.parent_task || data.id,
|
||||||
@@ -206,190 +208,150 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
completedCount: data.completed_count,
|
completedCount: data.completed_count,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
|
// Memoize socket event handlers
|
||||||
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
const handlePriorityChange = useCallback(
|
||||||
|
(response: ITaskListPriorityChangeResponse) => {
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
return () => {
|
|
||||||
socket.off(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
|
|
||||||
socket.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
|
||||||
};
|
|
||||||
}, [socket, dispatch]);
|
|
||||||
|
|
||||||
// Socket handler for priority updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handlePriorityChange = (response: ITaskListPriorityChangeResponse) => {
|
|
||||||
dispatch(updateTaskPriority(response));
|
dispatch(updateTaskPriority(response));
|
||||||
dispatch(setTaskPriority(response));
|
dispatch(setTaskPriority(response));
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAll());
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
|
// Memoize socket event handlers
|
||||||
|
const handleEndDateChange = useCallback(
|
||||||
return () => {
|
(task: {
|
||||||
socket.off(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
|
|
||||||
};
|
|
||||||
}, [socket, dispatch]);
|
|
||||||
|
|
||||||
// Socket handler for due date updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleEndDateChange = (task: {
|
|
||||||
id: string;
|
id: string;
|
||||||
parent_task: string | null;
|
parent_task: string | null;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
}) => {
|
}) => {
|
||||||
dispatch(updateTaskEndDate({ task }));
|
if (!task) return;
|
||||||
dispatch(setTaskEndDate(task));
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
const taskWithProgress = {
|
||||||
|
...task,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask;
|
||||||
|
|
||||||
return () => {
|
dispatch(updateTaskEndDate({ task: taskWithProgress }));
|
||||||
socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
dispatch(setTaskEndDate(taskWithProgress));
|
||||||
};
|
},
|
||||||
}, [socket, dispatch]);
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
// Socket handler for task name updates
|
// Memoize socket event handlers
|
||||||
useEffect(() => {
|
const handleTaskNameChange = useCallback(
|
||||||
if (!socket) return;
|
(data: { id: string; parent_task: string; name: string }) => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
const handleTaskNameChange = (data: { id: string; parent_task: string; name: string }) => {
|
|
||||||
dispatch(updateTaskName(data));
|
dispatch(updateTaskName(data));
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
|
// Memoize socket event handlers
|
||||||
|
const handlePhaseChange = useCallback(
|
||||||
|
(data: ITaskPhaseChangeResponse) => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
return () => {
|
|
||||||
socket.off(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
|
|
||||||
};
|
|
||||||
}, [socket, dispatch]);
|
|
||||||
|
|
||||||
// Socket handler for phase updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handlePhaseChange = (data: ITaskPhaseChangeResponse) => {
|
|
||||||
dispatch(updateTaskPhase(data));
|
dispatch(updateTaskPhase(data));
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAll());
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
|
// Memoize socket event handlers
|
||||||
|
const handleStartDateChange = useCallback(
|
||||||
return () => {
|
(task: {
|
||||||
socket.off(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
|
|
||||||
};
|
|
||||||
}, [socket, dispatch]);
|
|
||||||
|
|
||||||
// Socket handler for start date updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleStartDateChange = (task: {
|
|
||||||
id: string;
|
id: string;
|
||||||
parent_task: string | null;
|
parent_task: string | null;
|
||||||
start_date: string;
|
start_date: string;
|
||||||
}) => {
|
}) => {
|
||||||
dispatch(updateTaskStartDate({ task }));
|
if (!task) return;
|
||||||
dispatch(setStartDate(task));
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
|
const taskWithProgress = {
|
||||||
|
...task,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask;
|
||||||
|
|
||||||
return () => {
|
dispatch(updateTaskStartDate({ task: taskWithProgress }));
|
||||||
socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
|
dispatch(setStartDate(taskWithProgress));
|
||||||
};
|
},
|
||||||
}, [socket, dispatch]);
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
// Socket handler for task subscribers updates
|
// Memoize socket event handlers
|
||||||
useEffect(() => {
|
const handleTaskSubscribersChange = useCallback(
|
||||||
if (!socket) return;
|
(data: InlineMember[]) => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
const handleTaskSubscribersChange = (data: InlineMember[]) => {
|
|
||||||
dispatch(setTaskSubscribers(data));
|
dispatch(setTaskSubscribers(data));
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
|
// Memoize socket event handlers
|
||||||
|
const handleEstimationChange = useCallback(
|
||||||
return () => {
|
(task: {
|
||||||
socket.off(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
|
|
||||||
};
|
|
||||||
}, [socket, dispatch]);
|
|
||||||
|
|
||||||
// Socket handler for task estimation updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleEstimationChange = (task: {
|
|
||||||
id: string;
|
id: string;
|
||||||
parent_task: string | null;
|
parent_task: string | null;
|
||||||
estimation: number;
|
estimation: number;
|
||||||
}) => {
|
}) => {
|
||||||
dispatch(updateTaskEstimation({ task }));
|
if (!task) return;
|
||||||
};
|
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
|
const taskWithProgress = {
|
||||||
|
...task,
|
||||||
|
manual_progress: false,
|
||||||
|
} as IProjectTask;
|
||||||
|
|
||||||
return () => {
|
dispatch(updateTaskEstimation({ task: taskWithProgress }));
|
||||||
socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
|
},
|
||||||
};
|
[dispatch]
|
||||||
}, [socket, dispatch]);
|
);
|
||||||
|
|
||||||
// Socket handler for task description updates
|
// Memoize socket event handlers
|
||||||
useEffect(() => {
|
const handleTaskDescriptionChange = useCallback(
|
||||||
if (!socket) return;
|
(data: {
|
||||||
|
|
||||||
const handleTaskDescriptionChange = (data: {
|
|
||||||
id: string;
|
id: string;
|
||||||
parent_task: string;
|
parent_task: string;
|
||||||
description: string;
|
description: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
dispatch(updateTaskDescription(data));
|
dispatch(updateTaskDescription(data));
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
|
// Memoize socket event handlers
|
||||||
|
const handleNewTaskReceived = useCallback(
|
||||||
return () => {
|
(data: IProjectTask) => {
|
||||||
socket.off(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
|
|
||||||
};
|
|
||||||
}, [socket, dispatch]);
|
|
||||||
|
|
||||||
// Socket handler for new task creation
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleNewTaskReceived = (data: IProjectTask) => {
|
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
if (data.parent_task_id) {
|
if (data.parent_task_id) {
|
||||||
dispatch(updateSubTasks(data));
|
dispatch(updateSubTasks(data));
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
|
// Memoize socket event handlers
|
||||||
|
const handleTaskProgressUpdated = useCallback(
|
||||||
return () => {
|
(data: {
|
||||||
socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
|
|
||||||
};
|
|
||||||
}, [socket, dispatch]);
|
|
||||||
|
|
||||||
// Socket handler for task progress updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleTaskProgressUpdated = (data: {
|
|
||||||
task_id: string;
|
task_id: string;
|
||||||
progress_value?: number;
|
progress_value?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (!data || !taskGroups) return;
|
||||||
|
|
||||||
if (data.progress_value !== undefined) {
|
if (data.progress_value !== undefined) {
|
||||||
// Find the task in the task groups and update its progress
|
|
||||||
for (const group of taskGroups) {
|
for (const group of taskGroups) {
|
||||||
const task = group.tasks.find(task => task.id === data.task_id);
|
const task = group.tasks?.find(task => task.id === data.task_id);
|
||||||
if (task) {
|
if (task) {
|
||||||
dispatch(
|
dispatch(
|
||||||
updateTaskProgress({
|
updateTaskProgress({
|
||||||
@@ -403,25 +365,76 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[dispatch, taskGroups]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up socket event listeners
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
const eventHandlers = {
|
||||||
|
[SocketEvents.QUICK_ASSIGNEES_UPDATE.toString()]: handleAssigneesUpdate,
|
||||||
|
[SocketEvents.TASK_LABELS_CHANGE.toString()]: handleLabelsChange,
|
||||||
|
[SocketEvents.CREATE_LABEL.toString()]: handleLabelsChange,
|
||||||
|
[SocketEvents.TASK_STATUS_CHANGE.toString()]: handleTaskStatusChange,
|
||||||
|
[SocketEvents.GET_TASK_PROGRESS.toString()]: handleTaskProgress,
|
||||||
|
[SocketEvents.TASK_PRIORITY_CHANGE.toString()]: handlePriorityChange,
|
||||||
|
[SocketEvents.TASK_END_DATE_CHANGE.toString()]: handleEndDateChange,
|
||||||
|
[SocketEvents.TASK_NAME_CHANGE.toString()]: handleTaskNameChange,
|
||||||
|
[SocketEvents.TASK_PHASE_CHANGE.toString()]: handlePhaseChange,
|
||||||
|
[SocketEvents.TASK_START_DATE_CHANGE.toString()]: handleStartDateChange,
|
||||||
|
[SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString()]: handleTaskSubscribersChange,
|
||||||
|
[SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString()]: handleEstimationChange,
|
||||||
|
[SocketEvents.TASK_DESCRIPTION_CHANGE.toString()]: handleTaskDescriptionChange,
|
||||||
|
[SocketEvents.QUICK_TASK.toString()]: handleNewTaskReceived,
|
||||||
|
[SocketEvents.TASK_PROGRESS_UPDATED.toString()]: handleTaskProgressUpdated,
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated);
|
// Register all event handlers
|
||||||
|
Object.entries(eventHandlers).forEach(([event, handler]) => {
|
||||||
|
if (handler) {
|
||||||
|
socket.on(event, handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
return () => {
|
return () => {
|
||||||
socket.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated);
|
Object.entries(eventHandlers).forEach(([event, handler]) => {
|
||||||
|
if (handler) {
|
||||||
|
socket.off(event, handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}, [socket, dispatch, taskGroups]);
|
}, [
|
||||||
|
socket,
|
||||||
|
handleAssigneesUpdate,
|
||||||
|
handleLabelsChange,
|
||||||
|
handleTaskStatusChange,
|
||||||
|
handleTaskProgress,
|
||||||
|
handlePriorityChange,
|
||||||
|
handleEndDateChange,
|
||||||
|
handleTaskNameChange,
|
||||||
|
handlePhaseChange,
|
||||||
|
handleStartDateChange,
|
||||||
|
handleTaskSubscribersChange,
|
||||||
|
handleEstimationChange,
|
||||||
|
handleTaskDescriptionChange,
|
||||||
|
handleNewTaskReceived,
|
||||||
|
handleTaskProgressUpdated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Memoize drag handlers
|
||||||
const handleDragStart = useCallback(({ active }: DragStartEvent) => {
|
const handleDragStart = useCallback(({ active }: DragStartEvent) => {
|
||||||
setActiveId(active.id as string);
|
setActiveId(active.id as string);
|
||||||
|
|
||||||
// Add smooth transition to the dragged item
|
|
||||||
const draggedElement = document.querySelector(`[data-id="${active.id}"]`);
|
const draggedElement = document.querySelector(`[data-id="${active.id}"]`);
|
||||||
if (draggedElement) {
|
if (draggedElement) {
|
||||||
(draggedElement as HTMLElement).style.transition = 'transform 0.2s ease';
|
(draggedElement as HTMLElement).style.transition = 'transform 0.2s ease';
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Memoize drag handlers
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
async ({ active, over }: DragEndEvent) => {
|
async ({ active, over }: DragEndEvent) => {
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
@@ -440,10 +453,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
|
const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
|
||||||
if (fromIndex === -1) return;
|
if (fromIndex === -1) return;
|
||||||
|
|
||||||
// Create a deep clone of the task to avoid reference issues
|
|
||||||
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
|
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
|
||||||
|
|
||||||
// Check if task dependencies allow the move
|
|
||||||
if (activeGroupId !== overGroupId) {
|
if (activeGroupId !== overGroupId) {
|
||||||
const canContinue = await checkTaskDependencyStatus(task.id, overGroupId);
|
const canContinue = await checkTaskDependencyStatus(task.id, overGroupId);
|
||||||
if (!canContinue) {
|
if (!canContinue) {
|
||||||
@@ -455,7 +466,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update task properties based on target group
|
|
||||||
switch (groupBy) {
|
switch (groupBy) {
|
||||||
case IGroupBy.STATUS:
|
case IGroupBy.STATUS:
|
||||||
task.status = overGroupId;
|
task.status = overGroupId;
|
||||||
@@ -468,35 +478,29 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
task.priority_color_dark = targetGroup.color_code_dark;
|
task.priority_color_dark = targetGroup.color_code_dark;
|
||||||
break;
|
break;
|
||||||
case IGroupBy.PHASE:
|
case IGroupBy.PHASE:
|
||||||
// Check if ALPHA_CHANNEL is already added
|
|
||||||
const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL)
|
const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL)
|
||||||
? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) // Remove ALPHA_CHANNEL
|
? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length)
|
||||||
: targetGroup.color_code; // Use as is if not present
|
: targetGroup.color_code;
|
||||||
task.phase_id = overGroupId;
|
task.phase_id = overGroupId;
|
||||||
task.phase_color = baseColor; // Set the cleaned color
|
task.phase_color = baseColor;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTargetGroupEmpty = targetGroup.tasks.length === 0;
|
const isTargetGroupEmpty = targetGroup.tasks.length === 0;
|
||||||
|
|
||||||
// Calculate toIndex - for empty groups, always add at index 0
|
|
||||||
const toIndex = isTargetGroupEmpty
|
const toIndex = isTargetGroupEmpty
|
||||||
? 0
|
? 0
|
||||||
: overTaskId
|
: overTaskId
|
||||||
? targetGroup.tasks.findIndex(t => t.id === overTaskId)
|
? targetGroup.tasks.findIndex(t => t.id === overTaskId)
|
||||||
: targetGroup.tasks.length;
|
: targetGroup.tasks.length;
|
||||||
|
|
||||||
// Calculate toPos similar to Angular implementation
|
|
||||||
const toPos = isTargetGroupEmpty
|
const toPos = isTargetGroupEmpty
|
||||||
? -1
|
? -1
|
||||||
: targetGroup.tasks[toIndex]?.sort_order ||
|
: targetGroup.tasks[toIndex]?.sort_order ||
|
||||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||||
-1;
|
-1;
|
||||||
|
|
||||||
// Update Redux state
|
|
||||||
if (activeGroupId === overGroupId) {
|
if (activeGroupId === overGroupId) {
|
||||||
// Same group - move within array
|
|
||||||
const updatedTasks = [...sourceGroup.tasks];
|
const updatedTasks = [...sourceGroup.tasks];
|
||||||
updatedTasks.splice(fromIndex, 1);
|
updatedTasks.splice(fromIndex, 1);
|
||||||
updatedTasks.splice(toIndex, 0, task);
|
updatedTasks.splice(toIndex, 0, task);
|
||||||
@@ -514,7 +518,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Different groups - transfer between arrays
|
|
||||||
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
||||||
const updatedTargetTasks = [...targetGroup.tasks];
|
const updatedTargetTasks = [...targetGroup.tasks];
|
||||||
|
|
||||||
@@ -540,7 +543,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit socket event
|
|
||||||
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
from_index: sourceGroup.tasks[fromIndex].sort_order,
|
from_index: sourceGroup.tasks[fromIndex].sort_order,
|
||||||
@@ -549,13 +551,11 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
from_group: sourceGroup.id,
|
from_group: sourceGroup.id,
|
||||||
to_group: targetGroup.id,
|
to_group: targetGroup.id,
|
||||||
group_by: groupBy,
|
group_by: groupBy,
|
||||||
task: sourceGroup.tasks[fromIndex], // Send original task to maintain references
|
task: sourceGroup.tasks[fromIndex],
|
||||||
team_id: currentSession?.team_id,
|
team_id: currentSession?.team_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset styles
|
|
||||||
setTimeout(resetTaskRowStyles, 0);
|
setTimeout(resetTaskRowStyles, 0);
|
||||||
|
|
||||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -570,6 +570,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Memoize drag handlers
|
||||||
const handleDragOver = useCallback(
|
const handleDragOver = useCallback(
|
||||||
({ active, over }: DragEndEvent) => {
|
({ active, over }: DragEndEvent) => {
|
||||||
if (!over) return;
|
if (!over) return;
|
||||||
@@ -589,12 +590,9 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
|
|
||||||
if (fromIndex === -1 || toIndex === -1) return;
|
if (fromIndex === -1 || toIndex === -1) return;
|
||||||
|
|
||||||
// Create a deep clone of the task to avoid reference issues
|
|
||||||
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
|
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
|
||||||
|
|
||||||
// Update Redux state
|
|
||||||
if (activeGroupId === overGroupId) {
|
if (activeGroupId === overGroupId) {
|
||||||
// Same group - move within array
|
|
||||||
const updatedTasks = [...sourceGroup.tasks];
|
const updatedTasks = [...sourceGroup.tasks];
|
||||||
updatedTasks.splice(fromIndex, 1);
|
updatedTasks.splice(fromIndex, 1);
|
||||||
updatedTasks.splice(toIndex, 0, task);
|
updatedTasks.splice(toIndex, 0, task);
|
||||||
@@ -612,10 +610,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Different groups - transfer between arrays
|
|
||||||
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
||||||
const updatedTargetTasks = [...targetGroup.tasks];
|
const updatedTargetTasks = [...targetGroup.tasks];
|
||||||
|
|
||||||
updatedTargetTasks.splice(toIndex, 0, task);
|
updatedTargetTasks.splice(toIndex, 0, task);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -663,7 +659,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
// Handle animation cleanup after drag ends
|
// Handle animation cleanup after drag ends
|
||||||
useIsomorphicLayoutEffect(() => {
|
useIsomorphicLayoutEffect(() => {
|
||||||
if (activeId === null) {
|
if (activeId === null) {
|
||||||
// Final cleanup after React updates DOM
|
|
||||||
const timeoutId = setTimeout(resetTaskRowStyles, 50);
|
const timeoutId = setTimeout(resetTaskRowStyles, 50);
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const ProjectTemplatesSettings = () => {
|
|||||||
style={{ display: 'flex', gap: '10px', justifyContent: 'right' }}
|
style={{ display: 'flex', gap: '10px', justifyContent: 'right' }}
|
||||||
className="button-visibilty"
|
className="button-visibilty"
|
||||||
>
|
>
|
||||||
<Tooltip title={t('editToolTip')}>
|
{/* <Tooltip title={t('editToolTip')}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -60,7 +60,7 @@ const ProjectTemplatesSettings = () => {
|
|||||||
>
|
>
|
||||||
<EditOutlined />
|
<EditOutlined />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip> */}
|
||||||
<Tooltip title={t('deleteToolTip')}>
|
<Tooltip title={t('deleteToolTip')}>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={
|
title={
|
||||||
|
|||||||
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;
|
timer_start_time?: number;
|
||||||
recurring?: boolean;
|
recurring?: boolean;
|
||||||
task_level?: number;
|
task_level?: number;
|
||||||
|
schedule_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITaskTeamMember extends ITeamMember {
|
export interface ITaskTeamMember extends ITeamMember {
|
||||||
|
|||||||
Reference in New Issue
Block a user