Compare commits
10 Commits
release/v2
...
feature/me
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1067d87fe | ||
|
|
97feef5982 | ||
|
|
76c92b1cc6 | ||
|
|
67c62fc69b | ||
|
|
14d8f43001 | ||
|
|
3b59a8560b | ||
|
|
819252cedd | ||
|
|
1dade05f54 | ||
|
|
34613e5e0c | ||
|
|
a8b20680e5 |
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://s3.us-west-2.amazonaws.com/worklenz.com/assets/icon-144x144.png" alt="Worklenz Logo" width="75">
|
<img src="https://app.worklenz.com/assets/icons/icon-144x144.png" alt="Worklenz Logo" width="75">
|
||||||
</a>
|
</a>
|
||||||
<br>
|
<br>
|
||||||
Worklenz
|
Worklenz
|
||||||
@@ -315,7 +315,6 @@ 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.
|
||||||
@@ -404,10 +403,6 @@ 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:
|
||||||
@@ -433,10 +428,6 @@ 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,45 +16,24 @@ 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 available options:
|
You can choose how often your task repeats. Here are the most common 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 one or more days (e.g., every Monday and Thursday).
|
- **Weekly:** The task is created once a week. You can pick the day (e.g., every Monday).
|
||||||
- **Monthly:** The task is created once a month. You have two options:
|
- **Monthly:** The task is created once a month. You can pick the date (e.g., the 1st of every month).
|
||||||
- **On a specific date:** Choose a date from 1 to 28 (limited to 28 to ensure consistency across all months)
|
- **Weekdays:** The task is created every Monday to Friday.
|
||||||
- **On a specific day:** Choose a week (first, second, third, fourth, or last) and a day of the week
|
- **Custom:** Set your own schedule, such as every 2 days, every 3 weeks, or only on certain days.
|
||||||
- **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 15th of each month (monthly, specific date)
|
- "Submit expense report" on the 1st of each month (monthly)
|
||||||
- "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 (weekly, multiple days)
|
- "Review project status" every Monday and Thursday (custom)
|
||||||
- "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,51 +17,6 @@ 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`.
|
||||||
@@ -72,7 +27,6 @@ const FUTURE_LIMITS = {
|
|||||||
- 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.
|
||||||
@@ -87,12 +41,10 @@ const FUTURE_LIMITS = {
|
|||||||
- **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`
|
||||||
|
|||||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "worklenz",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
@@ -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=$(openssl rand -base64 48)
|
SESSION_SECRET=change_me_in_production
|
||||||
COOKIE_SECRET=$(openssl rand -base64 48)
|
COOKIE_SECRET=change_me_in_production
|
||||||
|
|
||||||
# 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=$(openssl rand -base64 48)
|
JWT_SECRET=change_me_in_production
|
||||||
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")
|
||||||
|
|||||||
@@ -79,7 +79,3 @@ 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"
|
|
||||||
131
worklenz-backend/Gruntfile.js
Normal file
131
worklenz-backend/Gruntfile.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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", []);
|
||||||
|
};
|
||||||
@@ -145,7 +145,7 @@ BEGIN
|
|||||||
SET progress_value = NULL,
|
SET progress_value = NULL,
|
||||||
progress_mode = NULL
|
progress_mode = NULL
|
||||||
WHERE project_id = _project_id
|
WHERE project_id = _project_id
|
||||||
AND progress_mode::text::progress_mode_type = _old_mode;
|
AND progress_mode = _old_mode;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ BEGIN
|
|||||||
SELECT SUM(time_spent)
|
SELECT SUM(time_spent)
|
||||||
FROM task_work_log
|
FROM task_work_log
|
||||||
WHERE task_id = t.id
|
WHERE task_id = t.id
|
||||||
), 0) as logged_minutes
|
), 0) / 60.0 as logged_minutes
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
WHERE t.id = _task_id
|
WHERE t.id = _task_id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
COALESCE((SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id), '') as name,
|
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id),
|
||||||
COALESCE((SELECT email_notifications_enabled
|
(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), false) AS email_notifications_enabled,
|
AND notification_settings.user_id = u.id) AS email_notifications_enabled,
|
||||||
COALESCE(u.avatar_url, '') as avatar_url,
|
u.avatar_url,
|
||||||
u.id AS user_id,
|
u.id AS user_id,
|
||||||
COALESCE(u.email, '') as email,
|
u.email,
|
||||||
COALESCE(u.socket_id, '') as socket_id,
|
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 COALESCE(name, '') FROM tasks WHERE id = _task_id INTO _task_name;
|
SELECT name FROM tasks WHERE id = _task_id INTO _task_name;
|
||||||
|
|
||||||
SELECT COALESCE(name, '')
|
SELECT 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 COALESCE(name, '') FROM task_statuses WHERE id = _status_id INTO _new_status_name;
|
SELECT 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,22 +4081,14 @@ 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 COALESCE(name, '') FROM users WHERE id = _user_id INTO _updater_name;
|
SELECT 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
|
||||||
@@ -4105,7 +4097,7 @@ BEGIN
|
|||||||
INTO _status_category;
|
INTO _status_category;
|
||||||
|
|
||||||
RETURN JSON_BUILD_OBJECT(
|
RETURN JSON_BUILD_OBJECT(
|
||||||
'message', COALESCE(_message, ''),
|
'message', _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
|
||||||
@@ -4113,14 +4105,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', COALESCE((_task_info ->> 'color_code')::TEXT, ''),
|
'color_code', (_task_info ->> 'color_code')::TEXT,
|
||||||
'color_code_dark', COALESCE((_task_info ->> 'color_code_dark')::TEXT, ''),
|
'color_code_dark', (_task_info ->> 'color_code_dark')::TEXT,
|
||||||
'total_tasks', COALESCE((_task_info ->> 'total_tasks')::INT, 0),
|
'total_tasks', (_task_info ->> 'total_tasks')::INT,
|
||||||
'total_completed', COALESCE((_task_info ->> 'total_completed')::INT, 0),
|
'total_completed', (_task_info ->> 'total_completed')::INT,
|
||||||
'members', COALESCE((_task_info ->> 'members')::JSON, '[]'::JSON),
|
'members', (_task_info ->> 'members')::JSON,
|
||||||
'completed_at', _task_completed_at,
|
'completed_at', _task_completed_at,
|
||||||
'status_category', COALESCE(_status_category, '{}'::JSON),
|
'status_category', _status_category,
|
||||||
'schedule_id', COALESCE(_schedule_id, 'null'::JSON)
|
'schedule_id', _schedule_id
|
||||||
);
|
);
|
||||||
END
|
END
|
||||||
$$;
|
$$;
|
||||||
@@ -6156,219 +6148,3 @@ 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;
|
|
||||||
$$;
|
|
||||||
|
|||||||
11238
worklenz-backend/package-lock.json
generated
11238
worklenz-backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,30 +11,16 @@
|
|||||||
"repository": "GITHUB_REPO_HERE",
|
"repository": "GITHUB_REPO_HERE",
|
||||||
"author": "worklenz.com",
|
"author": "worklenz.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest",
|
"start": "node ./build/bin/www",
|
||||||
"start": "node build/bin/www.js",
|
"tcs": "grunt build:tsc",
|
||||||
"dev": "npm run build:dev && npm run watch",
|
"build": "grunt build",
|
||||||
"build": "npm run clean && npm run compile && npm run copy && npm run compress",
|
"watch": "grunt watch",
|
||||||
"build:dev": "npm run clean && npm run compile:dev && npm run copy",
|
"dev": "grunt dev",
|
||||||
"build:prod": "npm run clean && npm run compile:prod && npm run copy && npm run minify && npm run compress",
|
"es": "esbuild `find src -type f -name '*.ts'` --platform=node --minify=true --watch=true --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=dist",
|
||||||
"clean": "rimraf build",
|
"copy": "grunt copy",
|
||||||
"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": {
|
||||||
@@ -59,7 +45,6 @@
|
|||||||
"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",
|
||||||
@@ -68,7 +53,6 @@
|
|||||||
"express-rate-limit": "^6.8.0",
|
"express-rate-limit": "^6.8.0",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"express-validator": "^6.15.0",
|
"express-validator": "^6.15.0",
|
||||||
"grunt-cli": "^1.5.0",
|
|
||||||
"helmet": "^6.2.0",
|
"helmet": "^6.2.0",
|
||||||
"hpp": "^0.2.3",
|
"hpp": "^0.2.3",
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
@@ -94,10 +78,8 @@
|
|||||||
"sharp": "^0.32.6",
|
"sharp": "^0.32.6",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"socket.io": "^4.7.1",
|
"socket.io": "^4.7.1",
|
||||||
"tinymce": "^7.8.0",
|
|
||||||
"uglify-js": "^3.17.4",
|
"uglify-js": "^3.17.4",
|
||||||
"winston": "^3.10.0",
|
"winston": "^3.10.0",
|
||||||
"worklenz-backend": "file:",
|
|
||||||
"xss-filters": "^1.2.7"
|
"xss-filters": "^1.2.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -105,17 +87,15 @@
|
|||||||
"@babel/preset-typescript": "^7.22.5",
|
"@babel/preset-typescript": "^7.22.5",
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/bluebird": "^3.5.38",
|
"@types/bluebird": "^3.5.38",
|
||||||
"@types/body-parser": "^1.19.2",
|
|
||||||
"@types/compression": "^1.7.2",
|
"@types/compression": "^1.7.2",
|
||||||
"@types/connect-flash": "^0.0.37",
|
"@types/connect-flash": "^0.0.37",
|
||||||
"@types/cookie-parser": "^1.4.3",
|
"@types/cookie-parser": "^1.4.3",
|
||||||
"@types/cron": "^2.0.1",
|
"@types/cron": "^2.0.1",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/csurf": "^1.11.2",
|
"@types/csurf": "^1.11.2",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.17",
|
||||||
"@types/express-brute": "^1.0.2",
|
"@types/express-brute": "^1.0.2",
|
||||||
"@types/express-brute-redis": "^0.0.4",
|
"@types/express-brute-redis": "^0.0.4",
|
||||||
"@types/express-serve-static-core": "^4.17.34",
|
|
||||||
"@types/express-session": "^1.17.7",
|
"@types/express-session": "^1.17.7",
|
||||||
"@types/fs-extra": "^9.0.13",
|
"@types/fs-extra": "^9.0.13",
|
||||||
"@types/hpp": "^0.2.2",
|
"@types/hpp": "^0.2.2",
|
||||||
@@ -140,22 +120,26 @@
|
|||||||
"@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",
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
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 { csrfSync } from "csrf-sync";
|
import csurf from "csurf";
|
||||||
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,13 +112,17 @@ function isLoggedIn(req: Request, _res: Response, next: NextFunction) {
|
|||||||
return req.user ? next() : next(createError(401));
|
return req.user ? next() : next(createError(401));
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF configuration using csrf-sync for session-based authentication
|
// CSRF configuration
|
||||||
const {
|
const csrfProtection = csurf({
|
||||||
invalidCsrfTokenError,
|
cookie: {
|
||||||
generateToken,
|
key: "XSRF-TOKEN",
|
||||||
csrfSynchronisedProtection,
|
path: "/",
|
||||||
} = csrfSync({
|
httpOnly: false,
|
||||||
getTokenFromRequest: (req: Request) => req.headers["x-csrf-token"] as string || (req.body && req.body["_csrf"])
|
secure: isProduction(), // Only secure in production
|
||||||
|
sameSite: isProduction() ? "none" : "lax", // Different settings for dev vs prod
|
||||||
|
domain: isProduction() ? ".worklenz.com" : undefined // Only set domain in production
|
||||||
|
},
|
||||||
|
ignoreMethods: ["HEAD", "OPTIONS"]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply CSRF selectively (exclude webhooks and public routes)
|
// Apply CSRF selectively (exclude webhooks and public routes)
|
||||||
@@ -131,25 +135,38 @@ app.use((req, res, next) => {
|
|||||||
) {
|
) {
|
||||||
next();
|
next();
|
||||||
} else {
|
} else {
|
||||||
csrfSynchronisedProtection(req, res, next);
|
csrfProtection(req, res, next);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set CSRF token method on request object for compatibility
|
// Set CSRF token cookie
|
||||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
// Add csrfToken method to request object for compatibility
|
if (req.csrfToken) {
|
||||||
if (!req.csrfToken && generateToken) {
|
const token = req.csrfToken();
|
||||||
req.csrfToken = (overwrite?: boolean) => generateToken(req, overwrite);
|
res.cookie("XSRF-TOKEN", token, {
|
||||||
|
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) => {
|
||||||
try {
|
if (req.csrfToken) {
|
||||||
const token = generateToken(req);
|
const token = req.csrfToken();
|
||||||
res.status(200).json({ done: true, message: "CSRF token refreshed", token });
|
res.cookie("XSRF-TOKEN", token, {
|
||||||
} catch (error) {
|
httpOnly: false,
|
||||||
|
secure: isProduction(),
|
||||||
|
sameSite: isProduction() ? "none" : "lax",
|
||||||
|
domain: isProduction() ? ".worklenz.com" : undefined,
|
||||||
|
path: "/"
|
||||||
|
});
|
||||||
|
res.status(200).json({ done: true, message: "CSRF token refreshed" });
|
||||||
|
} else {
|
||||||
res.status(500).json({ done: false, message: "Failed to generate CSRF token" });
|
res.status(500).json({ done: false, message: "Failed to generate CSRF token" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -202,7 +219,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 === invalidCsrfTokenError) {
|
if (err.code === "EBADCSRFTOKEN") {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
done: false,
|
done: false,
|
||||||
message: "Invalid CSRF token",
|
message: "Invalid CSRF token",
|
||||||
|
|||||||
@@ -35,18 +35,8 @@ 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;
|
||||||
|
|
||||||
// Determine title based on authentication status and strategy
|
const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
|
||||||
let title = null;
|
const title = req.query.strategy ? midTitle : 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();
|
||||||
|
|||||||
@@ -756,186 +756,4 @@ export default class ProjectsController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HandleExceptions()
|
|
||||||
public static async getGrouped(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
|
||||||
// Use qualified field name for projects to avoid ambiguity
|
|
||||||
const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, ["projects.name"]);
|
|
||||||
const groupBy = req.query.groupBy as string || "category";
|
|
||||||
|
|
||||||
const filterByMember = !req.user?.owner && !req.user?.is_admin ?
|
|
||||||
` AND is_member_of_project(projects.id, '${req.user?.id}', $1) ` : "";
|
|
||||||
|
|
||||||
const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : "";
|
|
||||||
const isArchived = req.query.filter === "2"
|
|
||||||
? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`
|
|
||||||
: ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`;
|
|
||||||
const categories = this.getFilterByCategoryWhereClosure(req.query.categories as string);
|
|
||||||
const statuses = this.getFilterByStatusWhereClosure(req.query.statuses as string);
|
|
||||||
|
|
||||||
// Determine grouping field and join based on groupBy parameter
|
|
||||||
let groupField = "";
|
|
||||||
let groupName = "";
|
|
||||||
let groupColor = "";
|
|
||||||
let groupJoin = "";
|
|
||||||
let groupByFields = "";
|
|
||||||
let groupOrderBy = "";
|
|
||||||
|
|
||||||
switch (groupBy) {
|
|
||||||
case "client":
|
|
||||||
groupField = "COALESCE(projects.client_id::text, 'no-client')";
|
|
||||||
groupName = "COALESCE(clients.name, 'No Client')";
|
|
||||||
groupColor = "'#688'";
|
|
||||||
groupJoin = "LEFT JOIN clients ON projects.client_id = clients.id";
|
|
||||||
groupByFields = "projects.client_id, clients.name";
|
|
||||||
groupOrderBy = "COALESCE(clients.name, 'No Client')";
|
|
||||||
break;
|
|
||||||
case "status":
|
|
||||||
groupField = "COALESCE(projects.status_id::text, 'no-status')";
|
|
||||||
groupName = "COALESCE(sys_project_statuses.name, 'No Status')";
|
|
||||||
groupColor = "COALESCE(sys_project_statuses.color_code, '#888')";
|
|
||||||
groupJoin = "LEFT JOIN sys_project_statuses ON projects.status_id = sys_project_statuses.id";
|
|
||||||
groupByFields = "projects.status_id, sys_project_statuses.name, sys_project_statuses.color_code";
|
|
||||||
groupOrderBy = "COALESCE(sys_project_statuses.name, 'No Status')";
|
|
||||||
break;
|
|
||||||
case "category":
|
|
||||||
default:
|
|
||||||
groupField = "COALESCE(projects.category_id::text, 'uncategorized')";
|
|
||||||
groupName = "COALESCE(project_categories.name, 'Uncategorized')";
|
|
||||||
groupColor = "COALESCE(project_categories.color_code, '#888')";
|
|
||||||
groupJoin = "LEFT JOIN project_categories ON projects.category_id = project_categories.id";
|
|
||||||
groupByFields = "projects.category_id, project_categories.name, project_categories.color_code";
|
|
||||||
groupOrderBy = "COALESCE(project_categories.name, 'Uncategorized')";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure sortField is properly qualified for the inner project query
|
|
||||||
let qualifiedSortField = sortField;
|
|
||||||
if (Array.isArray(sortField)) {
|
|
||||||
qualifiedSortField = sortField[0]; // Take the first field if it's an array
|
|
||||||
}
|
|
||||||
// Replace "projects." with "p2." for the inner query
|
|
||||||
const innerSortField = qualifiedSortField.replace("projects.", "p2.");
|
|
||||||
|
|
||||||
const q = `
|
|
||||||
SELECT ROW_TO_JSON(rec) AS groups
|
|
||||||
FROM (
|
|
||||||
SELECT COUNT(DISTINCT ${groupField}) AS total_groups,
|
|
||||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(group_data))), '[]'::JSON)
|
|
||||||
FROM (
|
|
||||||
SELECT ${groupField} AS group_key,
|
|
||||||
${groupName} AS group_name,
|
|
||||||
${groupColor} AS group_color,
|
|
||||||
COUNT(*) AS project_count,
|
|
||||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(project_data))), '[]'::JSON)
|
|
||||||
FROM (
|
|
||||||
SELECT p2.id,
|
|
||||||
p2.name,
|
|
||||||
(SELECT sys_project_statuses.name FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status,
|
|
||||||
(SELECT sys_project_statuses.color_code FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_color,
|
|
||||||
(SELECT sys_project_statuses.icon FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_icon,
|
|
||||||
EXISTS(SELECT user_id
|
|
||||||
FROM favorite_projects
|
|
||||||
WHERE user_id = '${req.user?.id}'
|
|
||||||
AND project_id = p2.id) AS favorite,
|
|
||||||
EXISTS(SELECT user_id
|
|
||||||
FROM archived_projects
|
|
||||||
WHERE user_id = '${req.user?.id}'
|
|
||||||
AND project_id = p2.id) AS archived,
|
|
||||||
p2.color_code,
|
|
||||||
p2.start_date,
|
|
||||||
p2.end_date,
|
|
||||||
p2.category_id,
|
|
||||||
(SELECT COUNT(*)
|
|
||||||
FROM tasks
|
|
||||||
WHERE archived IS FALSE
|
|
||||||
AND project_id = p2.id) AS all_tasks_count,
|
|
||||||
(SELECT COUNT(*)
|
|
||||||
FROM tasks
|
|
||||||
WHERE archived IS FALSE
|
|
||||||
AND project_id = p2.id
|
|
||||||
AND status_id IN (SELECT task_statuses.id
|
|
||||||
FROM task_statuses
|
|
||||||
WHERE task_statuses.project_id = p2.id
|
|
||||||
AND task_statuses.category_id IN
|
|
||||||
(SELECT sys_task_status_categories.id FROM sys_task_status_categories WHERE sys_task_status_categories.is_done IS TRUE))) AS completed_tasks_count,
|
|
||||||
(SELECT COUNT(*)
|
|
||||||
FROM project_members
|
|
||||||
WHERE project_members.project_id = p2.id) AS members_count,
|
|
||||||
(SELECT get_project_members(p2.id)) AS names,
|
|
||||||
(SELECT clients.name FROM clients WHERE clients.id = p2.client_id) AS client_name,
|
|
||||||
(SELECT users.name FROM users WHERE users.id = p2.owner_id) AS project_owner,
|
|
||||||
(SELECT project_categories.name FROM project_categories WHERE project_categories.id = p2.category_id) AS category_name,
|
|
||||||
(SELECT project_categories.color_code
|
|
||||||
FROM project_categories
|
|
||||||
WHERE project_categories.id = p2.category_id) AS category_color,
|
|
||||||
((SELECT project_members.team_member_id as team_member_id
|
|
||||||
FROM project_members
|
|
||||||
WHERE project_members.project_id = p2.id
|
|
||||||
AND project_members.project_access_level_id = (SELECT project_access_levels.id FROM project_access_levels WHERE project_access_levels.key = 'PROJECT_MANAGER'))) AS project_manager_team_member_id,
|
|
||||||
(SELECT project_members.default_view
|
|
||||||
FROM project_members
|
|
||||||
WHERE project_members.project_id = p2.id
|
|
||||||
AND project_members.team_member_id = '${req.user?.team_member_id}') AS team_member_default_view,
|
|
||||||
(SELECT CASE
|
|
||||||
WHEN ((SELECT MAX(tasks.updated_at)
|
|
||||||
FROM tasks
|
|
||||||
WHERE tasks.archived IS FALSE
|
|
||||||
AND tasks.project_id = p2.id) >
|
|
||||||
p2.updated_at)
|
|
||||||
THEN (SELECT MAX(tasks.updated_at)
|
|
||||||
FROM tasks
|
|
||||||
WHERE tasks.archived IS FALSE
|
|
||||||
AND tasks.project_id = p2.id)
|
|
||||||
ELSE p2.updated_at END) AS updated_at
|
|
||||||
FROM projects p2
|
|
||||||
${groupJoin.replace("projects.", "p2.")}
|
|
||||||
WHERE p2.team_id = $1
|
|
||||||
AND ${groupField.replace("projects.", "p2.")} = ${groupField}
|
|
||||||
${categories.replace("projects.", "p2.")}
|
|
||||||
${statuses.replace("projects.", "p2.")}
|
|
||||||
${isArchived.replace("projects.", "p2.")}
|
|
||||||
${isFavorites.replace("projects.", "p2.")}
|
|
||||||
${filterByMember.replace("projects.", "p2.")}
|
|
||||||
${searchQuery.replace("projects.", "p2.")}
|
|
||||||
ORDER BY ${innerSortField} ${sortOrder}
|
|
||||||
) project_data
|
|
||||||
) AS projects
|
|
||||||
FROM projects
|
|
||||||
${groupJoin}
|
|
||||||
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
|
|
||||||
GROUP BY ${groupByFields}
|
|
||||||
ORDER BY ${groupOrderBy}
|
|
||||||
LIMIT $2 OFFSET $3
|
|
||||||
) group_data
|
|
||||||
) AS data
|
|
||||||
FROM projects
|
|
||||||
${groupJoin}
|
|
||||||
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
|
|
||||||
) rec;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
|
|
||||||
const [data] = result.rows;
|
|
||||||
|
|
||||||
// Process the grouped data
|
|
||||||
for (const group of data?.groups.data || []) {
|
|
||||||
for (const project of group.projects || []) {
|
|
||||||
project.progress = project.all_tasks_count > 0
|
|
||||||
? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0;
|
|
||||||
|
|
||||||
project.updated_at_string = moment(project.updated_at).fromNow();
|
|
||||||
|
|
||||||
project.names = this.createTagList(project?.names);
|
|
||||||
project.names.map((a: any) => a.color_code = getColor(a.name));
|
|
||||||
|
|
||||||
if (project.project_manager_team_member_id) {
|
|
||||||
project.project_manager = {
|
|
||||||
id: project.project_manager_team_member_id
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, data?.groups || { total_groups: 0, data: [] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -415,15 +415,20 @@ export default class ReportingController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async getMyTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async getMyTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const selectedTeamId = req.user?.team_id;
|
||||||
|
if (!selectedTeamId) {
|
||||||
|
return res.status(400).send(new ServerResponse(false, "No selected team"));
|
||||||
|
}
|
||||||
const q = `SELECT team_id AS id, name
|
const q = `SELECT team_id AS id, name
|
||||||
FROM team_members tm
|
FROM team_members tm
|
||||||
LEFT JOIN teams ON teams.id = tm.team_id
|
LEFT JOIN teams ON teams.id = tm.team_id
|
||||||
WHERE tm.user_id = $1
|
WHERE tm.user_id = $1
|
||||||
|
AND tm.team_id = $2
|
||||||
AND role_id IN (SELECT id
|
AND role_id IN (SELECT id
|
||||||
FROM roles
|
FROM roles
|
||||||
WHERE (admin_role IS TRUE OR owner IS TRUE))
|
WHERE (admin_role IS TRUE OR owner IS TRUE))
|
||||||
ORDER BY name;`;
|
ORDER BY name;`;
|
||||||
const result = await db.query(q, [req.user?.id]);
|
const result = await db.query(q, [req.user?.id, selectedTeamId]);
|
||||||
result.rows.forEach((team: any) => team.selected = true);
|
result.rows.forEach((team: any) => team.selected = true);
|
||||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,27 +445,52 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count only weekdays (Mon-Fri) in the period
|
// Get organization working days
|
||||||
|
const orgWorkingDaysQuery = `
|
||||||
|
SELECT monday, tuesday, wednesday, thursday, friday, saturday, sunday
|
||||||
|
FROM organization_working_days
|
||||||
|
WHERE organization_id IN (
|
||||||
|
SELECT t.organization_id
|
||||||
|
FROM teams t
|
||||||
|
WHERE t.id IN (${teamIds})
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
const orgWorkingDaysResult = await db.query(orgWorkingDaysQuery, []);
|
||||||
|
const workingDaysConfig = orgWorkingDaysResult.rows[0] || {
|
||||||
|
monday: true,
|
||||||
|
tuesday: true,
|
||||||
|
wednesday: true,
|
||||||
|
thursday: true,
|
||||||
|
friday: true,
|
||||||
|
saturday: false,
|
||||||
|
sunday: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count working days based on organization settings
|
||||||
let workingDays = 0;
|
let workingDays = 0;
|
||||||
let current = startDate.clone();
|
let current = startDate.clone();
|
||||||
while (current.isSameOrBefore(endDate, 'day')) {
|
while (current.isSameOrBefore(endDate, 'day')) {
|
||||||
const day = current.isoWeekday();
|
const day = current.isoWeekday();
|
||||||
if (day >= 1 && day <= 5) workingDays++;
|
if (
|
||||||
|
(day === 1 && workingDaysConfig.monday) ||
|
||||||
|
(day === 2 && workingDaysConfig.tuesday) ||
|
||||||
|
(day === 3 && workingDaysConfig.wednesday) ||
|
||||||
|
(day === 4 && workingDaysConfig.thursday) ||
|
||||||
|
(day === 5 && workingDaysConfig.friday) ||
|
||||||
|
(day === 6 && workingDaysConfig.saturday) ||
|
||||||
|
(day === 7 && workingDaysConfig.sunday)
|
||||||
|
) {
|
||||||
|
workingDays++;
|
||||||
|
}
|
||||||
current.add(1, 'day');
|
current.add(1, 'day');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get hours_per_day for all selected projects
|
// Get organization working hours
|
||||||
const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`;
|
const orgWorkingHoursQuery = `SELECT working_hours FROM organizations WHERE id = (SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1)`;
|
||||||
const projectHoursResult = await db.query(projectHoursQuery, []);
|
const orgWorkingHoursResult = await db.query(orgWorkingHoursQuery, []);
|
||||||
const projectHoursMap: Record<string, number> = {};
|
const orgWorkingHours = orgWorkingHoursResult.rows[0]?.working_hours || 8;
|
||||||
for (const row of projectHoursResult.rows) {
|
let totalWorkingHours = workingDays * orgWorkingHours;
|
||||||
projectHoursMap[row.id] = row.hours_per_day || 8;
|
|
||||||
}
|
|
||||||
// Sum total working hours for all selected projects
|
|
||||||
let totalWorkingHours = 0;
|
|
||||||
for (const pid of Object.keys(projectHoursMap)) {
|
|
||||||
totalWorkingHours += workingDays * projectHoursMap[pid];
|
|
||||||
}
|
|
||||||
|
|
||||||
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||||
const archivedClause = archived
|
const archivedClause = archived
|
||||||
@@ -490,11 +515,18 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
|||||||
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
|
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
|
||||||
member.color_code = getColor(member.name);
|
member.color_code = getColor(member.name);
|
||||||
member.total_working_hours = totalWorkingHours;
|
member.total_working_hours = totalWorkingHours;
|
||||||
member.utilization_percent = (totalWorkingHours > 0 && member.logged_time) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00';
|
if (totalWorkingHours === 0) {
|
||||||
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
|
member.utilization_percent = member.logged_time && parseFloat(member.logged_time) > 0 ? 'N/A' : '0.00';
|
||||||
// Over/under utilized hours: utilized_hours - total_working_hours
|
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
|
||||||
const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0;
|
// Over/under utilized hours: all logged time is over-utilized
|
||||||
member.over_under_utilized_hours = overUnder.toFixed(2);
|
member.over_under_utilized_hours = member.utilized_hours;
|
||||||
|
} else {
|
||||||
|
member.utilization_percent = (member.logged_time && totalWorkingHours > 0) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00';
|
||||||
|
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
|
||||||
|
// Over/under utilized hours: utilized_hours - total_working_hours
|
||||||
|
const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0;
|
||||||
|
member.over_under_utilized_hours = overUnder.toFixed(2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
|
|||||||
@@ -74,14 +74,9 @@ export default class ScheduleControllerV2 extends WorklenzControllerBase {
|
|||||||
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
|
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
const updateQuery = `
|
const updateQuery = `UPDATE public.organization_working_days
|
||||||
UPDATE public.organization_working_days
|
|
||||||
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
|
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE organization_id IN (
|
WHERE organization_id IN (SELECT id FROM organizations WHERE user_id = $1);`;
|
||||||
SELECT organization_id FROM organizations
|
|
||||||
WHERE user_id = $1
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
await db.query(updateQuery, [req.user?.owner_id]);
|
await db.query(updateQuery, [req.user?.owner_id]);
|
||||||
|
|
||||||
|
|||||||
@@ -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,14 +32,23 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -49,20 +58,19 @@ 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
|
// For tasks with no subtasks and no manual progress, calculate based on time
|
||||||
else {
|
else {
|
||||||
// Only calculate progress based on time if time-based progress is enabled for the project
|
task.progress = task.total_minutes_spent && task.total_minutes
|
||||||
if (task.project_use_time_progress && task.total_minutes_spent && task.total_minutes) {
|
? ~~(task.total_minutes_spent / task.total_minutes * 100)
|
||||||
// Cap the progress at 100% to prevent showing more than 100% progress
|
: 0;
|
||||||
task.progress = Math.min(~~(task.total_minutes_spent / task.total_minutes * 100), 100);
|
|
||||||
} else {
|
|
||||||
// Default to 0% progress when time-based calculation is not enabled
|
|
||||||
task.progress = 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
|
||||||
@@ -71,7 +79,7 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
|||||||
|
|
||||||
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,6 +97,7 @@ 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;
|
||||||
@@ -610,21 +611,6 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
return this.createTagList(result.rows);
|
return this.createTagList(result.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async getProjectSubscribers(projectId: string) {
|
|
||||||
const q = `
|
|
||||||
SELECT u.name, u.avatar_url, ps.user_id, ps.team_member_id, ps.project_id
|
|
||||||
FROM project_subscribers ps
|
|
||||||
LEFT JOIN users u ON ps.user_id = u.id
|
|
||||||
WHERE ps.project_id = $1;
|
|
||||||
`;
|
|
||||||
const result = await db.query(q, [projectId]);
|
|
||||||
|
|
||||||
for (const member of result.rows)
|
|
||||||
member.color_code = getColor(member.name);
|
|
||||||
|
|
||||||
return this.createTagList(result.rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async checkUserAssignedToTask(taskId: string, userId: string, teamId: string) {
|
public static async checkUserAssignedToTask(taskId: string, userId: string, teamId: string) {
|
||||||
const q = `
|
const q = `
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ export function startCronJobs() {
|
|||||||
startNotificationsJob();
|
startNotificationsJob();
|
||||||
startDailyDigestJob();
|
startDailyDigestJob();
|
||||||
startProjectDigestJob();
|
startProjectDigestJob();
|
||||||
if (process.env.ENABLE_RECURRING_JOBS === "true") startRecurringTasksJob();
|
// startRecurringTasksJob();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,90 +7,12 @@ 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 = process.env.RECURRING_JOBS_INTERVAL || "0 11 */1 * 1-5";
|
const TIME = "*/2 * * * *"; // runs every 2 minutes - for testing purposes
|
||||||
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.");
|
||||||
@@ -111,44 +33,65 @@ async function onRecurringTaskJobTick() {
|
|||||||
? moment(template.last_task_end_date)
|
? moment(template.last_task_end_date)
|
||||||
: moment(template.created_at);
|
: moment(template.created_at);
|
||||||
|
|
||||||
// Calculate future limit based on schedule type
|
const futureLimit = moment(template.last_checked_at || template.created_at).add(1, "week");
|
||||||
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 all future occurrences within the limit
|
// Find the next future occurrence
|
||||||
while (nextEndDate.isSameOrBefore(futureLimit)) {
|
while (nextEndDate.isSameOrBefore(now)) {
|
||||||
if (nextEndDate.isAfter(now)) {
|
|
||||||
endDatesToCreate.push(moment(nextEndDate));
|
|
||||||
}
|
|
||||||
nextEndDate = calculateNextEndDate(template, nextEndDate);
|
nextEndDate = calculateNextEndDate(template, nextEndDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch create tasks for all future dates
|
// Only create a task if it's within the future limit
|
||||||
if (endDatesToCreate.length > 0) {
|
if (nextEndDate.isSameOrBefore(futureLimit)) {
|
||||||
const createdTasks = await createBatchTasks(template, endDatesToCreate);
|
const existingTaskQuery = `
|
||||||
createdTaskCount += createdTasks.length;
|
SELECT id FROM tasks
|
||||||
|
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;
|
|
||||||
`;
|
`;
|
||||||
await db.query(updateScheduleQuery, [
|
const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]);
|
||||||
moment().format(TIME_FORMAT),
|
|
||||||
endDatesToCreate[endDatesToCreate.length - 1].format(TIME_FORMAT),
|
if (existingTaskResult.rows.length === 0) {
|
||||||
template.schedule_id
|
const createTaskQuery = `SELECT create_quick_task($1::json) as task;`;
|
||||||
]);
|
const taskData = {
|
||||||
|
name: template.name,
|
||||||
|
priority_id: template.priority_id,
|
||||||
|
project_id: template.project_id,
|
||||||
|
reporter_id: template.reporter_id,
|
||||||
|
status_id: template.status_id || null,
|
||||||
|
end_date: nextEndDate.format(TIME_FORMAT),
|
||||||
|
schedule_id: template.schedule_id
|
||||||
|
};
|
||||||
|
const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]);
|
||||||
|
const createdTask = createTaskResult.rows[0].task;
|
||||||
|
|
||||||
|
if (createdTask) {
|
||||||
|
createdTaskCount++;
|
||||||
|
|
||||||
|
for (const assignee of template.assignees) {
|
||||||
|
await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const label of template.labels) {
|
||||||
|
const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`;
|
||||||
|
await db.query(q, [createdTask.id, label.label_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`No tasks created for template ${template.name} - next occurrence is beyond the future limit`);
|
console.log(`No task 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.`);
|
||||||
|
|||||||
@@ -3,16 +3,13 @@ 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) {
|
||||||
// Clear any existing flash messages
|
console.log("Login attempt for:", email);
|
||||||
(req.session as any).flash = {};
|
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
const errorMsg = "Please enter both email and password";
|
console.log("Missing credentials");
|
||||||
req.flash(ERROR_KEY, errorMsg);
|
return done(null, false, { message: "Please enter both email and password" });
|
||||||
return done(null, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -22,27 +19,23 @@ 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) {
|
||||||
const errorMsg = "No account found with this email";
|
console.log("No account found");
|
||||||
req.flash(ERROR_KEY, errorMsg);
|
return done(null, false, { message: "No account found with this email" });
|
||||||
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;
|
||||||
const successMsg = "User successfully logged in";
|
return done(null, data, {message: "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);
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "tinymce",
|
|
||||||
"version": "6.8.4",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "tinymce",
|
|
||||||
"version": "6.8.4",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tinymce": "file:"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tinymce": {
|
|
||||||
"resolved": "",
|
|
||||||
"link": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,8 +28,5 @@
|
|||||||
"homepage": "https://www.tiny.cloud/",
|
"homepage": "https://www.tiny.cloud/",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/tinymce/tinymce/issues"
|
"url": "https://github.com/tinymce/tinymce/issues"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"tinymce": "file:"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,6 @@ projectsApiRouter.get("/update-exist-sort-order", safeControllerFunction(Project
|
|||||||
|
|
||||||
projectsApiRouter.post("/", teamOwnerOrAdminValidator, projectsBodyValidator, safeControllerFunction(ProjectsController.create));
|
projectsApiRouter.post("/", teamOwnerOrAdminValidator, projectsBodyValidator, safeControllerFunction(ProjectsController.create));
|
||||||
projectsApiRouter.get("/", safeControllerFunction(ProjectsController.get));
|
projectsApiRouter.get("/", safeControllerFunction(ProjectsController.get));
|
||||||
projectsApiRouter.get("/grouped", safeControllerFunction(ProjectsController.getGrouped));
|
|
||||||
projectsApiRouter.get("/my-task-projects", safeControllerFunction(ProjectsController.getMyProjectsToTasks));
|
projectsApiRouter.get("/my-task-projects", safeControllerFunction(ProjectsController.getMyProjectsToTasks));
|
||||||
projectsApiRouter.get("/my-projects", safeControllerFunction(ProjectsController.getMyProjects));
|
projectsApiRouter.get("/my-projects", safeControllerFunction(ProjectsController.getMyProjects));
|
||||||
projectsApiRouter.get("/all", safeControllerFunction(ProjectsController.getAllProjects));
|
projectsApiRouter.get("/all", safeControllerFunction(ProjectsController.getAllProjects));
|
||||||
|
|||||||
@@ -166,7 +166,9 @@ export const UNMAPPED = "Unmapped";
|
|||||||
|
|
||||||
export const DATE_RANGES = {
|
export const DATE_RANGES = {
|
||||||
YESTERDAY: "YESTERDAY",
|
YESTERDAY: "YESTERDAY",
|
||||||
|
LAST_7_DAYS: "LAST_7_DAYS",
|
||||||
LAST_WEEK: "LAST_WEEK",
|
LAST_WEEK: "LAST_WEEK",
|
||||||
|
LAST_30_DAYS: "LAST_30_DAYS",
|
||||||
LAST_MONTH: "LAST_MONTH",
|
LAST_MONTH: "LAST_MONTH",
|
||||||
LAST_QUARTER: "LAST_QUARTER",
|
LAST_QUARTER: "LAST_QUARTER",
|
||||||
ALL_TIME: "ALL_TIME"
|
ALL_TIME: "ALL_TIME"
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ export async function on_project_subscriber_change(_io: Server, socket: Socket,
|
|||||||
const isSubscribe = data.mode == 0;
|
const isSubscribe = data.mode == 0;
|
||||||
const q = isSubscribe
|
const q = isSubscribe
|
||||||
? `INSERT INTO project_subscribers (user_id, project_id, team_member_id)
|
? `INSERT INTO project_subscribers (user_id, project_id, team_member_id)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3);`
|
||||||
ON CONFLICT (user_id, project_id, team_member_id) DO NOTHING;`
|
|
||||||
: `DELETE
|
: `DELETE
|
||||||
FROM project_subscribers
|
FROM project_subscribers
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
@@ -28,7 +27,7 @@ export async function on_project_subscriber_change(_io: Server, socket: Socket,
|
|||||||
AND team_member_id = $3;`;
|
AND team_member_id = $3;`;
|
||||||
await db.query(q, [data.user_id, data.project_id, data.team_member_id]);
|
await db.query(q, [data.user_id, data.project_id, data.team_member_id]);
|
||||||
|
|
||||||
const subscribers = await TasksControllerV2.getProjectSubscribers(data.project_id);
|
const subscribers = await TasksControllerV2.getTaskSubscribers(data.project_id);
|
||||||
socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), subscribers);
|
socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), subscribers);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,30 +2,31 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Password Changed | Worklenz</title>
|
<title></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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
a[x-apple-data-detectors] {
|
||||||
background: #fff;
|
color: inherit !important;
|
||||||
border-radius: 18px;
|
text-decoration: inherit !important
|
||||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
}
|
||||||
margin: 40px auto 0 auto;
|
|
||||||
max-width: 500px;
|
#MessageViewBody a {
|
||||||
padding: 0 0 20px 0;
|
color: inherit;
|
||||||
|
text-decoration: none
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -41,48 +42,33 @@
|
|||||||
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 {
|
||||||
@@ -90,145 +76,135 @@
|
|||||||
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;">
|
||||||
<div class="main-container">
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
width="100%">
|
||||||
width="100%">
|
<tbody>
|
||||||
<tbody>
|
<tr>
|
||||||
<tr>
|
<td>
|
||||||
<td>
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||||
<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%">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
<tbody>
|
||||||
<tbody>
|
<tr>
|
||||||
<tr>
|
<td>
|
||||||
<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="pad" style="width:100%;padding-right:0;padding-left:0">
|
<td class="column column-1"
|
||||||
<div align="left" class="alignment" style="line-height:10px">
|
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"
|
||||||
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
width="100%">
|
||||||
target="_blank"><img
|
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||||
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="alignment" style="vertical-align:middle;text-align:center">
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
<!--[if vml]>
|
<div align="center" class="alignment" style="line-height:10px"><img
|
||||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-password-changed.png"
|
||||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
style="display:block;height:auto;border:0;width:100px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||||
<![endif]-->
|
width="100">
|
||||||
<!--[if !vml]><!-->
|
</div>
|
||||||
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation"
|
</td>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
</tr>
|
||||||
</table>
|
<tr>
|
||||||
|
<td class="pad">
|
||||||
|
<div
|
||||||
|
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:150%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
|
||||||
|
<p style="margin-top: 0px;margin-bottom: 30px;">
|
||||||
|
We wanted to let you know that your Worklenz password was reset.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</tbody>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
</table>
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
</td>
|
</tbody>
|
||||||
</tr>
|
</table>
|
||||||
</tbody>
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||||
</table>
|
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
<div class="footer">
|
<tbody>
|
||||||
If you have any questions, contact us at <a href="mailto:support@worklenz.com"
|
<tr>
|
||||||
style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
|
<td>
|
||||||
© 2025 Worklenz. All rights reserved.
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation"
|
||||||
</div>
|
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||||
</div>
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="column column-1"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||||
|
width="100%">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="pad"
|
||||||
|
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||||
|
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||||
|
<!--[if vml]>
|
||||||
|
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !vml]><!-->
|
||||||
|
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table><!-- End -->
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,30 +2,31 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Reset Your Password | Worklenz</title>
|
<title></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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
a[x-apple-data-detectors] {
|
||||||
background: #fff;
|
color: inherit !important;
|
||||||
border-radius: 18px;
|
text-decoration: inherit !important
|
||||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
}
|
||||||
margin: 40px auto 0 auto;
|
|
||||||
max-width: 500px;
|
#MessageViewBody a {
|
||||||
padding: 0 0 20px 0;
|
color: inherit;
|
||||||
|
text-decoration: none
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -41,68 +42,33 @@
|
|||||||
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 {
|
||||||
@@ -110,137 +76,179 @@
|
|||||||
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>
|
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||||
<div class="main-container">
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="background:transparent;" 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%">
|
||||||
|
<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%">
|
|
||||||
<tbody>
|
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
||||||
<tr>
|
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||||
<td>
|
padding-bottom: 20px;" width="220">
|
||||||
<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>
|
||||||
<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 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>
|
target="_blank"><img
|
||||||
</div>
|
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
|
||||||
</td>
|
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||||
</tr>
|
</div>
|
||||||
</table>
|
</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="475">
|
</tr>
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="column column-1" style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0" width="100%">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td class="pad">
|
|
||||||
<h1 style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;font-family: 'Mada', Arial, sans-serif;">
|
|
||||||
<span class="tinyMce-placeholder">Reset your password</span>
|
|
||||||
</h1>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
|
||||||
<div align="center" class="alignment" style="line-height:10px"><img src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png" style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;" width="180">
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td class="pad">
|
|
||||||
<div style="color:#505771;font-size:20px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:130%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px">
|
|
||||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td class="pad">
|
|
||||||
<div style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
|
|
||||||
<p style="margin:0;margin-bottom:10px">We received a request to reset your Worklenz account password.</p>
|
|
||||||
<p style="margin:0;margin-bottom:10px">Click the button below to set a new password. If you did not request this, you can safely ignore this email.</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td class="pad">
|
|
||||||
<div align="center" class="alignment">
|
|
||||||
<a href="https://[VAR_HOSTNAME]/auth/verify-reset-email/[VAR_USER_ID]/[VAR_HASH]" class="modern-btn">
|
|
||||||
Reset my password
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div style="color:#b0b8c9;font-size:14px;text-align:center;margin-top:10px;">
|
|
||||||
<p style="margin:0;">For your security, this link will expire in 1 hour.</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</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%">
|
<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>
|
<td class="column column-1"
|
||||||
<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">
|
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"
|
||||||
<tbody>
|
width="100%">
|
||||||
<tr>
|
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
|
||||||
<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%">
|
style="mso-table-lspace:0;mso-table-rspace: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>
|
||||||
<tr>
|
<td class="pad">
|
||||||
<td class="pad" style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
<h1
|
||||||
<table cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
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>
|
||||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
</h1>
|
||||||
<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>
|
||||||
</table>
|
</tr>
|
||||||
</td>
|
<tr>
|
||||||
</tr>
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
</table>
|
<div align="center" class="alignment" style="line-height:10px"><img
|
||||||
</td>
|
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png"
|
||||||
</tr>
|
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||||
</table>
|
width="180">
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||||
</tr>
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="pad">
|
||||||
|
<div
|
||||||
|
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:120%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
|
||||||
|
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||||
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="pad">
|
||||||
|
<div
|
||||||
|
style="color:rgb(143,152,164);font-size:16px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:18px">
|
||||||
|
<p style="margin:0;margin-bottom:10px">You have requested to reset your password
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;margin-bottom:10px">To reset your password, click the following link and follow the instructions.</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="pad">
|
||||||
|
<div align="center" class="alignment">
|
||||||
|
<a href="https://[VAR_HOSTNAME]/auth/verify-reset-email/[VAR_USER_ID]/[VAR_HASH]">
|
||||||
|
<div
|
||||||
|
style="text-decoration:none;display:inline-block;color:#fff;background: #54bf6b;border-radius:3px;width:auto;font-weight:400;padding-top:6px;padding-bottom:7px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;text-align:center;mso-border-alt:none;word-break:keep-all">
|
||||||
|
<span
|
||||||
|
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
|
||||||
|
dir="ltr" style="word-break: break-word; line-height: 28px;">Reset my password</span></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||||
|
width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||||
|
role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="column column-1"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||||
|
width="100%">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="pad"
|
||||||
|
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||||
|
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||||
|
<!--[if vml]>
|
||||||
|
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !vml]><!-->
|
||||||
|
<table cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
class="icons-inner" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="footer">
|
</td>
|
||||||
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>
|
</tr>
|
||||||
© 2025 Worklenz. All rights reserved.
|
</tbody>
|
||||||
</div>
|
</table><!-- End -->
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,30 +2,31 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Join Your Team on Worklenz</title>
|
<title></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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
a[x-apple-data-detectors] {
|
||||||
background: #fff;
|
color: inherit !important;
|
||||||
border-radius: 18px;
|
text-decoration: inherit !important
|
||||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
}
|
||||||
margin: 40px auto 0 auto;
|
|
||||||
max-width: 500px;
|
#MessageViewBody a {
|
||||||
padding: 0 0 20px 0;
|
color: inherit;
|
||||||
|
text-decoration: none
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -41,68 +42,33 @@
|
|||||||
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 {
|
||||||
@@ -110,134 +76,181 @@
|
|||||||
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>
|
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||||
<div class="main-container">
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="background:transparent;" 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%">
|
||||||
|
<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%">
|
|
||||||
<tbody>
|
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
||||||
<tr>
|
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||||
<td>
|
padding-bottom: 20px;" width="220">
|
||||||
<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>
|
||||||
<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 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>
|
target="_blank"><img
|
||||||
</div>
|
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
|
||||||
</td>
|
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||||
</tr>
|
</div>
|
||||||
</table>
|
</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="475">
|
</tr>
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="column column-1" style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0" width="100%">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td class="pad">
|
|
||||||
<h1 style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;font-family: 'Mada', Arial, sans-serif;">
|
|
||||||
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
|
||||||
</h1>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
|
||||||
<div align="center" class="alignment" style="line-height:10px"><img src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png" style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;" width="180">
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td class="pad">
|
|
||||||
<div style="color:#505771;font-size:20px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:130%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px">
|
|
||||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td class="pad">
|
|
||||||
<div style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
|
|
||||||
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team on Worklenz!</p>
|
|
||||||
<p>Sign in to your Worklenz account to continue.</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;" width="100%">
|
|
||||||
<tr>
|
|
||||||
<td class="pad">
|
|
||||||
<div align="center" class="alignment">
|
|
||||||
<a href="https://[VAR_HOSTNAME]/auth/login?team=[VAR_TEAM_ID]&user=[VAR_USER_ID]&project=[PROJECT_ID]" class="modern-btn">
|
|
||||||
Go to Worklenz
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</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%">
|
<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>
|
<td class="column column-1"
|
||||||
<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">
|
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"
|
||||||
<tbody>
|
width="100%">
|
||||||
<tr>
|
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
|
||||||
<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%">
|
style="mso-table-lspace:0;mso-table-rspace: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>
|
||||||
<tr>
|
<td class="pad">
|
||||||
<td class="pad" style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
<h1
|
||||||
<table cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
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>
|
||||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
</h1>
|
||||||
<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>
|
||||||
</table>
|
</tr>
|
||||||
</td>
|
<tr>
|
||||||
</tr>
|
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||||
</table>
|
<div align="center" class="alignment" style="line-height:10px"><img
|
||||||
</td>
|
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png"
|
||||||
</tr>
|
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||||
</table>
|
width="180">
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||||
</tr>
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="pad">
|
||||||
|
<div
|
||||||
|
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:120%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
|
||||||
|
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||||
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="pad">
|
||||||
|
<div
|
||||||
|
style="color:rgb(143,152,164);font-size:16px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:18px">
|
||||||
|
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team
|
||||||
|
on Worklenz!
|
||||||
|
</p>
|
||||||
|
<p>Sign in to your Worklenz account to continue.</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="pad">
|
||||||
|
<div align="center" class="alignment">
|
||||||
|
<a href="https://[VAR_HOSTNAME]/auth/login?team=[VAR_TEAM_ID]&user=[VAR_USER_ID]&project=[PROJECT_ID]">
|
||||||
|
<div
|
||||||
|
style="text-decoration:none;display:inline-block;color:#fff;background: #54bf6b;border-radius:3px;width:auto;font-weight:400;padding-top:6px;padding-bottom:7px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;text-align:center;mso-border-alt:none;word-break:keep-all">
|
||||||
|
<span
|
||||||
|
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
|
||||||
|
dir="ltr" style="word-break: break-word; line-height: 28px;">Go to
|
||||||
|
Worklenz</span></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||||
|
width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||||
|
role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="column column-1"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||||
|
width="100%">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="pad"
|
||||||
|
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||||
|
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||||
|
<!--[if vml]>
|
||||||
|
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !vml]><!-->
|
||||||
|
<table cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
class="icons-inner" role="presentation"
|
||||||
|
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="footer">
|
</td>
|
||||||
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>
|
</tr>
|
||||||
© 2025 Worklenz. All rights reserved.
|
</tbody>
|
||||||
</div>
|
</table><!-- End -->
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,30 +2,31 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Join Your Team on Worklenz</title>
|
<title></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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
a[x-apple-data-detectors] {
|
||||||
background: #fff;
|
color: inherit !important;
|
||||||
border-radius: 18px;
|
text-decoration: inherit !important
|
||||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
}
|
||||||
margin: 40px auto 0 auto;
|
|
||||||
max-width: 500px;
|
#MessageViewBody a {
|
||||||
padding: 0 0 20px 0;
|
color: inherit;
|
||||||
|
text-decoration: none
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -41,68 +42,33 @@
|
|||||||
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 {
|
||||||
@@ -110,174 +76,180 @@
|
|||||||
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>
|
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||||
<div class="main-container">
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||||
style="background:transparent;"
|
width="100%">
|
||||||
width="100%">
|
<tbody>
|
||||||
<tbody>
|
<tr>
|
||||||
<tr>
|
<td>
|
||||||
<td>
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||||
<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%">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
<tbody>
|
||||||
<tbody>
|
<tr>
|
||||||
<tr>
|
<td>
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
|
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
|
||||||
width="475">
|
width="475">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="column column-1"
|
<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"
|
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="0" cellspacing="0" class="image_block block-3" role="presentation"
|
<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%">
|
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<h1
|
<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;">
|
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>
|
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
||||||
</h1>
|
</h1>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<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="center" class="alignment" style="line-height:10px"><img
|
<div align="center" class="alignment" style="line-height:10px"><img
|
||||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-unregistered-team-member.png"
|
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;"
|
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||||
width="180">
|
width="180">
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
<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"
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||||
width="100%">
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<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
|
<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">
|
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">
|
||||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
<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>
|
</div>
|
||||||
</td>
|
</a>
|
||||||
</tr>
|
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||||
</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%">
|
|
||||||
<tr>
|
|
||||||
<td class="pad">
|
|
||||||
<div
|
|
||||||
style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
|
|
||||||
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team
|
|
||||||
on Worklenz!</p>
|
|
||||||
<p>Create an account in Worklenz to continue.</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
|
||||||
width="100%">
|
|
||||||
<tr>
|
|
||||||
<td class="pad">
|
|
||||||
<div align="center" class="alignment">
|
|
||||||
<a href="https://[VAR_HOSTNAME]/auth/signup?email=[VAR_EMAIL]&user=[VAR_USER_ID]&team=[VAR_TEAM_ID]&name=[VAR_USER_NAME]&project=[PROJECT_ID]" class="modern-btn">
|
|
||||||
Join Worklenz
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</tbody>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
</table>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||||
width="100%">
|
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||||
<tbody>
|
width="100%">
|
||||||
<tr>
|
<tbody>
|
||||||
<td>
|
<tr>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
<td>
|
||||||
role="presentation"
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
role="presentation"
|
||||||
<tbody>
|
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||||
<tr>
|
<tbody>
|
||||||
<td class="column column-1"
|
<tr>
|
||||||
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"
|
<td class="column column-1"
|
||||||
width="100%">
|
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="icons_block block-1" role="presentation"
|
width="100%">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
<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="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"
|
||||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||||
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"
|
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||||
cellspacing="0"
|
<!--[if vml]>
|
||||||
class="icons-inner" role="presentation"
|
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
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">
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
</table>
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
</td>
|
</tbody>
|
||||||
</tr>
|
</table>
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
<div class="footer">
|
</tbody>
|
||||||
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>
|
</table>
|
||||||
© 2025 Worklenz. All rights reserved.
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
</div>
|
</tbody>
|
||||||
|
</table><!-- End -->
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,30 +2,31 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Welcome to Worklenz</title>
|
<title></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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
a[x-apple-data-detectors] {
|
||||||
background: #fff;
|
color: inherit !important;
|
||||||
border-radius: 18px;
|
text-decoration: inherit !important
|
||||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
}
|
||||||
margin: 40px auto 0 auto;
|
|
||||||
max-width: 500px;
|
#MessageViewBody a {
|
||||||
padding: 0 0 20px 0;
|
color: inherit;
|
||||||
|
text-decoration: none
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
.padding-30 {
|
.padding-30 {
|
||||||
@@ -41,68 +42,33 @@
|
|||||||
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 {
|
||||||
@@ -110,173 +76,179 @@
|
|||||||
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>
|
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||||
<div class="main-container">
|
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
style="mso-table-lspace:0;mso-table-rspace:0;background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;background:transparent;"
|
width="100%">
|
||||||
width="100%">
|
<tbody>
|
||||||
<tbody>
|
<tr>
|
||||||
<tr>
|
<td>
|
||||||
<td>
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||||
<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%">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
<tbody>
|
||||||
<tbody>
|
<tr>
|
||||||
<tr>
|
<td>
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
|
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
|
||||||
width="475">
|
width="475">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="column column-1"
|
<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"
|
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="0" cellspacing="0" class="image_block block-3" role="presentation"
|
<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%">
|
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<td class="pad">
|
||||||
<h1
|
<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;">
|
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>
|
<span class="tinyMce-placeholder">Let's get started with Worklenz.</span>
|
||||||
</h1>
|
</h1>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<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="center" class="alignment" style="line-height:10px"><img
|
<div align="center" class="alignment" style="line-height:10px"><img
|
||||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-signup.png"
|
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;"
|
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||||
width="180">
|
width="180">
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
<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"
|
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||||
width="100%">
|
width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="pad">
|
<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
|
<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">
|
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">
|
||||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
<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>
|
</div>
|
||||||
</td>
|
</a>
|
||||||
</tr>
|
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||||
</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%">
|
|
||||||
<tr>
|
|
||||||
<td class="pad">
|
|
||||||
<div
|
|
||||||
style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
|
|
||||||
<p style="margin:0;margin-bottom:10px">Thanks for joining Worklenz!</p>
|
|
||||||
<p style="margin:0"> We're excited to have you on board. </p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
|
||||||
width="100%">
|
|
||||||
<tr>
|
|
||||||
<td class="pad">
|
|
||||||
<div align="center" class="alignment">
|
|
||||||
<a href="https://[VAR_HOSTNAME]/auth/login" class="modern-btn">
|
|
||||||
Go to Worklenz
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</tbody>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
</table>
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||||
width="100%">
|
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||||
<tbody>
|
width="100%">
|
||||||
<tr>
|
<tbody>
|
||||||
<td>
|
<tr>
|
||||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
<td>
|
||||||
role="presentation"
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
role="presentation"
|
||||||
<tbody>
|
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||||
<tr>
|
<tbody>
|
||||||
<td class="column column-1"
|
<tr>
|
||||||
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"
|
<td class="column column-1"
|
||||||
width="100%">
|
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="icons_block block-1" role="presentation"
|
width="100%">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
<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="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"
|
||||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||||
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"
|
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||||
cellspacing="0"
|
<!--[if vml]>
|
||||||
class="icons-inner" role="presentation"
|
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
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">
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
</table>
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
</td>
|
</tbody>
|
||||||
</tr>
|
</table>
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
<div class="footer">
|
</tbody>
|
||||||
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>
|
</table>
|
||||||
© 2025 Worklenz. All rights reserved.
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
</div>
|
</tbody>
|
||||||
|
</table><!-- End -->
|
||||||
</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 NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS production
|
FROM node:22-alpine AS production
|
||||||
|
|
||||||
|
|||||||
@@ -48,17 +48,6 @@
|
|||||||
<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>
|
||||||
1533
worklenz-frontend/package-lock.json
generated
1533
worklenz-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite dev",
|
"start": "vite",
|
||||||
"dev": "vite dev",
|
|
||||||
"prebuild": "node scripts/copy-tinymce.js",
|
"prebuild": "node scripts/copy-tinymce.js",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev-build": "vite build",
|
"dev-build": "vite build",
|
||||||
@@ -14,7 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/colors": "^7.1.0",
|
"@ant-design/colors": "^7.1.0",
|
||||||
"@ant-design/compatible": "^5.1.4",
|
"@ant-design/compatible": "^5.1.4",
|
||||||
"@ant-design/icons": "^4.7.0",
|
"@ant-design/icons": "^5.4.0",
|
||||||
"@ant-design/pro-components": "^2.7.19",
|
"@ant-design/pro-components": "^2.7.19",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
@@ -30,7 +29,6 @@
|
|||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"chart.js": "^4.4.7",
|
"chart.js": "^4.4.7",
|
||||||
"chartjs-plugin-datalabels": "^2.2.0",
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
"cors": "^2.8.5",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.2.5",
|
"dompurify": "^3.2.5",
|
||||||
"gantt-task-react": "^0.3.9",
|
"gantt-task-react": "^0.3.9",
|
||||||
@@ -40,7 +38,6 @@
|
|||||||
"i18next-http-backend": "^2.7.3",
|
"i18next-http-backend": "^2.7.3",
|
||||||
"jspdf": "^3.0.0",
|
"jspdf": "^3.0.0",
|
||||||
"mixpanel-browser": "^2.56.0",
|
"mixpanel-browser": "^2.56.0",
|
||||||
"nanoid": "^5.1.5",
|
|
||||||
"primereact": "^10.8.4",
|
"primereact": "^10.8.4",
|
||||||
"re-resizable": "^6.10.3",
|
"re-resizable": "^6.10.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -55,8 +52,7 @@
|
|||||||
"react-window": "^1.8.11",
|
"react-window": "^1.8.11",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tinymce": "^7.7.2",
|
"tinymce": "^7.7.2",
|
||||||
"web-vitals": "^4.2.4",
|
"web-vitals": "^4.2.4"
|
||||||
"worklenz": "file:"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
@@ -74,7 +70,6 @@
|
|||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.5.2",
|
"postcss": "^8.5.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.8",
|
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||||
"rollup": "^4.40.2",
|
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"terser": "^5.39.0",
|
"terser": "^5.39.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
@@ -4,5 +4,19 @@
|
|||||||
"owner": "Organization Owner",
|
"owner": "Organization Owner",
|
||||||
"admins": "Organization Admins",
|
"admins": "Organization Admins",
|
||||||
"contactNumber": "Add Contact Number",
|
"contactNumber": "Add Contact Number",
|
||||||
"edit": "Edit"
|
"edit": "Edit",
|
||||||
|
"organizationWorkingDaysAndHours": "Organization Working Days & Hours",
|
||||||
|
"workingDays": "Working Days",
|
||||||
|
"workingHours": "Working Hours",
|
||||||
|
"monday": "Monday",
|
||||||
|
"tuesday": "Tuesday",
|
||||||
|
"wednesday": "Wednesday",
|
||||||
|
"thursday": "Thursday",
|
||||||
|
"friday": "Friday",
|
||||||
|
"saturday": "Saturday",
|
||||||
|
"sunday": "Sunday",
|
||||||
|
"hours": "hours",
|
||||||
|
"saveButton": "Save",
|
||||||
|
"saved": "Saved successfully!",
|
||||||
|
"errorSaving": "Error saving settings."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,5 @@
|
|||||||
"unarchiveConfirm": "Are you sure you want to unarchive this project?",
|
"unarchiveConfirm": "Are you sure you want to unarchive this project?",
|
||||||
"clickToFilter": "Click to filter by",
|
"clickToFilter": "Click to filter by",
|
||||||
"noProjects": "No projects found",
|
"noProjects": "No projects found",
|
||||||
"addToFavourites": "Add to favourites",
|
"addToFavourites": "Add to favourites"
|
||||||
"list": "List",
|
|
||||||
"group": "Group",
|
|
||||||
"listView": "List View",
|
|
||||||
"groupView": "Group View",
|
|
||||||
"groupBy": {
|
|
||||||
"category": "Category",
|
|
||||||
"client": "Client"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,5 @@
|
|||||||
"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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"recurring": "Recurring",
|
"recurring": "Recurring",
|
||||||
"recurringTaskConfiguration": "Recurring task configuration",
|
"recurringTaskConfiguration": "Recurring task configuration",
|
||||||
"repeats": "Repeats",
|
"repeats": "Repeats",
|
||||||
"daily": "Daily",
|
|
||||||
"weekly": "Weekly",
|
"weekly": "Weekly",
|
||||||
"everyXDays": "Every X Days",
|
"everyXDays": "Every X Days",
|
||||||
"everyXWeeks": "Every X Weeks",
|
"everyXWeeks": "Every X Weeks",
|
||||||
|
|||||||
@@ -40,18 +40,5 @@
|
|||||||
"noCategory": "No Category",
|
"noCategory": "No Category",
|
||||||
"noProjects": "No projects found",
|
"noProjects": "No projects found",
|
||||||
"noTeams": "No teams found",
|
"noTeams": "No teams found",
|
||||||
"noData": "No data found",
|
"noData": "No data found"
|
||||||
|
|
||||||
"groupBy": "Group by",
|
|
||||||
"groupByCategory": "Category",
|
|
||||||
"groupByTeam": "Team",
|
|
||||||
"groupByStatus": "Status",
|
|
||||||
"groupByNone": "None",
|
|
||||||
"clearSearch": "Clear search",
|
|
||||||
"selectedProjects": "Selected Projects",
|
|
||||||
"projectsSelected": "projects selected",
|
|
||||||
"showSelected": "Show Selected Only",
|
|
||||||
"expandAll": "Expand All",
|
|
||||||
"collapseAll": "Collapse All",
|
|
||||||
"ungrouped": "Ungrouped"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,19 @@
|
|||||||
"owner": "Propietario de la Organización",
|
"owner": "Propietario de la Organización",
|
||||||
"admins": "Administradores de la Organización",
|
"admins": "Administradores de la Organización",
|
||||||
"contactNumber": "Agregar Número de Contacto",
|
"contactNumber": "Agregar Número de Contacto",
|
||||||
"edit": "Editar"
|
"edit": "Editar",
|
||||||
|
"organizationWorkingDaysAndHours": "Días y Horas Laborales de la Organización",
|
||||||
|
"workingDays": "Días Laborales",
|
||||||
|
"workingHours": "Horas Laborales",
|
||||||
|
"monday": "Lunes",
|
||||||
|
"tuesday": "Martes",
|
||||||
|
"wednesday": "Miércoles",
|
||||||
|
"thursday": "Jueves",
|
||||||
|
"friday": "Viernes",
|
||||||
|
"saturday": "Sábado",
|
||||||
|
"sunday": "Domingo",
|
||||||
|
"hours": "horas",
|
||||||
|
"saveButton": "Guardar",
|
||||||
|
"saved": "¡Guardado exitosamente!",
|
||||||
|
"errorSaving": "Error al guardar la configuración."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,5 @@
|
|||||||
"unarchiveConfirm": "¿Estás seguro de que deseas desarchivar este proyecto?",
|
"unarchiveConfirm": "¿Estás seguro de que deseas desarchivar este proyecto?",
|
||||||
"clickToFilter": "Clique para filtrar por",
|
"clickToFilter": "Clique para filtrar por",
|
||||||
"noProjects": "No se encontraron proyectos",
|
"noProjects": "No se encontraron proyectos",
|
||||||
"addToFavourites": "Añadir a favoritos",
|
"addToFavourites": "Añadir a favoritos"
|
||||||
"list": "Lista",
|
|
||||||
"group": "Grupo",
|
|
||||||
"listView": "Vista de Lista",
|
|
||||||
"groupView": "Vista de Grupo",
|
|
||||||
"groupBy": {
|
|
||||||
"category": "Categoría",
|
|
||||||
"client": "Cliente"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,5 @@
|
|||||||
"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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"recurring": "Recurrente",
|
"recurring": "Recurrente",
|
||||||
"recurringTaskConfiguration": "Configuración de tarea recurrente",
|
"recurringTaskConfiguration": "Configuración de tarea recurrente",
|
||||||
"repeats": "Repeticiones",
|
"repeats": "Repeticiones",
|
||||||
"daily": "Diario",
|
|
||||||
"weekly": "Semanal",
|
"weekly": "Semanal",
|
||||||
"everyXDays": "Cada X días",
|
"everyXDays": "Cada X días",
|
||||||
"everyXWeeks": "Cada X semanas",
|
"everyXWeeks": "Cada X semanas",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"selectAll": "Seleccionar Todo",
|
"selectAll": "Seleccionar Todo",
|
||||||
"teams": "Equipos",
|
"teams": "Equipos",
|
||||||
|
|
||||||
"searchByProject": "Buscar por nombre del proyecto",
|
"searchByProject": "Buscar por nombre de proyecto",
|
||||||
"projects": "Proyectos",
|
"projects": "Proyectos",
|
||||||
|
|
||||||
"searchByCategory": "Buscar por nombre de categoría",
|
"searchByCategory": "Buscar por nombre de categoría",
|
||||||
@@ -37,21 +37,8 @@
|
|||||||
"actualDays": "Días Reales",
|
"actualDays": "Días Reales",
|
||||||
|
|
||||||
"noCategories": "No se encontraron categorías",
|
"noCategories": "No se encontraron categorías",
|
||||||
"noCategory": "Sin Categoría",
|
"noCategory": "No Categoría",
|
||||||
"noProjects": "No se encontraron proyectos",
|
"noProjects": "No se encontraron proyectos",
|
||||||
"noTeams": "No se encontraron equipos",
|
"noTeams": "No se encontraron equipos",
|
||||||
"noData": "No se encontraron datos",
|
"noData": "No se encontraron datos"
|
||||||
|
|
||||||
"groupBy": "Agrupar por",
|
|
||||||
"groupByCategory": "Categoría",
|
|
||||||
"groupByTeam": "Equipo",
|
|
||||||
"groupByStatus": "Estado",
|
|
||||||
"groupByNone": "Ninguno",
|
|
||||||
"clearSearch": "Limpiar búsqueda",
|
|
||||||
"selectedProjects": "Proyectos Seleccionados",
|
|
||||||
"projectsSelected": "proyectos seleccionados",
|
|
||||||
"showSelected": "Mostrar Solo Seleccionados",
|
|
||||||
"expandAll": "Expandir Todo",
|
|
||||||
"collapseAll": "Contraer Todo",
|
|
||||||
"ungrouped": "Sin Agrupar"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,19 @@
|
|||||||
"owner": "Proprietário da Organização",
|
"owner": "Proprietário da Organização",
|
||||||
"admins": "Administradores da Organização",
|
"admins": "Administradores da Organização",
|
||||||
"contactNumber": "Adicione o Número de Contato",
|
"contactNumber": "Adicione o Número de Contato",
|
||||||
"edit": "Editar"
|
"edit": "Editar",
|
||||||
|
"organizationWorkingDaysAndHours": "Dias e Horas de Trabalho da Organização",
|
||||||
|
"workingDays": "Dias de Trabalho",
|
||||||
|
"workingHours": "Horas de Trabalho",
|
||||||
|
"monday": "Segunda-feira",
|
||||||
|
"tuesday": "Terça-feira",
|
||||||
|
"wednesday": "Quarta-feira",
|
||||||
|
"thursday": "Quinta-feira",
|
||||||
|
"friday": "Sexta-feira",
|
||||||
|
"saturday": "Sábado",
|
||||||
|
"sunday": "Domingo",
|
||||||
|
"hours": "horas",
|
||||||
|
"saveButton": "Salvar",
|
||||||
|
"saved": "Salvo com sucesso!",
|
||||||
|
"errorSaving": "Erro ao salvar as configurações."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,5 @@
|
|||||||
"unarchiveConfirm": "Tem certeza de que deseja desarquivar este projeto?",
|
"unarchiveConfirm": "Tem certeza de que deseja desarquivar este projeto?",
|
||||||
"clickToFilter": "Clique para filtrar por",
|
"clickToFilter": "Clique para filtrar por",
|
||||||
"noProjects": "Nenhum projeto encontrado",
|
"noProjects": "Nenhum projeto encontrado",
|
||||||
"addToFavourites": "Adicionar aos favoritos",
|
"addToFavourites": "Adicionar aos favoritos"
|
||||||
"list": "Lista",
|
|
||||||
"group": "Grupo",
|
|
||||||
"listView": "Visualização em Lista",
|
|
||||||
"groupView": "Visualização em Grupo",
|
|
||||||
"groupBy": {
|
|
||||||
"category": "Categoria",
|
|
||||||
"client": "Cliente"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,5 @@
|
|||||||
"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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"recurring": "Recorrente",
|
"recurring": "Recorrente",
|
||||||
"recurringTaskConfiguration": "Configuração de tarefa recorrente",
|
"recurringTaskConfiguration": "Configuração de tarefa recorrente",
|
||||||
"repeats": "Repete",
|
"repeats": "Repete",
|
||||||
"daily": "Diário",
|
|
||||||
"weekly": "Semanal",
|
"weekly": "Semanal",
|
||||||
"everyXDays": "A cada X dias",
|
"everyXDays": "A cada X dias",
|
||||||
"everyXWeeks": "A cada X semanas",
|
"everyXWeeks": "A cada X semanas",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"timeSheet": "Folha de Tempo",
|
"timeSheet": "Folha de Tempo",
|
||||||
|
|
||||||
"searchByName": "Pesquisar por nome",
|
"searchByName": "Pesquisar por nome",
|
||||||
"selectAll": "Selecionar Tudo",
|
"selectAll": "Selecionar Todos",
|
||||||
"teams": "Equipes",
|
"teams": "Equipes",
|
||||||
|
|
||||||
"searchByProject": "Pesquisar por nome do projeto",
|
"searchByProject": "Pesquisar por nome do projeto",
|
||||||
@@ -13,45 +13,32 @@
|
|||||||
"searchByCategory": "Pesquisar por nome da categoria",
|
"searchByCategory": "Pesquisar por nome da categoria",
|
||||||
"categories": "Categorias",
|
"categories": "Categorias",
|
||||||
|
|
||||||
"billable": "Faturável",
|
"billable": "Cobrável",
|
||||||
"nonBillable": "Não Faturável",
|
"nonBillable": "Não Cobrável",
|
||||||
|
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
|
||||||
"projectsTimeSheet": "Folha de Tempo de Projetos",
|
"projectsTimeSheet": "Folha de Tempo dos Projetos",
|
||||||
|
|
||||||
"loggedTime": "Tempo Registrado(horas)",
|
"loggedTime": "Tempo Registrado (horas)",
|
||||||
|
|
||||||
"exportToExcel": "Exportar para Excel",
|
"exportToExcel": "Exportar para Excel",
|
||||||
"logged": "registrado",
|
"logged": "registrado",
|
||||||
"for": "para",
|
"for": "para",
|
||||||
|
|
||||||
"membersTimeSheet": "Folha de Tempo de Membros",
|
"membersTimeSheet": "Folha de Tempo dos Membros",
|
||||||
"member": "Membro",
|
"member": "Membro",
|
||||||
|
|
||||||
"estimatedVsActual": "Estimado vs Real",
|
"estimatedVsActual": "Estimado vs Real",
|
||||||
"workingDays": "Dias Úteis",
|
"workingDays": "Dias de Trabalho",
|
||||||
"manDays": "Dias Homem",
|
"manDays": "Dias-Homem",
|
||||||
"days": "Dias",
|
"days": "Dias",
|
||||||
"estimatedDays": "Dias Estimados",
|
"estimatedDays": "Dias Estimados",
|
||||||
"actualDays": "Dias Reais",
|
"actualDays": "Dias Reais",
|
||||||
|
|
||||||
"noCategories": "Nenhuma categoria encontrada",
|
"noCategories": "Nenhuma categoria encontrada",
|
||||||
"noCategory": "Sem Categoria",
|
"noCategory": "Nenhuma Categoria",
|
||||||
"noProjects": "Nenhum projeto encontrado",
|
"noProjects": "Nenhum projeto encontrado",
|
||||||
"noTeams": "Nenhuma equipe encontrada",
|
"noTeams": "Nenhum time encontrado",
|
||||||
"noData": "Nenhum dado encontrado",
|
"noData": "Nenhum dado encontrado"
|
||||||
|
|
||||||
"groupBy": "Agrupar por",
|
|
||||||
"groupByCategory": "Categoria",
|
|
||||||
"groupByTeam": "Equipe",
|
|
||||||
"groupByStatus": "Status",
|
|
||||||
"groupByNone": "Nenhum",
|
|
||||||
"clearSearch": "Limpar pesquisa",
|
|
||||||
"selectedProjects": "Projetos Selecionados",
|
|
||||||
"projectsSelected": "projetos selecionados",
|
|
||||||
"showSelected": "Mostrar Apenas Selecionados",
|
|
||||||
"expandAll": "Expandir Tudo",
|
|
||||||
"collapseAll": "Recolher Tudo",
|
|
||||||
"ungrouped": "Não Agrupado"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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';
|
||||||
@@ -36,13 +35,6 @@ 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,36 +4,27 @@ 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';
|
||||||
|
|
||||||
// Store CSRF token in memory (since csrf-sync uses session-based tokens)
|
|
||||||
let csrfToken: string | null = null;
|
|
||||||
|
|
||||||
export const getCsrfToken = (): string | null => {
|
export const getCsrfToken = (): string | null => {
|
||||||
return csrfToken;
|
const match = document.cookie.split('; ').find(cookie => cookie.startsWith('XSRF-TOKEN='));
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return decodeURIComponent(match.split('=')[1]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to refresh CSRF token from server
|
// Function to refresh CSRF token if needed
|
||||||
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
|
||||||
const response = await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
|
await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true });
|
||||||
if (response.data && response.data.token) {
|
return getCsrfToken();
|
||||||
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,
|
||||||
@@ -45,16 +36,12 @@ const apiClient = axios.create({
|
|||||||
|
|
||||||
// Request interceptor
|
// Request interceptor
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
async config => {
|
config => {
|
||||||
// Ensure we have a CSRF token before making requests
|
const token = getCsrfToken();
|
||||||
if (!csrfToken) {
|
if (token) {
|
||||||
await refreshCsrfToken();
|
config.headers['X-CSRF-Token'] = token;
|
||||||
}
|
|
||||||
|
|
||||||
if (csrfToken) {
|
|
||||||
config.headers['X-CSRF-Token'] = csrfToken;
|
|
||||||
} else {
|
} else {
|
||||||
console.warn('No CSRF token available');
|
console.warn('No CSRF token found');
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
@@ -97,7 +84,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...');
|
||||||
|
|
||||||
@@ -107,7 +94,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 apiClient(error.config);
|
return axios(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, refreshCsrfToken } from '../api-client';
|
import { getCsrfToken } from '../api-client';
|
||||||
import config from '@/config/env';
|
import config from '@/config/env';
|
||||||
|
|
||||||
const rootUrl = '/home';
|
const rootUrl = '/home';
|
||||||
@@ -14,18 +14,9 @@ const api = createApi({
|
|||||||
reducerPath: 'homePageApi',
|
reducerPath: 'homePageApi',
|
||||||
baseQuery: fetchBaseQuery({
|
baseQuery: fetchBaseQuery({
|
||||||
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
||||||
prepareHeaders: async headers => {
|
prepareHeaders: headers => {
|
||||||
// Get CSRF token, refresh if needed
|
headers.set('X-CSRF-Token', getCsrfToken() || '');
|
||||||
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',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
|||||||
import { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types';
|
import { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types';
|
||||||
import { IProjectMembersViewModel } from '@/types/projectMember.types';
|
import { IProjectMembersViewModel } from '@/types/projectMember.types';
|
||||||
import { IProjectManager } from '@/types/project/projectManager.types';
|
import { IProjectManager } from '@/types/project/projectManager.types';
|
||||||
import { IGroupedProjectsViewModel } from '@/types/project/groupedProjectsViewModel.types';
|
|
||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/projects`;
|
const rootUrl = `${API_BASE_URL}/projects`;
|
||||||
|
|
||||||
@@ -33,23 +32,6 @@ export const projectsApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getGroupedProjects: async (
|
|
||||||
index: number,
|
|
||||||
size: number,
|
|
||||||
field: string | null,
|
|
||||||
order: string | null,
|
|
||||||
search: string | null,
|
|
||||||
groupBy: string,
|
|
||||||
filter: number | null = null,
|
|
||||||
statuses: string | null = null,
|
|
||||||
categories: string | null = null
|
|
||||||
): Promise<IServerResponse<IGroupedProjectsViewModel>> => {
|
|
||||||
const s = encodeURIComponent(search || '');
|
|
||||||
const url = `${rootUrl}/grouped${toQueryString({ index, size, field, order, search: s, groupBy, filter, statuses, categories })}`;
|
|
||||||
const response = await apiClient.get<IServerResponse<IGroupedProjectsViewModel>>(`${url}`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
getProject: async (id: string): Promise<IServerResponse<IProjectViewModel>> => {
|
getProject: async (id: string): Promise<IServerResponse<IProjectViewModel>> => {
|
||||||
const url = `${rootUrl}/${id}`;
|
const url = `${rootUrl}/${id}`;
|
||||||
const response = await apiClient.get<IServerResponse<IProjectViewModel>>(`${url}`);
|
const response = await apiClient.get<IServerResponse<IProjectViewModel>>(`${url}`);
|
||||||
|
|||||||
@@ -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, refreshCsrfToken } from '../api-client';
|
import { getCsrfToken } from '../api-client';
|
||||||
import config from '@/config/env';
|
import config from '@/config/env';
|
||||||
|
|
||||||
const rootUrl = '/projects';
|
const rootUrl = '/projects';
|
||||||
@@ -14,18 +14,9 @@ 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: async headers => {
|
prepareHeaders: headers => {
|
||||||
// Get CSRF token, refresh if needed
|
headers.set('X-CSRF-Token', getCsrfToken() || '');
|
||||||
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,16 +5,6 @@ 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}`);
|
||||||
@@ -36,11 +26,6 @@ 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}`;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -76,8 +76,6 @@ import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/g
|
|||||||
import homePageApiService from '@/api/home-page/home-page.api.service';
|
import homePageApiService from '@/api/home-page/home-page.api.service';
|
||||||
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||||
|
|
||||||
import projectViewReducer from '@features/project/project-view-slice';
|
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
middleware: getDefaultMiddleware =>
|
middleware: getDefaultMiddleware =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
@@ -116,8 +114,6 @@ export const store = configureStore({
|
|||||||
boardReducer: boardReducer,
|
boardReducer: boardReducer,
|
||||||
projectDrawerReducer: projectDrawerReducer,
|
projectDrawerReducer: projectDrawerReducer,
|
||||||
|
|
||||||
projectViewReducer: projectViewReducer,
|
|
||||||
|
|
||||||
// Project Lookups
|
// Project Lookups
|
||||||
projectCategoriesReducer: projectCategoriesReducer,
|
projectCategoriesReducer: projectCategoriesReducer,
|
||||||
projectStatusesReducer: projectStatusesReducer,
|
projectStatusesReducer: projectStatusesReducer,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type EmptyListPlaceholderProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EmptyListPlaceholder = ({
|
const EmptyListPlaceholder = ({
|
||||||
imageSrc = 'https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp',
|
imageSrc = '/src/assets/images/empty-box.webp',
|
||||||
imageHeight = 60,
|
imageHeight = 60,
|
||||||
text,
|
text,
|
||||||
}: EmptyListPlaceholderProps) => {
|
}: EmptyListPlaceholderProps) => {
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -68,7 +68,7 @@ const AccountStorage = ({ themeMode }: IAccountStorageProps) => {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<div style={{ padding: '0 16px' }}>
|
<div style={{ padding: '0 16px' }}>
|
||||||
<Progress
|
<Progress
|
||||||
percent={billingInfo?.used_percent ?? 0}
|
percent={billingInfo?.usedPercentage ?? 0}
|
||||||
type="circle"
|
type="circle"
|
||||||
format={percent => <span style={{ fontSize: '13px' }}>{percent}% Used</span>}
|
format={percent => <span style={{ fontSize: '13px' }}>{percent}% Used</span>}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { Card, Col, Row, Tooltip, Typography } from 'antd';
|
import { Button, Card, Col, Modal, Row, Tooltip, Typography } from 'antd';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import './current-bill.css';
|
import './current-bill.css';
|
||||||
import { InfoCircleTwoTone } from '@ant-design/icons';
|
import { InfoCircleTwoTone } from '@ant-design/icons';
|
||||||
import ChargesTable from './billing-tables/charges-table';
|
import ChargesTable from './billing-tables/charges-table';
|
||||||
import InvoicesTable from './billing-tables/invoices-table';
|
import InvoicesTable from './billing-tables/invoices-table';
|
||||||
|
import UpgradePlansLKR from './drawers/upgrade-plans-lkr/upgrade-plans-lkr';
|
||||||
|
import UpgradePlans from './drawers/upgrade-plans/upgrade-plans';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { useMediaQuery } from 'react-responsive';
|
import { useMediaQuery } from 'react-responsive';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
toggleDrawer,
|
||||||
|
toggleUpgradeModal,
|
||||||
|
} from '@/features/admin-center/billing/billing.slice';
|
||||||
import { fetchBillingInfo, fetchFreePlanSettings } from '@/features/admin-center/admin-center.slice';
|
import { fetchBillingInfo, fetchFreePlanSettings } from '@/features/admin-center/admin-center.slice';
|
||||||
|
import RedeemCodeDrawer from './drawers/redeem-code-drawer/redeem-code-drawer';
|
||||||
import CurrentPlanDetails from './current-plan-details/current-plan-details';
|
import CurrentPlanDetails from './current-plan-details/current-plan-details';
|
||||||
import AccountStorage from './account-storage/account-storage';
|
import AccountStorage from './account-storage/account-storage';
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
@@ -21,7 +25,9 @@ const CurrentBill: React.FC = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { t } = useTranslation('admin-center/current-bill');
|
const { t } = useTranslation('admin-center/current-bill');
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
|
const { isUpgradeModalOpen } = useAppSelector(state => state.adminCenterReducer);
|
||||||
const isTablet = useMediaQuery({ query: '(min-width: 1025px)' });
|
const isTablet = useMediaQuery({ query: '(min-width: 1025px)' });
|
||||||
|
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
const currentSession = useAuthService().getCurrentSession();
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,7 +46,42 @@ const CurrentBill: React.FC = () => {
|
|||||||
const renderMobileView = () => (
|
const renderMobileView = () => (
|
||||||
<div>
|
<div>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<CurrentPlanDetails />
|
<Card
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
title={<span style={titleStyle}>{t('currentPlanDetails')}</span>}
|
||||||
|
extra={
|
||||||
|
<div style={{ marginTop: '8px', marginRight: '8px' }}>
|
||||||
|
<Button type="primary" onClick={() => dispatch(toggleUpgradeModal())}>
|
||||||
|
{t('upgradePlan')}
|
||||||
|
</Button>
|
||||||
|
<Modal
|
||||||
|
open={isUpgradeModalOpen}
|
||||||
|
onCancel={() => dispatch(toggleUpgradeModal())}
|
||||||
|
width={1000}
|
||||||
|
centered
|
||||||
|
okButtonProps={{ hidden: true }}
|
||||||
|
cancelButtonProps={{ hidden: true }}
|
||||||
|
>
|
||||||
|
{browserTimeZone === 'Asia/Colombo' ? <UpgradePlansLKR /> : <UpgradePlans />}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', width: '50%', padding: '0 12px' }}>
|
||||||
|
<div style={{ marginBottom: '14px' }}>
|
||||||
|
<Typography.Text style={{ fontWeight: 700 }}>{t('cardBodyText01')}</Typography.Text>
|
||||||
|
<Typography.Text>{t('cardBodyText02')}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{ margin: 0, padding: 0, width: '90px' }}
|
||||||
|
onClick={() => dispatch(toggleDrawer())}
|
||||||
|
>
|
||||||
|
{t('redeemCode')}
|
||||||
|
</Button>
|
||||||
|
<RedeemCodeDrawer />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col span={24} style={{ marginTop: '1.5rem' }}>
|
<Col span={24} style={{ marginTop: '1.5rem' }}>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Avatar, Tooltip } from 'antd';
|
import { Avatar, Tooltip } from 'antd';
|
||||||
import React, { useCallback, useMemo } from 'react';
|
|
||||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
|
|
||||||
interface AvatarsProps {
|
interface AvatarsProps {
|
||||||
@@ -7,54 +6,41 @@ interface AvatarsProps {
|
|||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount }) => {
|
const renderAvatar = (member: InlineMember, index: number) => (
|
||||||
const stopPropagation = useCallback((e: React.MouseEvent) => {
|
<Tooltip
|
||||||
e.stopPropagation();
|
key={member.team_member_id || index}
|
||||||
}, []);
|
title={member.end && member.names ? member.names.join(', ') : member.name}
|
||||||
|
>
|
||||||
const renderAvatar = useCallback((member: InlineMember, index: number) => (
|
{member.avatar_url ? (
|
||||||
<Tooltip
|
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||||
key={member.team_member_id || index}
|
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
||||||
title={member.end && member.names ? member.names.join(', ') : member.name}
|
</span>
|
||||||
>
|
) : (
|
||||||
{member.avatar_url ? (
|
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||||
<span onClick={stopPropagation}>
|
<Avatar
|
||||||
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
size={28}
|
||||||
</span>
|
key={member.team_member_id || index}
|
||||||
) : (
|
style={{
|
||||||
<span onClick={stopPropagation}>
|
backgroundColor: member.color_code || '#ececec',
|
||||||
<Avatar
|
fontSize: '14px',
|
||||||
size={28}
|
}}
|
||||||
key={member.team_member_id || index}
|
>
|
||||||
style={{
|
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
|
||||||
backgroundColor: member.color_code || '#ececec',
|
</Avatar>
|
||||||
fontSize: '14px',
|
</span>
|
||||||
}}
|
)}
|
||||||
>
|
</Tooltip>
|
||||||
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
|
);
|
||||||
</Avatar>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
), [stopPropagation]);
|
|
||||||
|
|
||||||
const visibleMembers = useMemo(() => {
|
|
||||||
return maxCount ? members.slice(0, maxCount) : members;
|
|
||||||
}, [members, maxCount]);
|
|
||||||
|
|
||||||
const avatarElements = useMemo(() => {
|
|
||||||
return visibleMembers.map((member, index) => renderAvatar(member, index));
|
|
||||||
}, [visibleMembers, renderAvatar]);
|
|
||||||
|
|
||||||
|
const Avatars: React.FC<AvatarsProps> = ({ members, maxCount }) => {
|
||||||
|
const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
|
||||||
return (
|
return (
|
||||||
<div onClick={stopPropagation}>
|
<div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||||
<Avatar.Group>
|
<Avatar.Group>
|
||||||
{avatarElements}
|
{visibleMembers.map((member, index) => renderAvatar(member, index))}
|
||||||
</Avatar.Group>
|
</Avatar.Group>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
Avatars.displayName = 'Avatars';
|
|
||||||
|
|
||||||
export default Avatars;
|
export default Avatars;
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
.priority-dropdown .ant-dropdown-menu {
|
|
||||||
padding: 0 !important;
|
|
||||||
margin-top: 8px !important;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.priority-dropdown .ant-dropdown-menu-item {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.priority-dropdown-card .ant-card-body {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.priority-menu .ant-menu-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { Flex, Typography } from 'antd';
|
|
||||||
import './priority-section.css';
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
|
||||||
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
|
||||||
import { DoubleLeftOutlined, MinusOutlined, PauseOutlined } from '@ant-design/icons';
|
|
||||||
|
|
||||||
type PrioritySectionProps = {
|
|
||||||
task: IProjectTask;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PrioritySection = ({ task }: PrioritySectionProps) => {
|
|
||||||
const [selectedPriority, setSelectedPriority] = useState<ITaskPriority | undefined>(undefined);
|
|
||||||
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
|
||||||
|
|
||||||
// Update selectedPriority whenever task.priority or priorityList changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!task.priority || !priorityList.length) {
|
|
||||||
setSelectedPriority(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundPriority = priorityList.find(priority => priority.id === task.priority);
|
|
||||||
setSelectedPriority(foundPriority);
|
|
||||||
}, [task.priority, priorityList]);
|
|
||||||
|
|
||||||
const priorityIcon = useMemo(() => {
|
|
||||||
if (!selectedPriority) return null;
|
|
||||||
|
|
||||||
const iconProps = {
|
|
||||||
style: {
|
|
||||||
color: themeMode === 'dark' ? selectedPriority.color_code_dark : selectedPriority.color_code,
|
|
||||||
marginRight: '0.25rem',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (selectedPriority.name) {
|
|
||||||
case 'Low':
|
|
||||||
return <MinusOutlined {...iconProps} />;
|
|
||||||
case 'Medium':
|
|
||||||
return <PauseOutlined {...iconProps} style={{ ...iconProps.style, transform: 'rotate(90deg)' }} />;
|
|
||||||
case 'High':
|
|
||||||
return <DoubleLeftOutlined {...iconProps} style={{ ...iconProps.style, transform: 'rotate(90deg)' }} />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [selectedPriority, themeMode]);
|
|
||||||
|
|
||||||
if (!task.priority || !selectedPriority) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex gap={4}>
|
|
||||||
{priorityIcon}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PrioritySection;
|
|
||||||
@@ -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: false, // Ensure this query runs
|
skip: true // Skip automatic queries entirely
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState<ITaskStatus | undefined>(undefined);
|
const [selectedStatus, setSelectedStatus] = useState<ITaskStatus | undefined>(undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ 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: false
|
skip: true // Skip automatic queries entirely
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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);
|
||||||
|
|
||||||
|
|||||||
132
worklenz-frontend/src/components/project-list/TableColumns.tsx
Normal file
132
worklenz-frontend/src/components/project-list/TableColumns.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||||
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
|
import { ColumnsType } from 'antd/es/table';
|
||||||
|
import { ColumnFilterItem } from 'antd/es/table/interface';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { NavigateFunction } from 'react-router-dom';
|
||||||
|
import Avatars from '../avatars/avatars';
|
||||||
|
import { ActionButtons } from './project-list-table/project-list-actions/project-list-actions';
|
||||||
|
import { CategoryCell } from './project-list-table/project-list-category/project-list-category';
|
||||||
|
import { ProgressListProgress } from './project-list-table/project-list-progress/progress-list-progress';
|
||||||
|
import { ProjectListUpdatedAt } from './project-list-table/project-list-updated-at/project-list-updated';
|
||||||
|
import { ProjectNameCell } from './project-list-table/project-name/project-name-cell';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { ProjectRateCell } from './project-list-table/project-list-favorite/project-rate-cell';
|
||||||
|
|
||||||
|
const createFilters = (items: { id: string; name: string }[]) =>
|
||||||
|
items.map(item => ({ text: item.name, value: item.id })) as ColumnFilterItem[];
|
||||||
|
|
||||||
|
interface ITableColumnsProps {
|
||||||
|
navigate: NavigateFunction;
|
||||||
|
filteredInfo: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableColumns = ({
|
||||||
|
navigate,
|
||||||
|
filteredInfo,
|
||||||
|
}: ITableColumnsProps): ColumnsType<IProjectViewModel> => {
|
||||||
|
const { t } = useTranslation('all-project-list');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||||
|
|
||||||
|
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
|
||||||
|
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
|
||||||
|
const { filteredCategories, filteredStatuses } = useAppSelector(
|
||||||
|
state => state.projectsReducer
|
||||||
|
);
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'favorite',
|
||||||
|
key: 'favorite',
|
||||||
|
render: (text: string, record: IProjectViewModel) => (
|
||||||
|
<ProjectRateCell key={record.id} t={t} record={record} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('name'),
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
defaultSortOrder: 'ascend',
|
||||||
|
render: (text: string, record: IProjectViewModel) => (
|
||||||
|
<ProjectNameCell navigate={navigate} key={record.id} t={t} record={record} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('client'),
|
||||||
|
dataIndex: 'client_name',
|
||||||
|
key: 'client_name',
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('category'),
|
||||||
|
dataIndex: 'category',
|
||||||
|
key: 'category_id',
|
||||||
|
filters: createFilters(
|
||||||
|
projectCategories.map(category => ({ id: category.id || '', name: category.name || '' }))
|
||||||
|
),
|
||||||
|
filteredValue: filteredInfo.category_id || filteredCategories || [],
|
||||||
|
filterMultiple: true,
|
||||||
|
render: (text: string, record: IProjectViewModel) => (
|
||||||
|
<CategoryCell key={record.id} t={t} record={record} />
|
||||||
|
),
|
||||||
|
sorter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('status'),
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status_id',
|
||||||
|
filters: createFilters(
|
||||||
|
projectStatuses.map(status => ({ id: status.id || '', name: status.name || '' }))
|
||||||
|
),
|
||||||
|
filteredValue: filteredInfo.status_id || [],
|
||||||
|
filterMultiple: true,
|
||||||
|
sorter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('tasksProgress'),
|
||||||
|
dataIndex: 'tasksProgress',
|
||||||
|
key: 'tasksProgress',
|
||||||
|
render: (_: string, record: IProjectViewModel) => <ProgressListProgress record={record} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('updated_at'),
|
||||||
|
dataIndex: 'updated_at',
|
||||||
|
key: 'updated_at',
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
render: (_: string, record: IProjectViewModel) => <ProjectListUpdatedAt record={record} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('members'),
|
||||||
|
dataIndex: 'names',
|
||||||
|
key: 'members',
|
||||||
|
render: (members: InlineMember[]) => <Avatars members={members} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'button',
|
||||||
|
dataIndex: '',
|
||||||
|
render: (record: IProjectViewModel) => (
|
||||||
|
<ActionButtons
|
||||||
|
t={t}
|
||||||
|
record={record}
|
||||||
|
dispatch={dispatch}
|
||||||
|
isOwnerOrAdmin={isOwnerOrAdmin}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, projectCategories, projectStatuses, filteredInfo, filteredCategories, filteredStatuses]
|
||||||
|
);
|
||||||
|
return columns as ColumnsType<IProjectViewModel>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TableColumns;
|
||||||
@@ -1,563 +0,0 @@
|
|||||||
import React, { useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
Col,
|
|
||||||
Empty,
|
|
||||||
Row,
|
|
||||||
Skeleton,
|
|
||||||
Typography,
|
|
||||||
Progress,
|
|
||||||
Tooltip,
|
|
||||||
Badge,
|
|
||||||
Space,
|
|
||||||
Avatar,
|
|
||||||
theme,
|
|
||||||
Divider
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
ClockCircleOutlined,
|
|
||||||
TeamOutlined,
|
|
||||||
CheckCircleOutlined,
|
|
||||||
ProjectOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
InboxOutlined,
|
|
||||||
MoreOutlined
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { ProjectGroupListProps } from '@/types/project/project.types';
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
||||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
|
||||||
import {
|
|
||||||
fetchProjectData,
|
|
||||||
setProjectId,
|
|
||||||
toggleProjectDrawer
|
|
||||||
} from '@/features/project/project-drawer.slice';
|
|
||||||
import {
|
|
||||||
toggleArchiveProject,
|
|
||||||
toggleArchiveProjectForAll
|
|
||||||
} from '@/features/projects/projectsSlice';
|
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
|
||||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
|
||||||
import {
|
|
||||||
evt_projects_settings_click,
|
|
||||||
evt_projects_archive,
|
|
||||||
evt_projects_archive_all
|
|
||||||
} from '@/shared/worklenz-analytics-events';
|
|
||||||
import logger from '@/utils/errorLogger';
|
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
|
||||||
|
|
||||||
const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|
||||||
groups,
|
|
||||||
navigate,
|
|
||||||
onProjectSelect,
|
|
||||||
loading,
|
|
||||||
t
|
|
||||||
}) => {
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
|
||||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
|
||||||
|
|
||||||
// Theme-aware color utilities
|
|
||||||
const getThemeAwareColor = (lightColor: string, darkColor: string) => {
|
|
||||||
return themeWiseColor(lightColor, darkColor, themeMode);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Enhanced color processing for better contrast
|
|
||||||
const processColor = (color: string | undefined, fallback?: string) => {
|
|
||||||
if (!color) return fallback || token.colorPrimary;
|
|
||||||
|
|
||||||
if (color.startsWith('#')) {
|
|
||||||
if (themeMode === 'dark') {
|
|
||||||
const hex = color.replace('#', '');
|
|
||||||
const r = parseInt(hex.substr(0, 2), 16);
|
|
||||||
const g = parseInt(hex.substr(2, 2), 16);
|
|
||||||
const b = parseInt(hex.substr(4, 2), 16);
|
|
||||||
|
|
||||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
|
||||||
|
|
||||||
if (brightness < 100) {
|
|
||||||
const factor = 1.5;
|
|
||||||
const newR = Math.min(255, Math.floor(r * factor));
|
|
||||||
const newG = Math.min(255, Math.floor(g * factor));
|
|
||||||
const newB = Math.min(255, Math.floor(b * factor));
|
|
||||||
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const hex = color.replace('#', '');
|
|
||||||
const r = parseInt(hex.substr(0, 2), 16);
|
|
||||||
const g = parseInt(hex.substr(2, 2), 16);
|
|
||||||
const b = parseInt(hex.substr(4, 2), 16);
|
|
||||||
|
|
||||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
|
||||||
|
|
||||||
if (brightness > 200) {
|
|
||||||
const factor = 0.7;
|
|
||||||
const newR = Math.floor(r * factor);
|
|
||||||
const newG = Math.floor(g * factor);
|
|
||||||
const newB = Math.floor(b * factor);
|
|
||||||
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return color;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Action handlers
|
|
||||||
const handleSettingsClick = (e: React.MouseEvent, projectId: string) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
trackMixpanelEvent(evt_projects_settings_click);
|
|
||||||
dispatch(setProjectId(projectId));
|
|
||||||
dispatch(fetchProjectData(projectId));
|
|
||||||
dispatch(toggleProjectDrawer());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleArchiveClick = async (e: React.MouseEvent, projectId: string, isArchived: boolean) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
if (isOwnerOrAdmin) {
|
|
||||||
trackMixpanelEvent(evt_projects_archive_all);
|
|
||||||
await dispatch(toggleArchiveProjectForAll(projectId));
|
|
||||||
} else {
|
|
||||||
trackMixpanelEvent(evt_projects_archive);
|
|
||||||
await dispatch(toggleArchiveProject(projectId));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to archive project:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Memoized styles for better performance
|
|
||||||
const styles = useMemo(() => ({
|
|
||||||
container: {
|
|
||||||
padding: '0',
|
|
||||||
background: 'transparent',
|
|
||||||
},
|
|
||||||
groupSection: {
|
|
||||||
marginBottom: '24px',
|
|
||||||
background: 'transparent',
|
|
||||||
},
|
|
||||||
groupHeader: {
|
|
||||||
background: getThemeAwareColor(
|
|
||||||
`linear-gradient(135deg, ${token.colorFillAlter} 0%, ${token.colorFillTertiary} 100%)`,
|
|
||||||
`linear-gradient(135deg, ${token.colorFillQuaternary} 0%, ${token.colorFillSecondary} 100%)`
|
|
||||||
),
|
|
||||||
borderRadius: token.borderRadius,
|
|
||||||
padding: '12px 16px',
|
|
||||||
marginBottom: '12px',
|
|
||||||
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
|
||||||
boxShadow: getThemeAwareColor(
|
|
||||||
'0 1px 4px rgba(0, 0, 0, 0.06)',
|
|
||||||
'0 1px 4px rgba(0, 0, 0, 0.15)'
|
|
||||||
),
|
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
},
|
|
||||||
groupTitle: {
|
|
||||||
margin: 0,
|
|
||||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: '-0.01em',
|
|
||||||
},
|
|
||||||
groupMeta: {
|
|
||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
|
||||||
fontSize: '12px',
|
|
||||||
marginTop: '2px',
|
|
||||||
},
|
|
||||||
projectCard: {
|
|
||||||
height: '100%',
|
|
||||||
borderRadius: token.borderRadius,
|
|
||||||
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
|
||||||
boxShadow: getThemeAwareColor(
|
|
||||||
'0 1px 4px rgba(0, 0, 0, 0.04)',
|
|
||||||
'0 1px 4px rgba(0, 0, 0, 0.12)'
|
|
||||||
),
|
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
overflow: 'hidden',
|
|
||||||
background: getThemeAwareColor(token.colorBgContainer, token.colorBgElevated),
|
|
||||||
},
|
|
||||||
projectCardHover: {
|
|
||||||
transform: 'translateY(-2px)',
|
|
||||||
boxShadow: getThemeAwareColor(
|
|
||||||
'0 4px 12px rgba(0, 0, 0, 0.08)',
|
|
||||||
'0 4px 12px rgba(0, 0, 0, 0.20)'
|
|
||||||
),
|
|
||||||
borderColor: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
|
||||||
},
|
|
||||||
statusBar: {
|
|
||||||
height: '3px',
|
|
||||||
background: 'linear-gradient(90deg, transparent 0%, currentColor 100%)',
|
|
||||||
borderRadius: '0 0 2px 2px',
|
|
||||||
},
|
|
||||||
projectContent: {
|
|
||||||
padding: '12px',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column' as const,
|
|
||||||
minHeight: '200px', // Ensure minimum height for consistent card sizes
|
|
||||||
},
|
|
||||||
projectTitle: {
|
|
||||||
margin: '0 0 6px 0',
|
|
||||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
lineHeight: 1.3,
|
|
||||||
},
|
|
||||||
clientName: {
|
|
||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
|
||||||
fontSize: '12px',
|
|
||||||
marginBottom: '8px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px',
|
|
||||||
},
|
|
||||||
progressSection: {
|
|
||||||
marginBottom: '10px',
|
|
||||||
// Remove flex: 1 to prevent it from taking all available space
|
|
||||||
},
|
|
||||||
progressLabel: {
|
|
||||||
fontSize: '10px',
|
|
||||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
|
|
||||||
marginBottom: '4px',
|
|
||||||
fontWeight: 500,
|
|
||||||
textTransform: 'uppercase' as const,
|
|
||||||
letterSpacing: '0.3px',
|
|
||||||
},
|
|
||||||
metaGrid: {
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
|
||||||
gap: '8px',
|
|
||||||
marginTop: 'auto', // This pushes the meta section to the bottom
|
|
||||||
paddingTop: '10px',
|
|
||||||
borderTop: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
|
||||||
flexShrink: 0, // Prevent the meta section from shrinking
|
|
||||||
},
|
|
||||||
metaItem: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row' as const,
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: token.borderRadiusSM,
|
|
||||||
background: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
},
|
|
||||||
metaContent: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column' as const,
|
|
||||||
gap: '1px',
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
metaIcon: {
|
|
||||||
fontSize: '12px',
|
|
||||||
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
|
||||||
},
|
|
||||||
metaValue: {
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
|
||||||
lineHeight: 1,
|
|
||||||
},
|
|
||||||
metaLabel: {
|
|
||||||
fontSize: '9px',
|
|
||||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
|
|
||||||
lineHeight: 1,
|
|
||||||
textTransform: 'uppercase' as const,
|
|
||||||
letterSpacing: '0.2px',
|
|
||||||
},
|
|
||||||
actionButtons: {
|
|
||||||
position: 'absolute' as const,
|
|
||||||
top: '8px',
|
|
||||||
right: '8px',
|
|
||||||
display: 'flex',
|
|
||||||
gap: '4px',
|
|
||||||
opacity: 0,
|
|
||||||
transition: 'opacity 0.2s ease',
|
|
||||||
},
|
|
||||||
actionButton: {
|
|
||||||
width: '24px',
|
|
||||||
height: '24px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
border: 'none',
|
|
||||||
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
|
|
||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '12px',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
backdropFilter: 'blur(4px)',
|
|
||||||
'&:hover': {
|
|
||||||
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
|
||||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
|
||||||
transform: 'scale(1.1)',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
emptyState: {
|
|
||||||
padding: '60px 20px',
|
|
||||||
textAlign: 'center' as const,
|
|
||||||
background: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
|
|
||||||
borderRadius: token.borderRadiusLG,
|
|
||||||
border: `2px dashed ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
|
||||||
},
|
|
||||||
loadingContainer: {
|
|
||||||
padding: '40px 20px',
|
|
||||||
}
|
|
||||||
}), [token, themeMode, getThemeAwareColor]);
|
|
||||||
|
|
||||||
// Early return for loading state
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div style={styles.loadingContainer}>
|
|
||||||
<Skeleton active paragraph={{ rows: 6 }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Early return for empty state
|
|
||||||
if (groups.length === 0) {
|
|
||||||
return (
|
|
||||||
<div style={styles.emptyState}>
|
|
||||||
<Empty
|
|
||||||
image={<ProjectOutlined style={{ fontSize: '48px', color: token.colorTextTertiary }} />}
|
|
||||||
description={
|
|
||||||
<div>
|
|
||||||
<Text style={{ fontSize: '16px', color: token.colorTextSecondary }}>
|
|
||||||
{t('noProjects')}
|
|
||||||
</Text>
|
|
||||||
<br />
|
|
||||||
<Text style={{ fontSize: '14px', color: token.colorTextTertiary }}>
|
|
||||||
Create your first project to get started
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderProjectCard = (project: any) => {
|
|
||||||
const projectColor = processColor(project.color_code, token.colorPrimary);
|
|
||||||
const statusColor = processColor(project.status_color, token.colorPrimary);
|
|
||||||
const progress = project.progress || 0;
|
|
||||||
const completedTasks = project.completed_tasks_count || 0;
|
|
||||||
const totalTasks = project.all_tasks_count || 0;
|
|
||||||
const membersCount = project.members_count || 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Col key={project.id} xs={24} sm={12} md={8} lg={6} xl={4}>
|
|
||||||
<Card
|
|
||||||
style={{ ...styles.projectCard, position: 'relative' }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
Object.assign(e.currentTarget.style, styles.projectCardHover);
|
|
||||||
const actionButtons = e.currentTarget.querySelector('.action-buttons') as HTMLElement;
|
|
||||||
if (actionButtons) {
|
|
||||||
actionButtons.style.opacity = '1';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
Object.assign(e.currentTarget.style, styles.projectCard);
|
|
||||||
const actionButtons = e.currentTarget.querySelector('.action-buttons') as HTMLElement;
|
|
||||||
if (actionButtons) {
|
|
||||||
actionButtons.style.opacity = '0';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => onProjectSelect(project.id || '')}
|
|
||||||
bodyStyle={{ padding: 0 }}
|
|
||||||
>
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="action-buttons" style={styles.actionButtons}>
|
|
||||||
<Tooltip title={t('setting')}>
|
|
||||||
<button
|
|
||||||
style={styles.actionButton}
|
|
||||||
onClick={(e) => handleSettingsClick(e, project.id)}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
Object.assign(e.currentTarget.style, {
|
|
||||||
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
|
||||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
|
||||||
transform: 'scale(1.1)',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
Object.assign(e.currentTarget.style, {
|
|
||||||
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
|
|
||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
|
||||||
transform: 'scale(1)',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SettingOutlined />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={project.archived ? t('unarchive') : t('archive')}>
|
|
||||||
<button
|
|
||||||
style={styles.actionButton}
|
|
||||||
onClick={(e) => handleArchiveClick(e, project.id, project.archived)}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
Object.assign(e.currentTarget.style, {
|
|
||||||
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
|
||||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
|
||||||
transform: 'scale(1.1)',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
Object.assign(e.currentTarget.style, {
|
|
||||||
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
|
|
||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
|
||||||
transform: 'scale(1)',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<InboxOutlined />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
{/* Project color indicator bar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
...styles.statusBar,
|
|
||||||
color: projectColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={styles.projectContent}>
|
|
||||||
{/* Project title */}
|
|
||||||
<Title level={5} ellipsis={{ rows: 2, tooltip: project.name }} style={styles.projectTitle}>
|
|
||||||
{project.name}
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
{/* Client name */}
|
|
||||||
{project.client_name && (
|
|
||||||
<div style={styles.clientName}>
|
|
||||||
<UserOutlined />
|
|
||||||
<Text ellipsis style={{ color: 'inherit' }}>
|
|
||||||
{project.client_name}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress section */}
|
|
||||||
<div style={styles.progressSection}>
|
|
||||||
<div style={styles.progressLabel}>
|
|
||||||
Progress
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
percent={progress}
|
|
||||||
size="small"
|
|
||||||
strokeColor={{
|
|
||||||
'0%': projectColor,
|
|
||||||
'100%': statusColor,
|
|
||||||
}}
|
|
||||||
trailColor={getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)}
|
|
||||||
strokeWidth={4}
|
|
||||||
showInfo={false}
|
|
||||||
/>
|
|
||||||
<Text style={{
|
|
||||||
fontSize: '10px',
|
|
||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
|
||||||
marginTop: '2px',
|
|
||||||
display: 'block'
|
|
||||||
}}>
|
|
||||||
{progress}%
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Meta information grid */}
|
|
||||||
<div style={styles.metaGrid}>
|
|
||||||
<Tooltip title="Tasks completed">
|
|
||||||
<div style={styles.metaItem}>
|
|
||||||
<CheckCircleOutlined style={styles.metaIcon} />
|
|
||||||
<div style={styles.metaContent}>
|
|
||||||
<span style={styles.metaValue}>{completedTasks}/{totalTasks}</span>
|
|
||||||
<span style={styles.metaLabel}>Tasks</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip title="Team members">
|
|
||||||
<div style={styles.metaItem}>
|
|
||||||
<TeamOutlined style={styles.metaIcon} />
|
|
||||||
<div style={styles.metaContent}>
|
|
||||||
<span style={styles.metaValue}>{membersCount}</span>
|
|
||||||
<span style={styles.metaLabel}>Members</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={styles.container}>
|
|
||||||
{groups.map((group, groupIndex) => (
|
|
||||||
<div key={group.groupKey} style={styles.groupSection}>
|
|
||||||
{/* Enhanced group header */}
|
|
||||||
<div style={styles.groupHeader}>
|
|
||||||
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
|
|
||||||
<Space align="center">
|
|
||||||
{group.groupColor && (
|
|
||||||
<div style={{
|
|
||||||
width: '16px',
|
|
||||||
height: '16px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: processColor(group.groupColor),
|
|
||||||
flexShrink: 0,
|
|
||||||
border: `2px solid ${getThemeAwareColor('rgba(255,255,255,0.8)', 'rgba(0,0,0,0.3)')}`
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<Title level={4} style={styles.groupTitle}>
|
|
||||||
{group.groupName}
|
|
||||||
</Title>
|
|
||||||
<div style={styles.groupMeta}>
|
|
||||||
{group.projects.length} {group.projects.length === 1 ? 'project' : 'projects'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Badge
|
|
||||||
count={group.projects.length}
|
|
||||||
style={{
|
|
||||||
backgroundColor: processColor(group.groupColor, token.colorPrimary),
|
|
||||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '12px',
|
|
||||||
minWidth: '24px',
|
|
||||||
height: '24px',
|
|
||||||
lineHeight: '22px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
border: `2px solid ${getThemeAwareColor(token.colorBgContainer, token.colorBgElevated)}`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Projects grid */}
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
{group.projects.map(renderProjectCard)}
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{/* Add spacing between groups except for the last one */}
|
|
||||||
{groupIndex < groups.length - 1 && (
|
|
||||||
<Divider style={{
|
|
||||||
margin: '32px 0 0 0',
|
|
||||||
borderColor: getThemeAwareColor(token.colorBorderSecondary, token.colorBorder),
|
|
||||||
opacity: 0.5
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProjectGroupList;
|
|
||||||
@@ -11,7 +11,7 @@ import List from 'antd/es/list';
|
|||||||
import Space from 'antd/es/space';
|
import Space from 'antd/es/space';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { useMemo, useRef, useState, useEffect } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -36,13 +36,6 @@ const LabelsFilterDropdown = () => {
|
|||||||
const tab = searchParams.get('tab');
|
const tab = searchParams.get('tab');
|
||||||
const projectView = tab === 'tasks-list' ? 'list' : 'kanban';
|
const projectView = tab === 'tasks-list' ? 'list' : 'kanban';
|
||||||
|
|
||||||
// Fetch labels when component mounts or projectId changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (projectId) {
|
|
||||||
dispatch(fetchLabelsByProject(projectId));
|
|
||||||
}
|
|
||||||
}, [dispatch, projectId]);
|
|
||||||
|
|
||||||
const filteredLabelData = useMemo(() => {
|
const filteredLabelData = useMemo(() => {
|
||||||
if (projectView === 'list') {
|
if (projectView === 'list') {
|
||||||
return labels.filter(label => label.name?.toLowerCase().includes(searchQuery.toLowerCase()));
|
return labels.filter(label => label.name?.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
@@ -88,6 +81,9 @@ const LabelsFilterDropdown = () => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
labelInputRef.current?.focus();
|
labelInputRef.current?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
|
if (projectView === 'kanban') {
|
||||||
|
dispatch(setBoardLabels(labels));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ const MembersFilterDropdown = () => {
|
|||||||
|
|
||||||
const handleSelectedFiltersCount = useCallback(async (memberId: string | undefined, checked: boolean) => {
|
const handleSelectedFiltersCount = useCallback(async (memberId: string | undefined, checked: boolean) => {
|
||||||
if (!memberId || !projectId) return;
|
if (!memberId || !projectId) return;
|
||||||
|
if (!memberId || !projectId) return;
|
||||||
|
|
||||||
const updateMembers = async (members: Member[], setAction: any, fetchAction: any) => {
|
const updateMembers = async (members: Member[], setAction: any, fetchAction: any) => {
|
||||||
const updatedMembers = members.map(member =>
|
const updatedMembers = members.map(member =>
|
||||||
@@ -141,12 +142,11 @@ const MembersFilterDropdown = () => {
|
|||||||
const handleMembersDropdownOpen = useCallback((open: boolean) => {
|
const handleMembersDropdownOpen = useCallback((open: boolean) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setTimeout(() => membersInputRef.current?.focus(), 0);
|
setTimeout(() => membersInputRef.current?.focus(), 0);
|
||||||
// Only sync the members if board members are empty
|
if (taskAssignees.length) {
|
||||||
if (projectView === 'kanban' && boardTaskAssignees.length === 0 && taskAssignees.length > 0) {
|
|
||||||
dispatch(setBoardMembers(taskAssignees));
|
dispatch(setBoardMembers(taskAssignees));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [dispatch, taskAssignees, boardTaskAssignees, projectView]);
|
}, [dispatch, taskAssignees]);
|
||||||
|
|
||||||
const buttonStyle = {
|
const buttonStyle = {
|
||||||
backgroundColor: selectedCount > 0
|
backgroundColor: selectedCount > 0
|
||||||
|
|||||||
@@ -24,46 +24,44 @@ import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service'
|
|||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice';
|
import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice';
|
||||||
|
|
||||||
|
const repeatOptions: IRepeatOption[] = [
|
||||||
|
{ label: 'Daily', value: ITaskRecurring.Daily },
|
||||||
|
{ label: 'Weekly', value: ITaskRecurring.Weekly },
|
||||||
|
{ label: 'Every X Days', value: ITaskRecurring.EveryXDays },
|
||||||
|
{ label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks },
|
||||||
|
{ label: 'Every X Months', value: ITaskRecurring.EveryXMonths },
|
||||||
|
{ label: 'Monthly', value: ITaskRecurring.Monthly },
|
||||||
|
];
|
||||||
|
|
||||||
|
const daysOfWeek = [
|
||||||
|
{ label: 'Sunday', value: 0, checked: false },
|
||||||
|
{ label: 'Monday', value: 1, checked: false },
|
||||||
|
{ label: 'Tuesday', value: 2, checked: false },
|
||||||
|
{ label: 'Wednesday', value: 3, checked: false },
|
||||||
|
{ label: 'Thursday', value: 4, checked: false },
|
||||||
|
{ label: 'Friday', value: 5, checked: false },
|
||||||
|
{ label: 'Saturday', value: 6, checked: false }
|
||||||
|
];
|
||||||
|
|
||||||
const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1);
|
const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1);
|
||||||
|
const weekOptions = [
|
||||||
|
{ label: 'First', value: 1 },
|
||||||
|
{ label: 'Second', value: 2 },
|
||||||
|
{ label: 'Third', value: 3 },
|
||||||
|
{ label: 'Fourth', value: 4 },
|
||||||
|
{ label: 'Last', value: 5 }
|
||||||
|
];
|
||||||
|
const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value }));
|
||||||
|
|
||||||
const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||||
const { socket, connected } = useSocket();
|
const { socket, connected } = useSocket();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { t } = useTranslation('task-drawer/task-drawer-recurring-config');
|
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 [recurring, setRecurring] = useState(false);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [repeatOption, setRepeatOption] = useState<IRepeatOption>({});
|
const [repeatOption, setRepeatOption] = useState<IRepeatOption>({});
|
||||||
const [selectedDays, setSelectedDays] = useState<number[]>([]);
|
const [selectedDays, setSelectedDays] = useState([]);
|
||||||
const [monthlyOption, setMonthlyOption] = useState('date');
|
const [monthlyOption, setMonthlyOption] = useState('date');
|
||||||
const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1);
|
const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1);
|
||||||
const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value);
|
const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value);
|
||||||
@@ -108,8 +106,8 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
[repeatOption]
|
[repeatOption]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDayCheckboxChange = (checkedValues: number[]) => {
|
const handleDayCheckboxChange = (checkedValues: string[]) => {
|
||||||
setSelectedDays(checkedValues);
|
setSelectedDays(checkedValues as unknown as string[]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSelectedDays = () => {
|
const getSelectedDays = () => {
|
||||||
@@ -167,9 +165,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
|
|
||||||
const res = await taskRecurringApiService.updateTaskRecurringData(task.schedule_id, body);
|
const res = await taskRecurringApiService.updateTaskRecurringData(task.schedule_id, body);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
setRecurring(true);
|
|
||||||
setShowConfig(false);
|
setShowConfig(false);
|
||||||
configVisibleChange(false);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("handleSave", e);
|
logger.error("handleSave", e);
|
||||||
@@ -224,9 +220,9 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|
||||||
if (task) setRecurring(!!task.schedule_id);
|
if (task) setRecurring(!!task.schedule_id);
|
||||||
if (task.schedule_id) void getScheduleData();
|
if (recurring) void getScheduleData();
|
||||||
socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse);
|
socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse);
|
||||||
}, [task?.schedule_id]);
|
}, [task]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -236,11 +232,11 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
|
|
||||||
{recurring && (
|
{recurring && (
|
||||||
<Popover
|
<Popover
|
||||||
title={t('recurringTaskConfiguration')}
|
title="Recurring task configuration"
|
||||||
content={
|
content={
|
||||||
<Skeleton loading={loadingData} active>
|
<Skeleton loading={loadingData} active>
|
||||||
<Form layout="vertical">
|
<Form layout="vertical">
|
||||||
<Form.Item label={t('repeats')}>
|
<Form.Item label="Repeats">
|
||||||
<Select
|
<Select
|
||||||
value={repeatOption.value}
|
value={repeatOption.value}
|
||||||
onChange={val => {
|
onChange={val => {
|
||||||
@@ -255,12 +251,9 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{repeatOption.value === ITaskRecurring.Weekly && (
|
{repeatOption.value === ITaskRecurring.Weekly && (
|
||||||
<Form.Item label={t('selectDaysOfWeek')}>
|
<Form.Item label="Select Days of the Week">
|
||||||
<Checkbox.Group
|
<Checkbox.Group
|
||||||
options={daysOfWeek.map(day => ({
|
options={daysOfWeek}
|
||||||
label: day.label,
|
|
||||||
value: day.value
|
|
||||||
}))}
|
|
||||||
value={selectedDays}
|
value={selectedDays}
|
||||||
onChange={handleDayCheckboxChange}
|
onChange={handleDayCheckboxChange}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
@@ -278,17 +271,17 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
|
|
||||||
{isMonthlySelected && (
|
{isMonthlySelected && (
|
||||||
<>
|
<>
|
||||||
<Form.Item label={t('monthlyRepeatType')}>
|
<Form.Item label="Monthly repeat type">
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
value={monthlyOption}
|
value={monthlyOption}
|
||||||
onChange={e => setMonthlyOption(e.target.value)}
|
onChange={e => setMonthlyOption(e.target.value)}
|
||||||
>
|
>
|
||||||
<Radio.Button value="date">{t('onSpecificDate')}</Radio.Button>
|
<Radio.Button value="date">On a specific date</Radio.Button>
|
||||||
<Radio.Button value="day">{t('onSpecificDay')}</Radio.Button>
|
<Radio.Button value="day">On a specific day</Radio.Button>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{monthlyOption === 'date' && (
|
{monthlyOption === 'date' && (
|
||||||
<Form.Item label={t('dateOfMonth')}>
|
<Form.Item label="Date of the month">
|
||||||
<Select
|
<Select
|
||||||
value={selectedMonthlyDate}
|
value={selectedMonthlyDate}
|
||||||
onChange={setSelectedMonthlyDate}
|
onChange={setSelectedMonthlyDate}
|
||||||
@@ -302,7 +295,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
)}
|
)}
|
||||||
{monthlyOption === 'day' && (
|
{monthlyOption === 'day' && (
|
||||||
<>
|
<>
|
||||||
<Form.Item label={t('weekOfMonth')}>
|
<Form.Item label="Week of the month">
|
||||||
<Select
|
<Select
|
||||||
value={selectedMonthlyWeek}
|
value={selectedMonthlyWeek}
|
||||||
onChange={setSelectedMonthlyWeek}
|
onChange={setSelectedMonthlyWeek}
|
||||||
@@ -310,7 +303,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
style={{ width: 150 }}
|
style={{ width: 150 }}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t('dayOfWeek')}>
|
<Form.Item label="Day of the week">
|
||||||
<Select
|
<Select
|
||||||
value={selectedMonthlyDay}
|
value={selectedMonthlyDay}
|
||||||
onChange={setSelectedMonthlyDay}
|
onChange={setSelectedMonthlyDay}
|
||||||
@@ -324,7 +317,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{repeatOption.value === ITaskRecurring.EveryXDays && (
|
{repeatOption.value === ITaskRecurring.EveryXDays && (
|
||||||
<Form.Item label={t('intervalDays')}>
|
<Form.Item label="Interval (days)">
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={1}
|
min={1}
|
||||||
value={intervalDays}
|
value={intervalDays}
|
||||||
@@ -333,7 +326,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
{repeatOption.value === ITaskRecurring.EveryXWeeks && (
|
{repeatOption.value === ITaskRecurring.EveryXWeeks && (
|
||||||
<Form.Item label={t('intervalWeeks')}>
|
<Form.Item label="Interval (weeks)">
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={1}
|
min={1}
|
||||||
value={intervalWeeks}
|
value={intervalWeeks}
|
||||||
@@ -342,7 +335,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
{repeatOption.value === ITaskRecurring.EveryXMonths && (
|
{repeatOption.value === ITaskRecurring.EveryXMonths && (
|
||||||
<Form.Item label={t('intervalMonths')}>
|
<Form.Item label="Interval (months)">
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={1}
|
min={1}
|
||||||
value={intervalMonths}
|
value={intervalMonths}
|
||||||
@@ -357,7 +350,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
loading={updatingData}
|
loading={updatingData}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
>
|
>
|
||||||
{t('saveChanges')}
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -97,28 +97,30 @@ const InfoTabFooter = () => {
|
|||||||
// mentions options
|
// mentions options
|
||||||
const mentionsOptions =
|
const mentionsOptions =
|
||||||
members?.map(member => ({
|
members?.map(member => ({
|
||||||
value: member.name,
|
value: member.id,
|
||||||
label: member.name,
|
label: member.name,
|
||||||
key: member.id,
|
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => {
|
const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => {
|
||||||
console.log('member', member);
|
console.log('member', member);
|
||||||
if (!member?.value || !member?.label) return;
|
if (!member?.value || !member?.label) return;
|
||||||
|
|
||||||
// Find the member ID from the members list using the name
|
|
||||||
const selectedMember = members.find(m => m.name === member.value);
|
|
||||||
if (!selectedMember) return;
|
|
||||||
|
|
||||||
// Add to selected members if not already present
|
|
||||||
setSelectedMembers(prev =>
|
setSelectedMembers(prev =>
|
||||||
prev.some(mention => mention.team_member_id === selectedMember.id)
|
prev.some(mention => mention.team_member_id === member.value)
|
||||||
? prev
|
? prev
|
||||||
: [...prev, { team_member_id: selectedMember.id!, name: selectedMember.name! }]
|
: [...prev, { team_member_id: member.value, name: member.label }]
|
||||||
);
|
);
|
||||||
}, [members]);
|
|
||||||
|
setCommentValue(prev => {
|
||||||
|
const parts = prev.split('@');
|
||||||
|
const lastPart = parts[parts.length - 1];
|
||||||
|
const mentionText = member.label;
|
||||||
|
// Keep only the part before the @ and add the new mention
|
||||||
|
return prev.slice(0, prev.length - lastPart.length) + mentionText;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCommentChange = useCallback((value: string) => {
|
const handleCommentChange = useCallback((value: string) => {
|
||||||
|
// Only update the value without trying to replace mentions
|
||||||
setCommentValue(value);
|
setCommentValue(value);
|
||||||
setCharacterLength(value.trim().length);
|
setCharacterLength(value.trim().length);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -273,12 +275,6 @@ const InfoTabFooter = () => {
|
|||||||
maxLength={5000}
|
maxLength={5000}
|
||||||
onClick={() => setIsCommentBoxExpand(true)}
|
onClick={() => setIsCommentBoxExpand(true)}
|
||||||
onChange={e => setCharacterLength(e.length)}
|
onChange={e => setCharacterLength(e.length)}
|
||||||
prefix="@"
|
|
||||||
filterOption={(input, option) => {
|
|
||||||
if (!input) return true;
|
|
||||||
const optionLabel = (option as any)?.label || '';
|
|
||||||
return optionLabel.toLowerCase().includes(input.toLowerCase());
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
minHeight: 60,
|
minHeight: 60,
|
||||||
resize: 'none',
|
resize: 'none',
|
||||||
@@ -375,11 +371,7 @@ const InfoTabFooter = () => {
|
|||||||
onSelect={option => memberSelectHandler(option as IMentionMemberSelectOption)}
|
onSelect={option => memberSelectHandler(option as IMentionMemberSelectOption)}
|
||||||
onChange={handleCommentChange}
|
onChange={handleCommentChange}
|
||||||
prefix="@"
|
prefix="@"
|
||||||
filterOption={(input, option) => {
|
split=""
|
||||||
if (!input) return true;
|
|
||||||
const optionLabel = (option as any)?.label || '';
|
|
||||||
return optionLabel.toLowerCase().includes(input.toLowerCase());
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
minHeight: 100,
|
minHeight: 100,
|
||||||
maxHeight: 200,
|
maxHeight: 200,
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progr
|
|||||||
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 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;
|
||||||
@@ -47,6 +46,9 @@ const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps)
|
|||||||
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) {
|
||||||
@@ -57,19 +59,13 @@ const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps)
|
|||||||
// 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 (
|
return <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
|
||||||
<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 (
|
return <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
|
||||||
<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 (
|
return <TaskDrawerProgress task={{...task, sub_tasks_count: hasSubTasks ? 1 : 0}} form={form} />;
|
||||||
<TaskDrawerProgress task={{ ...task, sub_tasks_count: hasSubTasks ? 1 : 0 }} form={form} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -152,13 +148,7 @@ 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
|
<Avatars members={taskFormViewModel?.task?.assignee_names || []} />
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
@@ -170,7 +160,10 @@ 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 task={taskFormViewModel?.task as ITaskViewModel} form={form} />
|
<ConditionalProgressInput
|
||||||
|
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')}>
|
||||||
@@ -183,9 +176,9 @@ 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')}>
|
{/* <Form.Item name="recurring" label={t('taskInfoTab.details.recurring')}>
|
||||||
<TaskDrawerRecurringConfig task={taskFormViewModel?.task as ITaskViewModel} />
|
<TaskDrawerRecurringConfig task={taskFormViewModel?.task as ITaskViewModel} />
|
||||||
</Form.Item>
|
</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} />
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ 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 ?? '');
|
||||||
@@ -89,7 +88,6 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInputBlur = () => {
|
const handleInputBlur = () => {
|
||||||
setIsEditing(false);
|
|
||||||
if (
|
if (
|
||||||
!selectedTaskId ||
|
!selectedTaskId ||
|
||||||
!connected ||
|
!connected ||
|
||||||
@@ -115,39 +113,21 @@ 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%' }}>
|
||||||
{isEditing ? (
|
<Input
|
||||||
<Input
|
ref={inputRef}
|
||||||
ref={inputRef}
|
size="large"
|
||||||
size="large"
|
value={taskName}
|
||||||
value={taskName}
|
onChange={e => onTaskNameChange(e)}
|
||||||
onChange={e => onTaskNameChange(e)}
|
onBlur={handleInputBlur}
|
||||||
onBlur={handleInputBlur}
|
placeholder={t('taskHeader.taskNamePlaceholder')}
|
||||||
placeholder={t('taskHeader.taskNamePlaceholder')}
|
className="task-name-input"
|
||||||
className="task-name-input"
|
style={{
|
||||||
style={{
|
width: '100%',
|
||||||
width: '100%',
|
border: 'none',
|
||||||
border: 'none',
|
}}
|
||||||
}}
|
showCount={false}
|
||||||
showCount={true}
|
maxLength={250}
|
||||||
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 { useMemo } from 'react';
|
import { useEffect, 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 } = useSocket();
|
const { socket, connected } = 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();
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ const TaskListBulkActionsBar = () => {
|
|||||||
placement="top"
|
placement="top"
|
||||||
arrow
|
arrow
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
destroyOnHidden
|
destroyPopupOnHide
|
||||||
onOpenChange={value => {
|
onOpenChange={value => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
setSelectedLabels([]);
|
setSelectedLabels([]);
|
||||||
|
|||||||
@@ -459,24 +459,10 @@ const boardSlice = createSlice({
|
|||||||
const { body, sectionId, taskId } = action.payload;
|
const { body, sectionId, taskId } = action.payload;
|
||||||
const section = state.taskGroups.find(sec => sec.id === sectionId);
|
const section = state.taskGroups.find(sec => sec.id === sectionId);
|
||||||
if (section) {
|
if (section) {
|
||||||
// First try to find the task in main tasks
|
const task = section.tasks.find(task => task.id === taskId);
|
||||||
const mainTask = section.tasks.find(task => task.id === taskId);
|
if (task) {
|
||||||
if (mainTask) {
|
task.assignees = body.assignees;
|
||||||
mainTask.assignees = body.assignees;
|
task.names = body.names;
|
||||||
mainTask.names = body.names;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not found in main tasks, look in subtasks
|
|
||||||
for (const parentTask of section.tasks) {
|
|
||||||
if (!parentTask.sub_tasks) continue;
|
|
||||||
|
|
||||||
const subtask = parentTask.sub_tasks.find(st => st.id === taskId);
|
|
||||||
if (subtask) {
|
|
||||||
subtask.assignees = body.assignees;
|
|
||||||
subtask.names = body.names;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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';
|
||||||
@@ -21,8 +22,6 @@ 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';
|
|
||||||
import HelpButton from './help/HelpButton';
|
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const [current, setCurrent] = useState<string>('home');
|
const [current, setCurrent] = useState<string>('home');
|
||||||
@@ -91,7 +90,6 @@ const Navbar = () => {
|
|||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<Col
|
<Col
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -103,6 +101,14 @@ 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%',
|
||||||
@@ -146,7 +152,6 @@ const Navbar = () => {
|
|||||||
<Flex align="center">
|
<Flex align="center">
|
||||||
<SwitchTeamButton />
|
<SwitchTeamButton />
|
||||||
<NotificationButton />
|
<NotificationButton />
|
||||||
{/* <TimerButton /> */}
|
|
||||||
<HelpButton />
|
<HelpButton />
|
||||||
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
|
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,379 +0,0 @@
|
|||||||
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 [error, setError] = useState<string | null>(null);
|
|
||||||
const { t } = useTranslation('navbar');
|
|
||||||
const { token } = useToken();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const { socket } = useSocket();
|
|
||||||
|
|
||||||
const logError = (message: string, error?: any) => {
|
|
||||||
// Production-safe error logging
|
|
||||||
console.error(`[TimerButton] ${message}`, error);
|
|
||||||
setError(message);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchRunningTimers = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await taskTimeLogsApiService.getRunningTimers();
|
|
||||||
|
|
||||||
if (response && response.done) {
|
|
||||||
const timers = Array.isArray(response.body) ? response.body : [];
|
|
||||||
setRunningTimers(timers);
|
|
||||||
} else {
|
|
||||||
logError('Invalid response from getRunningTimers API');
|
|
||||||
setRunningTimers([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error fetching running timers', error);
|
|
||||||
setRunningTimers([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateCurrentTimes = useCallback(() => {
|
|
||||||
try {
|
|
||||||
if (!Array.isArray(runningTimers) || runningTimers.length === 0) return;
|
|
||||||
|
|
||||||
const newTimes: Record<string, string> = {};
|
|
||||||
runningTimers.forEach(timer => {
|
|
||||||
try {
|
|
||||||
if (!timer || !timer.task_id || !timer.start_time) return;
|
|
||||||
|
|
||||||
const startTime = moment(timer.start_time);
|
|
||||||
if (!startTime.isValid()) {
|
|
||||||
logError(`Invalid start time for timer ${timer.task_id}: ${timer.start_time}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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')}`;
|
|
||||||
} catch (error) {
|
|
||||||
logError(`Error updating time for timer ${timer?.task_id}`, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setCurrentTimes(newTimes);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error in updateCurrentTimes', error);
|
|
||||||
}
|
|
||||||
}, [runningTimers]);
|
|
||||||
|
|
||||||
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, updateCurrentTimes]);
|
|
||||||
|
|
||||||
// Listen for timer start/stop events and project updates to refresh the count
|
|
||||||
useEffect(() => {
|
|
||||||
if (!socket) {
|
|
||||||
logError('Socket not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTimerStart = (data: string) => {
|
|
||||||
try {
|
|
||||||
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
|
||||||
const { id } = parsed || {};
|
|
||||||
if (id) {
|
|
||||||
// Refresh the running timers list when a new timer is started
|
|
||||||
fetchRunningTimers();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error parsing timer start event', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTimerStop = (data: string) => {
|
|
||||||
try {
|
|
||||||
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
|
||||||
const { id } = parsed || {};
|
|
||||||
if (id) {
|
|
||||||
// Refresh the running timers list when a timer is stopped
|
|
||||||
fetchRunningTimers();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error parsing timer stop event', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProjectUpdates = () => {
|
|
||||||
try {
|
|
||||||
// Refresh timers when project updates are available
|
|
||||||
fetchRunningTimers();
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error handling project updates', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
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 () => {
|
|
||||||
try {
|
|
||||||
socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart);
|
|
||||||
socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop);
|
|
||||||
socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error cleaning up socket listeners', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error setting up socket listeners', error);
|
|
||||||
}
|
|
||||||
}, [socket, fetchRunningTimers]);
|
|
||||||
|
|
||||||
const hasRunningTimers = () => {
|
|
||||||
return Array.isArray(runningTimers) && runningTimers.length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const timerCount = () => {
|
|
||||||
return Array.isArray(runningTimers) ? runningTimers.length : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStopTimer = (taskId: string) => {
|
|
||||||
if (!socket) {
|
|
||||||
logError('Socket not available for stopping timer');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!taskId) {
|
|
||||||
logError('Invalid task ID for stopping timer');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId }));
|
|
||||||
dispatch(updateTaskTimeTracking({ taskId, timeTracking: null }));
|
|
||||||
} catch (error) {
|
|
||||||
logError(`Error stopping timer for task ${taskId}`, error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderDropdownContent = () => {
|
|
||||||
try {
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 16, textAlign: 'center', width: 350 }}>
|
|
||||||
<Text type="danger">Error loading timers</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 350,
|
|
||||||
maxHeight: 400,
|
|
||||||
overflow: 'auto',
|
|
||||||
backgroundColor: token.colorBgElevated,
|
|
||||||
borderRadius: token.borderRadius,
|
|
||||||
boxShadow: token.boxShadowSecondary,
|
|
||||||
border: `1px solid ${token.colorBorderSecondary}`
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!Array.isArray(runningTimers) || runningTimers.length === 0 ? (
|
|
||||||
<div style={{ padding: 16, textAlign: 'center' }}>
|
|
||||||
<Text type="secondary">No running timers</Text>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<List
|
|
||||||
dataSource={runningTimers}
|
|
||||||
renderItem={(timer) => {
|
|
||||||
if (!timer || !timer.task_id) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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 || 'Unnamed Task'}
|
|
||||||
</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 || 'Unnamed Project'}
|
|
||||||
</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: {timer.start_time ? 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>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{hasRunningTimers() && (
|
|
||||||
<>
|
|
||||||
<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 }}>
|
|
||||||
{timerCount()} timer{timerCount() !== 1 ? 's' : ''} running
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error rendering dropdown content', error);
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 16, textAlign: 'center', width: 350 }}>
|
|
||||||
<Text type="danger">Error rendering timers</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDropdownOpenChange = (open: boolean) => {
|
|
||||||
try {
|
|
||||||
setDropdownOpen(open);
|
|
||||||
if (open) {
|
|
||||||
fetchRunningTimers();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error handling dropdown open change', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
popupRender={() => renderDropdownContent()}
|
|
||||||
trigger={['click']}
|
|
||||||
placement="bottomRight"
|
|
||||||
open={dropdownOpen}
|
|
||||||
onOpenChange={handleDropdownOpenChange}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Error rendering TimerButton', error);
|
|
||||||
return (
|
|
||||||
<Tooltip title="Timer Error">
|
|
||||||
<Button
|
|
||||||
style={{ height: '62px', width: '60px' }}
|
|
||||||
type="text"
|
|
||||||
icon={<ClockCircleOutlined style={{ fontSize: 20 }} />}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TimerButton;
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
|
||||||
import { ProjectGroupBy, ProjectViewType } from '@/types/project/project.types';
|
|
||||||
|
|
||||||
interface ProjectViewState {
|
|
||||||
mode: ProjectViewType;
|
|
||||||
groupBy: ProjectGroupBy;
|
|
||||||
lastUpdated?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOCAL_STORAGE_KEY = 'project_view_preferences';
|
|
||||||
|
|
||||||
const loadInitialState = (): ProjectViewState => {
|
|
||||||
const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
|
|
||||||
return saved
|
|
||||||
? JSON.parse(saved)
|
|
||||||
: {
|
|
||||||
mode: ProjectViewType.LIST,
|
|
||||||
groupBy: ProjectGroupBy.CATEGORY,
|
|
||||||
lastUpdated: new Date().toISOString()
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialState: ProjectViewState = loadInitialState();
|
|
||||||
|
|
||||||
export const projectViewSlice = createSlice({
|
|
||||||
name: 'projectView',
|
|
||||||
initialState,
|
|
||||||
reducers: {
|
|
||||||
setViewMode: (state, action: PayloadAction<ProjectViewType>) => {
|
|
||||||
state.mode = action.payload;
|
|
||||||
state.lastUpdated = new Date().toISOString();
|
|
||||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state));
|
|
||||||
},
|
|
||||||
setGroupBy: (state, action: PayloadAction<ProjectGroupBy>) => {
|
|
||||||
state.groupBy = action.payload;
|
|
||||||
state.lastUpdated = new Date().toISOString();
|
|
||||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state));
|
|
||||||
},
|
|
||||||
resetViewState: () => {
|
|
||||||
localStorage.removeItem(LOCAL_STORAGE_KEY);
|
|
||||||
return loadInitialState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const { setViewMode, setGroupBy, resetViewState } = projectViewSlice.actions;
|
|
||||||
export default projectViewSlice.reducer;
|
|
||||||
@@ -5,17 +5,12 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
|||||||
import { IProjectCategory } from '@/types/project/projectCategory.types';
|
import { IProjectCategory } from '@/types/project/projectCategory.types';
|
||||||
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
|
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
|
||||||
import { IProjectManager } from '@/types/project/projectManager.types';
|
import { IProjectManager } from '@/types/project/projectManager.types';
|
||||||
import { IGroupedProjectsViewModel } from '@/types/project/groupedProjectsViewModel.types';
|
|
||||||
|
|
||||||
interface ProjectState {
|
interface ProjectState {
|
||||||
projects: {
|
projects: {
|
||||||
data: IProjectViewModel[];
|
data: IProjectViewModel[];
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
groupedProjects: {
|
|
||||||
data: IGroupedProjectsViewModel | null;
|
|
||||||
loading: boolean;
|
|
||||||
};
|
|
||||||
categories: IProjectCategory[];
|
categories: IProjectCategory[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
creatingProject: boolean;
|
creatingProject: boolean;
|
||||||
@@ -34,17 +29,6 @@ interface ProjectState {
|
|||||||
statuses: string | null;
|
statuses: string | null;
|
||||||
categories: string | null;
|
categories: string | null;
|
||||||
};
|
};
|
||||||
groupedRequestParams: {
|
|
||||||
index: number;
|
|
||||||
size: number;
|
|
||||||
field: string;
|
|
||||||
order: string;
|
|
||||||
search: string;
|
|
||||||
groupBy: string;
|
|
||||||
filter: number;
|
|
||||||
statuses: string | null;
|
|
||||||
categories: string | null;
|
|
||||||
};
|
|
||||||
projectManagers: IProjectManager[];
|
projectManagers: IProjectManager[];
|
||||||
projectManagersLoading: boolean;
|
projectManagersLoading: boolean;
|
||||||
}
|
}
|
||||||
@@ -54,10 +38,6 @@ const initialState: ProjectState = {
|
|||||||
data: [],
|
data: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
},
|
},
|
||||||
groupedProjects: {
|
|
||||||
data: null,
|
|
||||||
loading: false,
|
|
||||||
},
|
|
||||||
categories: [],
|
categories: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
creatingProject: false,
|
creatingProject: false,
|
||||||
@@ -76,17 +56,6 @@ const initialState: ProjectState = {
|
|||||||
statuses: null,
|
statuses: null,
|
||||||
categories: null,
|
categories: null,
|
||||||
},
|
},
|
||||||
groupedRequestParams: {
|
|
||||||
index: 1,
|
|
||||||
size: DEFAULT_PAGE_SIZE,
|
|
||||||
field: 'name',
|
|
||||||
order: 'ascend',
|
|
||||||
search: '',
|
|
||||||
groupBy: '',
|
|
||||||
filter: 0,
|
|
||||||
statuses: null,
|
|
||||||
categories: null,
|
|
||||||
},
|
|
||||||
projectManagers: [],
|
projectManagers: [],
|
||||||
projectManagersLoading: false,
|
projectManagersLoading: false,
|
||||||
};
|
};
|
||||||
@@ -129,46 +98,6 @@ export const fetchProjects = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create async thunk for fetching grouped projects
|
|
||||||
export const fetchGroupedProjects = createAsyncThunk(
|
|
||||||
'projects/fetchGroupedProjects',
|
|
||||||
async (
|
|
||||||
params: {
|
|
||||||
index: number;
|
|
||||||
size: number;
|
|
||||||
field: string;
|
|
||||||
order: string;
|
|
||||||
search: string;
|
|
||||||
groupBy: string;
|
|
||||||
filter: number;
|
|
||||||
statuses: string | null;
|
|
||||||
categories: string | null;
|
|
||||||
},
|
|
||||||
{ rejectWithValue }
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const groupedProjectsResponse = await projectsApiService.getGroupedProjects(
|
|
||||||
params.index,
|
|
||||||
params.size,
|
|
||||||
params.field,
|
|
||||||
params.order,
|
|
||||||
params.search,
|
|
||||||
params.groupBy,
|
|
||||||
params.filter,
|
|
||||||
params.statuses,
|
|
||||||
params.categories
|
|
||||||
);
|
|
||||||
return groupedProjectsResponse.body;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Fetch Grouped Projects', error);
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return rejectWithValue(error.message);
|
|
||||||
}
|
|
||||||
return rejectWithValue('Failed to fetch grouped projects');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const toggleFavoriteProject = createAsyncThunk(
|
export const toggleFavoriteProject = createAsyncThunk(
|
||||||
'projects/toggleFavoriteProject',
|
'projects/toggleFavoriteProject',
|
||||||
async (id: string, { rejectWithValue }) => {
|
async (id: string, { rejectWithValue }) => {
|
||||||
@@ -202,7 +131,7 @@ export const createProject = createAsyncThunk(
|
|||||||
export const updateProject = createAsyncThunk(
|
export const updateProject = createAsyncThunk(
|
||||||
'projects/updateProject',
|
'projects/updateProject',
|
||||||
async ({ id, project }: { id: string; project: IProjectViewModel }, { rejectWithValue }) => {
|
async ({ id, project }: { id: string; project: IProjectViewModel }, { rejectWithValue }) => {
|
||||||
const response = await projectsApiService.updateProject({ id, ...project });
|
const response = await projectsApiService.updateProject(id, project);
|
||||||
return response.body;
|
return response.body;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -267,12 +196,6 @@ const projectSlice = createSlice({
|
|||||||
...action.payload,
|
...action.payload,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
setGroupedRequestParams: (state, action: PayloadAction<Partial<ProjectState['groupedRequestParams']>>) => {
|
|
||||||
state.groupedRequestParams = {
|
|
||||||
...state.groupedRequestParams,
|
|
||||||
...action.payload,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder
|
builder
|
||||||
@@ -290,16 +213,6 @@ const projectSlice = createSlice({
|
|||||||
.addCase(fetchProjects.rejected, state => {
|
.addCase(fetchProjects.rejected, state => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
})
|
})
|
||||||
.addCase(fetchGroupedProjects.pending, state => {
|
|
||||||
state.groupedProjects.loading = true;
|
|
||||||
})
|
|
||||||
.addCase(fetchGroupedProjects.fulfilled, (state, action) => {
|
|
||||||
state.groupedProjects.loading = false;
|
|
||||||
state.groupedProjects.data = action.payload;
|
|
||||||
})
|
|
||||||
.addCase(fetchGroupedProjects.rejected, state => {
|
|
||||||
state.groupedProjects.loading = false;
|
|
||||||
})
|
|
||||||
.addCase(createProject.pending, state => {
|
.addCase(createProject.pending, state => {
|
||||||
state.creatingProject = true;
|
state.creatingProject = true;
|
||||||
})
|
})
|
||||||
@@ -335,6 +248,5 @@ export const {
|
|||||||
setFilteredCategories,
|
setFilteredCategories,
|
||||||
setFilteredStatuses,
|
setFilteredStatuses,
|
||||||
setRequestParams,
|
setRequestParams,
|
||||||
setGroupedRequestParams,
|
|
||||||
} = projectSlice.actions;
|
} = projectSlice.actions;
|
||||||
export default projectSlice.reducer;
|
export default projectSlice.reducer;
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
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:
|
font-family: -apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial,
|
||||||
-apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial, "Noto Sans",
|
"Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||||
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !important;
|
"Noto Color Emoji" !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* helper classes */
|
/* helper classes */
|
||||||
@@ -145,4 +145,3 @@ 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 HubSpot from '@/components/HubSpot';
|
import TawkTo from '@/components/TawkTo';
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
@@ -68,6 +68,9 @@ 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',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EditOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons';
|
import { EditOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons';
|
||||||
import { PageHeader } from '@ant-design/pro-components';
|
import { PageHeader } from '@ant-design/pro-components';
|
||||||
import { Button, Card, Input, Space, Tooltip, Typography } from 'antd';
|
import { Button, Card, Input, Space, Tooltip, Typography, Checkbox, Col, Form, Row, message } from 'antd';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import OrganizationAdminsTable from '@/components/admin-center/overview/organization-admins-table/organization-admins-table';
|
import OrganizationAdminsTable from '@/components/admin-center/overview/organization-admins-table/organization-admins-table';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
@@ -12,6 +12,8 @@ import { adminCenterApiService } from '@/api/admin-center/admin-center.api.servi
|
|||||||
import { IOrganization, IOrganizationAdmin } from '@/types/admin-center/admin-center.types';
|
import { IOrganization, IOrganizationAdmin } from '@/types/admin-center/admin-center.types';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { tr } from 'date-fns/locale';
|
import { tr } from 'date-fns/locale';
|
||||||
|
import { scheduleAPIService } from '@/api/schedule/schedule.api.service';
|
||||||
|
import { Settings } from '@/types/schedule/schedule-v2.types';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -19,6 +21,10 @@ const Overview: React.FC = () => {
|
|||||||
const [organization, setOrganization] = useState<IOrganization | null>(null);
|
const [organization, setOrganization] = useState<IOrganization | null>(null);
|
||||||
const [organizationAdmins, setOrganizationAdmins] = useState<IOrganizationAdmin[] | null>(null);
|
const [organizationAdmins, setOrganizationAdmins] = useState<IOrganizationAdmin[] | null>(null);
|
||||||
const [loadingAdmins, setLoadingAdmins] = useState(false);
|
const [loadingAdmins, setLoadingAdmins] = useState(false);
|
||||||
|
const [workingDays, setWorkingDays] = useState<Settings['workingDays']>([]);
|
||||||
|
const [workingHours, setWorkingHours] = useState<Settings['workingHours']>(8);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
||||||
const { t } = useTranslation('admin-center/overview');
|
const { t } = useTranslation('admin-center/overview');
|
||||||
@@ -34,6 +40,19 @@ const Overview: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getOrgWorkingSettings = async () => {
|
||||||
|
try {
|
||||||
|
const res = await scheduleAPIService.fetchScheduleSettings();
|
||||||
|
if (res && res.done) {
|
||||||
|
setWorkingDays(res.body.workingDays || ['Monday','Tuesday','Wednesday','Thursday','Friday']);
|
||||||
|
setWorkingHours(res.body.workingHours || 8);
|
||||||
|
form.setFieldsValue({ workingDays: res.body.workingDays || ['Monday','Tuesday','Wednesday','Thursday','Friday'], workingHours: res.body.workingHours || 8 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting organization working settings', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getOrganizationAdmins = async () => {
|
const getOrganizationAdmins = async () => {
|
||||||
setLoadingAdmins(true);
|
setLoadingAdmins(true);
|
||||||
try {
|
try {
|
||||||
@@ -48,8 +67,30 @@ const Overview: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSave = async (values: any) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await scheduleAPIService.updateScheduleSettings({
|
||||||
|
workingDays: values.workingDays,
|
||||||
|
workingHours: values.workingHours,
|
||||||
|
});
|
||||||
|
if (res && res.done) {
|
||||||
|
message.success(t('saved'));
|
||||||
|
setWorkingDays(values.workingDays);
|
||||||
|
setWorkingHours(values.workingHours);
|
||||||
|
getOrgWorkingSettings();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating organization working days/hours', error);
|
||||||
|
message.error(t('errorSaving'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getOrganizationDetails();
|
getOrganizationDetails();
|
||||||
|
getOrgWorkingSettings();
|
||||||
getOrganizationAdmins();
|
getOrganizationAdmins();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -72,6 +113,37 @@ const Overview: React.FC = () => {
|
|||||||
refetch={getOrganizationDetails}
|
refetch={getOrganizationDetails}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Typography.Title level={5} style={{ margin: 0 }}>{t('organizationWorkingDaysAndHours') || 'Organization Working Days & Hours'}</Typography.Title>
|
||||||
|
<Form
|
||||||
|
layout="vertical"
|
||||||
|
form={form}
|
||||||
|
initialValues={{ workingDays, workingHours }}
|
||||||
|
onFinish={handleSave}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Form.Item label={t('workingDays')} name="workingDays">
|
||||||
|
<Checkbox.Group>
|
||||||
|
<Row>
|
||||||
|
<Col span={8}><Checkbox value="Monday">{t('monday')}</Checkbox></Col>
|
||||||
|
<Col span={8}><Checkbox value="Tuesday">{t('tuesday')}</Checkbox></Col>
|
||||||
|
<Col span={8}><Checkbox value="Wednesday">{t('wednesday')}</Checkbox></Col>
|
||||||
|
<Col span={8}><Checkbox value="Thursday">{t('thursday')}</Checkbox></Col>
|
||||||
|
<Col span={8}><Checkbox value="Friday">{t('friday')}</Checkbox></Col>
|
||||||
|
<Col span={8}><Checkbox value="Saturday">{t('saturday')}</Checkbox></Col>
|
||||||
|
<Col span={8}><Checkbox value="Sunday">{t('sunday')}</Checkbox></Col>
|
||||||
|
</Row>
|
||||||
|
</Checkbox.Group>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('workingHours')} name="workingHours">
|
||||||
|
<Input type="number" min={1} max={24} suffix={t('hours')} width={100} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving}>{t('saveButton') || 'Save'}</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||||
{t('admins')}
|
{t('admins')}
|
||||||
|
|||||||
@@ -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://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
image="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||||
imageStyle={{ height: 60 }}
|
imageStyle={{ height: 60 }}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -57,31 +57,20 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const calculateEndDate = (dueDate: string): string | undefined => {
|
const calculateEndDate = (dueDate: string): Date | undefined => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
let targetDate: Date;
|
|
||||||
|
|
||||||
switch (dueDate) {
|
switch (dueDate) {
|
||||||
case 'Today':
|
case 'Today':
|
||||||
targetDate = new Date(today);
|
return today;
|
||||||
break;
|
|
||||||
case 'Tomorrow':
|
case 'Tomorrow':
|
||||||
targetDate = new Date(today);
|
return new Date(today.setDate(today.getDate() + 1));
|
||||||
targetDate.setDate(today.getDate() + 1);
|
|
||||||
break;
|
|
||||||
case 'Next Week':
|
case 'Next Week':
|
||||||
targetDate = new Date(today);
|
return new Date(today.setDate(today.getDate() + 7));
|
||||||
targetDate.setDate(today.getDate() + 7);
|
|
||||||
break;
|
|
||||||
case 'Next Month':
|
case 'Next Month':
|
||||||
targetDate = new Date(today);
|
return new Date(today.setMonth(today.getMonth() + 1));
|
||||||
targetDate.setMonth(today.getMonth() + 1);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return targetDate.toISOString().split('T')[0]; // Returns YYYY-MM-DD format
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectOptions = [
|
const projectOptions = [
|
||||||
@@ -93,16 +82,12 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const handleTaskSubmit = (values: { name: string; project: string; dueDate: string }) => {
|
const handleTaskSubmit = (values: { name: string; project: string; dueDate: string }) => {
|
||||||
const endDate = calendarView
|
const newTask: IHomeTaskCreateRequest = {
|
||||||
? homeTasksConfig.selected_date?.format('YYYY-MM-DD')
|
|
||||||
: calculateEndDate(values.dueDate);
|
|
||||||
|
|
||||||
const newTask = {
|
|
||||||
name: values.name,
|
name: values.name,
|
||||||
project_id: values.project,
|
project_id: values.project,
|
||||||
reporter_id: currentSession?.id,
|
reporter_id: currentSession?.id,
|
||||||
team_id: currentSession?.team_id,
|
team_id: currentSession?.team_id,
|
||||||
end_date: endDate || new Date().toISOString().split('T')[0], // Fallback to today if undefined
|
end_date: (calendarView ? homeTasksConfig.selected_date?.format('YYYY-MM-DD') : calculateEndDate(values.dueDate)),
|
||||||
};
|
};
|
||||||
|
|
||||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(newTask));
|
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(newTask));
|
||||||
|
|||||||
@@ -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://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||||
text=" No tasks to show."
|
text=" No tasks to show."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -271,7 +271,7 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
imageSrc="/src/assets/images/empty-box.webp"
|
||||||
text={t('home:todoList.noTasks')}
|
text={t('home:todoList.noTasks')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -25,84 +25,3 @@
|
|||||||
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-bottom > div > .ant-tabs-nav::before {
|
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-bottom > div > .ant-tabs-nav::before {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-group-container {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-group {
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-group-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-color-indicator {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-stats {
|
|
||||||
margin-left: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-card {
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-card .ant-card-cover {
|
|
||||||
height: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-status-bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-card-content {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-title {
|
|
||||||
margin-bottom: 8px !important;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-client {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-progress {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-meta span {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-status-tag {
|
|
||||||
margin-top: 8px;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
@@ -1,44 +1,26 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ProjectViewType, ProjectGroupBy } from '@/types/project/project.types';
|
|
||||||
import { setViewMode, setGroupBy } from '@features/project/project-view-slice';
|
|
||||||
import debounce from 'lodash/debounce';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Empty,
|
Empty,
|
||||||
Flex,
|
Flex,
|
||||||
Input,
|
Input,
|
||||||
Pagination,
|
|
||||||
Segmented,
|
Segmented,
|
||||||
Select,
|
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Table,
|
Table,
|
||||||
TablePaginationConfig,
|
TablePaginationConfig,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { PageHeader } from '@ant-design/pro-components';
|
import { PageHeader } from '@ant-design/pro-components';
|
||||||
import {
|
import { SearchOutlined, SyncOutlined } from '@ant-design/icons';
|
||||||
SearchOutlined,
|
|
||||||
SyncOutlined,
|
|
||||||
UnorderedListOutlined,
|
|
||||||
AppstoreOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||||
|
|
||||||
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
||||||
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
||||||
import { ColumnsType } from 'antd/es/table';
|
import TableColumns from '@/components/project-list/TableColumns';
|
||||||
import { ColumnFilterItem } from 'antd/es/table/interface';
|
|
||||||
import Avatars from '@/components/avatars/avatars';
|
|
||||||
import { ActionButtons } from '@/components/project-list/project-list-table/project-list-actions/project-list-actions';
|
|
||||||
import { CategoryCell } from '@/components/project-list/project-list-table/project-list-category/project-list-category';
|
|
||||||
import { ProgressListProgress } from '@/components/project-list/project-list-table/project-list-progress/progress-list-progress';
|
|
||||||
import { ProjectListUpdatedAt } from '@/components/project-list/project-list-table/project-list-updated-at/project-list-updated';
|
|
||||||
import { ProjectNameCell } from '@/components/project-list/project-list-table/project-name/project-name-cell';
|
|
||||||
import { ProjectRateCell } from '@/components/project-list/project-list-table/project-list-favorite/project-rate-cell';
|
|
||||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
|
||||||
|
|
||||||
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
|
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
|
||||||
|
|
||||||
@@ -61,8 +43,6 @@ import {
|
|||||||
setFilteredCategories,
|
setFilteredCategories,
|
||||||
setFilteredStatuses,
|
setFilteredStatuses,
|
||||||
setRequestParams,
|
setRequestParams,
|
||||||
setGroupedRequestParams,
|
|
||||||
fetchGroupedProjects,
|
|
||||||
} from '@/features/projects/projectsSlice';
|
} from '@/features/projects/projectsSlice';
|
||||||
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
|
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
|
||||||
import { fetchProjectCategories } from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
|
import { fetchProjectCategories } from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
|
||||||
@@ -70,22 +50,12 @@ import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/pr
|
|||||||
import { setProjectId, setStatuses } from '@/features/project/project.slice';
|
import { setProjectId, setStatuses } from '@/features/project/project.slice';
|
||||||
import { setProject } from '@/features/project/project.slice';
|
import { setProject } from '@/features/project/project.slice';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import { evt_projects_page_visit, evt_projects_refresh_click, evt_projects_search } from '@/shared/worklenz-analytics-events';
|
||||||
evt_projects_page_visit,
|
|
||||||
evt_projects_refresh_click,
|
|
||||||
evt_projects_search,
|
|
||||||
} from '@/shared/worklenz-analytics-events';
|
|
||||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
import ProjectGroupList from '@/components/project-list/project-group/project-group-list';
|
|
||||||
import { groupProjects } from '@/utils/project-group';
|
|
||||||
|
|
||||||
const createFilters = (items: { id: string; name: string }[]) =>
|
|
||||||
items.map(item => ({ text: item.name, value: item.id })) as ColumnFilterItem[];
|
|
||||||
|
|
||||||
const ProjectList: React.FC = () => {
|
const ProjectList: React.FC = () => {
|
||||||
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
|
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const { t } = useTranslation('all-project-list');
|
const { t } = useTranslation('all-project-list');
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -93,23 +63,6 @@ const ProjectList: React.FC = () => {
|
|||||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||||
|
|
||||||
// Get view state from Redux
|
|
||||||
const { mode: viewMode, groupBy } = useAppSelector((state) => state.projectViewReducer);
|
|
||||||
const { requestParams, groupedRequestParams, groupedProjects } = useAppSelector(state => state.projectsReducer);
|
|
||||||
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
|
|
||||||
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
|
|
||||||
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
|
|
||||||
const { filteredCategories, filteredStatuses } = useAppSelector(
|
|
||||||
state => state.projectsReducer
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: projectsData,
|
|
||||||
isLoading: loadingProjects,
|
|
||||||
isFetching: isFetchingProjects,
|
|
||||||
refetch: refetchProjects,
|
|
||||||
} = useGetProjectsQuery(requestParams);
|
|
||||||
|
|
||||||
const getFilterIndex = useCallback(() => {
|
const getFilterIndex = useCallback(() => {
|
||||||
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
|
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -123,139 +76,42 @@ const ProjectList: React.FC = () => {
|
|||||||
localStorage.setItem(PROJECT_SORT_ORDER, order);
|
localStorage.setItem(PROJECT_SORT_ORDER, order);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const { requestParams } = useAppSelector(state => state.projectsReducer);
|
||||||
|
|
||||||
|
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
|
||||||
|
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
|
||||||
|
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: projectsData,
|
||||||
|
isLoading: loadingProjects,
|
||||||
|
isFetching: isFetchingProjects,
|
||||||
|
refetch: refetchProjects,
|
||||||
|
} = useGetProjectsQuery(requestParams);
|
||||||
|
|
||||||
const filters = useMemo(() => Object.values(IProjectFilter), []);
|
const filters = useMemo(() => Object.values(IProjectFilter), []);
|
||||||
|
|
||||||
|
// Create translated segment options for the filters
|
||||||
const segmentOptions = useMemo(() => {
|
const segmentOptions = useMemo(() => {
|
||||||
return filters.map(filter => ({
|
return filters.map(filter => ({
|
||||||
value: filter,
|
value: filter,
|
||||||
label: t(filter.toLowerCase()),
|
label: t(filter.toLowerCase())
|
||||||
}));
|
}));
|
||||||
}, [filters, t]);
|
}, [filters, t]);
|
||||||
|
|
||||||
const viewToggleOptions = useMemo(
|
useEffect(() => {
|
||||||
() => [
|
setIsLoading(loadingProjects || isFetchingProjects);
|
||||||
{
|
}, [loadingProjects, isFetchingProjects]);
|
||||||
value: ProjectViewType.LIST,
|
|
||||||
label: (
|
|
||||||
<Tooltip title={t('listView')}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<UnorderedListOutlined />
|
|
||||||
<span>{t('list')}</span>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: ProjectViewType.GROUP,
|
|
||||||
label: (
|
|
||||||
<Tooltip title={t('groupView')}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<AppstoreOutlined />
|
|
||||||
<span>{t('group')}</span>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const groupByOptions = useMemo(
|
useEffect(() => {
|
||||||
() => [
|
const filterIndex = getFilterIndex();
|
||||||
{
|
dispatch(setRequestParams({ filter: filterIndex }));
|
||||||
value: ProjectGroupBy.CATEGORY,
|
}, [dispatch, getFilterIndex]);
|
||||||
label: t('groupBy.category'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: ProjectGroupBy.CLIENT,
|
|
||||||
label: t('groupBy.client'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize category filters to prevent unnecessary recalculations
|
useEffect(() => {
|
||||||
const categoryFilters = useMemo(() =>
|
trackMixpanelEvent(evt_projects_page_visit);
|
||||||
createFilters(
|
refetchProjects();
|
||||||
projectCategories.map(category => ({ id: category.id || '', name: category.name || '' }))
|
}, [requestParams, refetchProjects]);
|
||||||
),
|
|
||||||
[projectCategories]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize status filters to prevent unnecessary recalculations
|
|
||||||
const statusFilters = useMemo(() =>
|
|
||||||
createFilters(
|
|
||||||
projectStatuses.map(status => ({ id: status.id || '', name: status.name || '' }))
|
|
||||||
),
|
|
||||||
[projectStatuses]
|
|
||||||
);
|
|
||||||
|
|
||||||
const paginationConfig = useMemo(
|
|
||||||
() => ({
|
|
||||||
current: requestParams.index,
|
|
||||||
pageSize: requestParams.size,
|
|
||||||
showSizeChanger: true,
|
|
||||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
|
||||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
|
||||||
size: 'small' as const,
|
|
||||||
total: projectsData?.body?.total,
|
|
||||||
}),
|
|
||||||
[requestParams.index, requestParams.size, projectsData?.body?.total]
|
|
||||||
);
|
|
||||||
|
|
||||||
const groupedPaginationConfig = useMemo(
|
|
||||||
() => ({
|
|
||||||
current: groupedRequestParams.index,
|
|
||||||
pageSize: groupedRequestParams.size,
|
|
||||||
showSizeChanger: true,
|
|
||||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
|
||||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
|
||||||
size: 'small' as const,
|
|
||||||
total: groupedProjects.data?.total_groups || 0,
|
|
||||||
}),
|
|
||||||
[groupedRequestParams.index, groupedRequestParams.size, groupedProjects.data?.total_groups]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize the project count calculation for the header
|
|
||||||
const projectCount = useMemo(() => {
|
|
||||||
if (viewMode === ProjectViewType.LIST) {
|
|
||||||
return projectsData?.body?.total || 0;
|
|
||||||
} else {
|
|
||||||
return groupedProjects.data?.data?.reduce((total, group) => total + group.project_count, 0) || 0;
|
|
||||||
}
|
|
||||||
}, [viewMode, projectsData?.body?.total, groupedProjects.data?.data]);
|
|
||||||
|
|
||||||
// Memoize the grouped projects data transformation
|
|
||||||
const transformedGroupedProjects = useMemo(() => {
|
|
||||||
return groupedProjects.data?.data?.map(group => ({
|
|
||||||
groupKey: group.group_key,
|
|
||||||
groupName: group.group_name,
|
|
||||||
groupColor: group.group_color,
|
|
||||||
projects: group.projects,
|
|
||||||
count: group.project_count,
|
|
||||||
totalProgress: 0,
|
|
||||||
totalTasks: 0
|
|
||||||
})) || [];
|
|
||||||
}, [groupedProjects.data?.data]);
|
|
||||||
|
|
||||||
// Memoize the table data source
|
|
||||||
const tableDataSource = useMemo(() =>
|
|
||||||
projectsData?.body?.data || [],
|
|
||||||
[projectsData?.body?.data]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize the empty text component
|
|
||||||
const emptyText = useMemo(() =>
|
|
||||||
<Empty description={t('noProjects')} />,
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize the pagination show total function
|
|
||||||
const paginationShowTotal = useMemo(() =>
|
|
||||||
(total: number, range: [number, number]) =>
|
|
||||||
`${range[0]}-${range[1]} of ${total} groups`,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTableChange = useCallback(
|
const handleTableChange = useCallback(
|
||||||
(
|
(
|
||||||
@@ -268,6 +124,7 @@ const ProjectList: React.FC = () => {
|
|||||||
newParams.statuses = null;
|
newParams.statuses = null;
|
||||||
dispatch(setFilteredStatuses([]));
|
dispatch(setFilteredStatuses([]));
|
||||||
} else {
|
} else {
|
||||||
|
// dispatch(setFilteredStatuses(filters.status_id as Array<string>));
|
||||||
newParams.statuses = filters.status_id.join(' ');
|
newParams.statuses = filters.status_id.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,6 +132,7 @@ const ProjectList: React.FC = () => {
|
|||||||
newParams.categories = null;
|
newParams.categories = null;
|
||||||
dispatch(setFilteredCategories([]));
|
dispatch(setFilteredCategories([]));
|
||||||
} else {
|
} else {
|
||||||
|
// dispatch(setFilteredCategories(filters.category_id as Array<string>));
|
||||||
newParams.categories = filters.category_id.join(' ');
|
newParams.categories = filters.category_id.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,289 +149,66 @@ const ProjectList: React.FC = () => {
|
|||||||
newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
|
newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
dispatch(setRequestParams(newParams));
|
dispatch(setRequestParams(newParams));
|
||||||
|
|
||||||
// Also update grouped request params to keep them in sync
|
|
||||||
dispatch(setGroupedRequestParams({
|
|
||||||
...groupedRequestParams,
|
|
||||||
statuses: newParams.statuses,
|
|
||||||
categories: newParams.categories,
|
|
||||||
order: newParams.order,
|
|
||||||
field: newParams.field,
|
|
||||||
index: newParams.index,
|
|
||||||
size: newParams.size,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setFilteredInfo(filters);
|
setFilteredInfo(filters);
|
||||||
},
|
},
|
||||||
[dispatch, setSortingValues, groupedRequestParams]
|
[setSortingValues]
|
||||||
);
|
|
||||||
|
|
||||||
const handleGroupedTableChange = useCallback(
|
|
||||||
(newPagination: TablePaginationConfig) => {
|
|
||||||
const newParams: Partial<typeof groupedRequestParams> = {
|
|
||||||
index: newPagination.current || 1,
|
|
||||||
size: newPagination.pageSize || DEFAULT_PAGE_SIZE,
|
|
||||||
};
|
|
||||||
dispatch(setGroupedRequestParams(newParams));
|
|
||||||
},
|
|
||||||
[dispatch, groupedRequestParams]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
trackMixpanelEvent(evt_projects_refresh_click);
|
trackMixpanelEvent(evt_projects_refresh_click);
|
||||||
if (viewMode === ProjectViewType.LIST) {
|
refetchProjects();
|
||||||
refetchProjects();
|
}, [refetchProjects, requestParams]);
|
||||||
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
|
|
||||||
dispatch(fetchGroupedProjects(groupedRequestParams));
|
|
||||||
}
|
|
||||||
}, [trackMixpanelEvent, refetchProjects, viewMode, groupBy, dispatch, groupedRequestParams]);
|
|
||||||
|
|
||||||
const handleSegmentChange = useCallback(
|
const handleSegmentChange = useCallback(
|
||||||
(value: IProjectFilter) => {
|
(value: IProjectFilter) => {
|
||||||
const newFilterIndex = filters.indexOf(value);
|
const newFilterIndex = filters.indexOf(value);
|
||||||
setFilterIndex(newFilterIndex);
|
setFilterIndex(newFilterIndex);
|
||||||
|
|
||||||
// Update both request params for consistency
|
|
||||||
dispatch(setRequestParams({ filter: newFilterIndex }));
|
dispatch(setRequestParams({ filter: newFilterIndex }));
|
||||||
dispatch(setGroupedRequestParams({
|
refetchProjects();
|
||||||
...groupedRequestParams,
|
|
||||||
filter: newFilterIndex,
|
|
||||||
index: 1 // Reset to first page when changing filter
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Refresh data based on current view mode
|
|
||||||
if (viewMode === ProjectViewType.LIST) {
|
|
||||||
refetchProjects();
|
|
||||||
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
|
|
||||||
dispatch(fetchGroupedProjects({
|
|
||||||
...groupedRequestParams,
|
|
||||||
filter: newFilterIndex,
|
|
||||||
index: 1
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[filters, setFilterIndex, dispatch, refetchProjects, viewMode, groupBy, groupedRequestParams]
|
[filters, setFilterIndex, refetchProjects]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Debounced search for grouped projects
|
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const debouncedGroupedSearch = useCallback(
|
trackMixpanelEvent(evt_projects_search);
|
||||||
debounce((params: typeof groupedRequestParams) => {
|
const value = e.target.value;
|
||||||
if (groupBy) {
|
dispatch(setRequestParams({ search: value }));
|
||||||
dispatch(fetchGroupedProjects(params));
|
}, []);
|
||||||
}
|
|
||||||
}, 300),
|
const paginationConfig = useMemo(
|
||||||
[dispatch, groupBy]
|
() => ({
|
||||||
|
current: requestParams.index,
|
||||||
|
pageSize: requestParams.size,
|
||||||
|
showSizeChanger: true,
|
||||||
|
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||||
|
size: 'small' as const,
|
||||||
|
total: projectsData?.body?.total,
|
||||||
|
}),
|
||||||
|
[requestParams.index, requestParams.size, projectsData?.body?.total]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSearchChange = useCallback(
|
const handleDrawerClose = () => {
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const searchValue = e.target.value;
|
|
||||||
trackMixpanelEvent(evt_projects_search);
|
|
||||||
|
|
||||||
// Update both request params for consistency
|
|
||||||
dispatch(setRequestParams({ search: searchValue, index: 1 }));
|
|
||||||
|
|
||||||
if (viewMode === ProjectViewType.GROUP) {
|
|
||||||
const newGroupedParams = {
|
|
||||||
...groupedRequestParams,
|
|
||||||
search: searchValue,
|
|
||||||
index: 1
|
|
||||||
};
|
|
||||||
dispatch(setGroupedRequestParams(newGroupedParams));
|
|
||||||
|
|
||||||
// Trigger debounced search in group mode
|
|
||||||
debouncedGroupedSearch(newGroupedParams);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[dispatch, trackMixpanelEvent, viewMode, groupedRequestParams, debouncedGroupedSearch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleViewToggle = useCallback(
|
|
||||||
(value: ProjectViewType) => {
|
|
||||||
dispatch(setViewMode(value));
|
|
||||||
if (value === ProjectViewType.GROUP) {
|
|
||||||
// Initialize grouped request params when switching to group view
|
|
||||||
const newGroupedParams = {
|
|
||||||
...groupedRequestParams,
|
|
||||||
groupBy: groupBy || ProjectGroupBy.CATEGORY,
|
|
||||||
search: requestParams.search,
|
|
||||||
filter: requestParams.filter,
|
|
||||||
statuses: requestParams.statuses,
|
|
||||||
categories: requestParams.categories,
|
|
||||||
};
|
|
||||||
dispatch(setGroupedRequestParams(newGroupedParams));
|
|
||||||
// Fetch grouped data immediately
|
|
||||||
dispatch(fetchGroupedProjects(newGroupedParams));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[dispatch, groupBy, groupedRequestParams, requestParams]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleGroupByChange = useCallback(
|
|
||||||
(value: ProjectGroupBy) => {
|
|
||||||
dispatch(setGroupBy(value));
|
|
||||||
const newGroupedParams = {
|
|
||||||
...groupedRequestParams,
|
|
||||||
groupBy: value,
|
|
||||||
index: 1, // Reset to first page when changing grouping
|
|
||||||
};
|
|
||||||
dispatch(setGroupedRequestParams(newGroupedParams));
|
|
||||||
// Fetch new grouped data
|
|
||||||
dispatch(fetchGroupedProjects(newGroupedParams));
|
|
||||||
},
|
|
||||||
[dispatch, groupedRequestParams]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDrawerClose = useCallback(() => {
|
|
||||||
dispatch(setProject({} as IProjectViewModel));
|
dispatch(setProject({} as IProjectViewModel));
|
||||||
dispatch(setProjectId(null));
|
dispatch(setProjectId(null));
|
||||||
}, [dispatch]);
|
};
|
||||||
|
const navigateToProject = (project_id: string | undefined, default_view: string | undefined) => {
|
||||||
const navigateToProject = useCallback((project_id: string | undefined, default_view: string | undefined) => {
|
|
||||||
if (project_id) {
|
if (project_id) {
|
||||||
navigate(
|
navigate(`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`); // Update the route as per your project structure
|
||||||
`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [navigate]);
|
};
|
||||||
|
|
||||||
// Define table columns directly in the component to avoid hooks order issues
|
|
||||||
const tableColumns: ColumnsType<IProjectViewModel> = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
dataIndex: 'favorite',
|
|
||||||
key: 'favorite',
|
|
||||||
render: (text: string, record: IProjectViewModel) => (
|
|
||||||
<ProjectRateCell key={record.id} t={t} record={record} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('name'),
|
|
||||||
dataIndex: 'name',
|
|
||||||
key: 'name',
|
|
||||||
sorter: true,
|
|
||||||
showSorterTooltip: false,
|
|
||||||
defaultSortOrder: 'ascend',
|
|
||||||
render: (text: string, record: IProjectViewModel) => (
|
|
||||||
<ProjectNameCell navigate={navigate} key={record.id} t={t} record={record} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('client'),
|
|
||||||
dataIndex: 'client_name',
|
|
||||||
key: 'client_name',
|
|
||||||
sorter: true,
|
|
||||||
showSorterTooltip: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('category'),
|
|
||||||
dataIndex: 'category',
|
|
||||||
key: 'category_id',
|
|
||||||
filters: categoryFilters,
|
|
||||||
filteredValue: filteredInfo.category_id || filteredCategories || [],
|
|
||||||
filterMultiple: true,
|
|
||||||
render: (text: string, record: IProjectViewModel) => (
|
|
||||||
<CategoryCell key={record.id} t={t} record={record} />
|
|
||||||
),
|
|
||||||
sorter: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('status'),
|
|
||||||
dataIndex: 'status',
|
|
||||||
key: 'status_id',
|
|
||||||
filters: statusFilters,
|
|
||||||
filteredValue: filteredInfo.status_id || [],
|
|
||||||
filterMultiple: true,
|
|
||||||
sorter: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('tasksProgress'),
|
|
||||||
dataIndex: 'tasksProgress',
|
|
||||||
key: 'tasksProgress',
|
|
||||||
render: (_: string, record: IProjectViewModel) => <ProgressListProgress record={record} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('updated_at'),
|
|
||||||
dataIndex: 'updated_at',
|
|
||||||
key: 'updated_at',
|
|
||||||
sorter: true,
|
|
||||||
showSorterTooltip: false,
|
|
||||||
render: (_: string, record: IProjectViewModel) => <ProjectListUpdatedAt record={record} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('members'),
|
|
||||||
dataIndex: 'names',
|
|
||||||
key: 'members',
|
|
||||||
render: (members: InlineMember[]) => <Avatars members={members} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
key: 'button',
|
|
||||||
dataIndex: '',
|
|
||||||
render: (record: IProjectViewModel) => (
|
|
||||||
<ActionButtons
|
|
||||||
t={t}
|
|
||||||
record={record}
|
|
||||||
dispatch={dispatch}
|
|
||||||
isOwnerOrAdmin={isOwnerOrAdmin}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t, categoryFilters, statusFilters, filteredInfo, filteredCategories, filteredStatuses, navigate, dispatch, isOwnerOrAdmin]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (viewMode === ProjectViewType.LIST) {
|
|
||||||
setIsLoading(loadingProjects || isFetchingProjects);
|
|
||||||
} else {
|
|
||||||
setIsLoading(groupedProjects.loading);
|
|
||||||
}
|
|
||||||
}, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const filterIndex = getFilterIndex();
|
|
||||||
dispatch(setRequestParams({ filter: filterIndex }));
|
|
||||||
// Also sync with grouped request params on initial load
|
|
||||||
dispatch(setGroupedRequestParams({
|
|
||||||
filter: filterIndex,
|
|
||||||
index: 1,
|
|
||||||
size: DEFAULT_PAGE_SIZE,
|
|
||||||
field: 'name',
|
|
||||||
order: 'ascend',
|
|
||||||
search: '',
|
|
||||||
groupBy: '',
|
|
||||||
statuses: null,
|
|
||||||
categories: null,
|
|
||||||
}));
|
|
||||||
}, [dispatch, getFilterIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
trackMixpanelEvent(evt_projects_page_visit);
|
|
||||||
if (viewMode === ProjectViewType.LIST) {
|
|
||||||
refetchProjects();
|
|
||||||
}
|
|
||||||
}, [requestParams, refetchProjects, trackMixpanelEvent, viewMode]);
|
|
||||||
|
|
||||||
// Separate useEffect for grouped projects
|
|
||||||
useEffect(() => {
|
|
||||||
if (viewMode === ProjectViewType.GROUP && groupBy) {
|
|
||||||
dispatch(fetchGroupedProjects(groupedRequestParams));
|
|
||||||
}
|
|
||||||
}, [dispatch, viewMode, groupBy, groupedRequestParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
|
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
|
||||||
if (projectCategories.length === 0) dispatch(fetchProjectCategories());
|
if (projectCategories.length === 0) dispatch(fetchProjectCategories());
|
||||||
if (projectHealths.length === 0) dispatch(fetchProjectHealth());
|
if (projectHealths.length === 0) dispatch(fetchProjectHealth());
|
||||||
}, [dispatch, projectStatuses.length, projectCategories.length, projectHealths.length]);
|
}, [requestParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
className="site-page-header"
|
className="site-page-header"
|
||||||
title={`${projectCount} ${t('projects')}`}
|
title={`${projectsData?.body?.total || 0} ${t('projects')}`}
|
||||||
style={{ padding: '16px 0' }}
|
style={{ padding: '16px 0' }}
|
||||||
extra={
|
extra={
|
||||||
<Flex gap={8} align="center">
|
<Flex gap={8} align="center">
|
||||||
@@ -590,19 +225,6 @@ const ProjectList: React.FC = () => {
|
|||||||
defaultValue={filters[getFilterIndex()] ?? filters[0]}
|
defaultValue={filters[getFilterIndex()] ?? filters[0]}
|
||||||
onChange={handleSegmentChange}
|
onChange={handleSegmentChange}
|
||||||
/>
|
/>
|
||||||
<Segmented
|
|
||||||
options={viewToggleOptions}
|
|
||||||
value={viewMode}
|
|
||||||
onChange={handleViewToggle}
|
|
||||||
/>
|
|
||||||
{viewMode === ProjectViewType.GROUP && (
|
|
||||||
<Select
|
|
||||||
value={groupBy}
|
|
||||||
onChange={handleGroupByChange}
|
|
||||||
options={groupByOptions}
|
|
||||||
style={{ width: 150 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('placeholder')}
|
placeholder={t('placeholder')}
|
||||||
suffix={<SearchOutlined />}
|
suffix={<SearchOutlined />}
|
||||||
@@ -616,44 +238,25 @@ const ProjectList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Card className="project-card">
|
<Card className="project-card">
|
||||||
<Skeleton active loading={isLoading} className="mt-4 p-4">
|
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||||
{viewMode === ProjectViewType.LIST ? (
|
<Table<IProjectViewModel>
|
||||||
<Table<IProjectViewModel>
|
columns={TableColumns({
|
||||||
columns={tableColumns}
|
navigate,
|
||||||
dataSource={tableDataSource}
|
filteredInfo,
|
||||||
rowKey={record => record.id || ''}
|
})}
|
||||||
loading={loadingProjects}
|
dataSource={projectsData?.body?.data || []}
|
||||||
size="small"
|
rowKey={record => record.id || ''}
|
||||||
onChange={handleTableChange}
|
loading={loadingProjects}
|
||||||
pagination={paginationConfig}
|
size="small"
|
||||||
locale={{ emptyText }}
|
onChange={handleTableChange}
|
||||||
onRow={record => ({
|
pagination={paginationConfig}
|
||||||
onClick: () => navigateToProject(record.id, record.team_member_default_view),
|
locale={{ emptyText: <Empty description={t('noProjects')} /> }}
|
||||||
})}
|
onRow={record => ({
|
||||||
/>
|
onClick: () => navigateToProject(record.id, record.team_member_default_view), // Navigate to project on row click
|
||||||
) : (
|
})}
|
||||||
<div>
|
/>
|
||||||
<ProjectGroupList
|
|
||||||
groups={transformedGroupedProjects}
|
|
||||||
navigate={navigate}
|
|
||||||
onProjectSelect={id => navigateToProject(id, undefined)}
|
|
||||||
onArchive={() => {}}
|
|
||||||
isOwnerOrAdmin={isOwnerOrAdmin}
|
|
||||||
loading={groupedProjects.loading}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
{!groupedProjects.loading && groupedProjects.data?.data && groupedProjects.data.data.length > 0 && (
|
|
||||||
<div style={{ marginTop: '24px', textAlign: 'center' }}>
|
|
||||||
<Pagination
|
|
||||||
{...groupedPaginationConfig}
|
|
||||||
onChange={(page, pageSize) => handleGroupedTableChange({ current: page, pageSize })}
|
|
||||||
showTotal={paginationShowTotal}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{createPortal(<ProjectDrawer onClose={handleDrawerClose} />, document.body, 'project-drawer')}
|
{createPortal(<ProjectDrawer onClose={handleDrawerClose} />, document.body, 'project-drawer')}
|
||||||
|
|||||||
@@ -1,110 +1,12 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { Checkbox, Flex, Tag, Tooltip } from 'antd';
|
import { Checkbox, Flex, Tag, Tooltip } from 'antd';
|
||||||
import { HolderOutlined } from '@ant-design/icons';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
DragEndEvent,
|
|
||||||
DragStartEvent,
|
|
||||||
DragOverlay,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
KeyboardSensor,
|
|
||||||
TouchSensor,
|
|
||||||
UniqueIdentifier,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
useSortable,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
||||||
import { useSocket } from '@/socket/socketContext';
|
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
|
||||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
|
||||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
|
||||||
import { reorderTasks } from '@/features/tasks/tasks.slice';
|
|
||||||
import { evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
|
|
||||||
|
|
||||||
// Draggable Row Component
|
|
||||||
interface DraggableRowProps {
|
|
||||||
task: IProjectTask;
|
|
||||||
visibleColumns: Array<{ key: string; width: number }>;
|
|
||||||
renderCell: (columnKey: string | number, task: IProjectTask, isSubtask?: boolean) => React.ReactNode;
|
|
||||||
hoverRow: string | null;
|
|
||||||
onRowHover: (taskId: string | null) => void;
|
|
||||||
isSubtask?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DraggableRow = ({
|
|
||||||
task,
|
|
||||||
visibleColumns,
|
|
||||||
renderCell,
|
|
||||||
hoverRow,
|
|
||||||
onRowHover,
|
|
||||||
isSubtask = false
|
|
||||||
}: DraggableRowProps) => {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({
|
|
||||||
id: task.id as UniqueIdentifier,
|
|
||||||
data: {
|
|
||||||
type: 'task',
|
|
||||||
task,
|
|
||||||
},
|
|
||||||
disabled: isSubtask, // Disable drag for subtasks
|
|
||||||
});
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
zIndex: isDragging ? 1000 : 'auto',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className="flex w-full border-b hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
||||||
onMouseEnter={() => onRowHover(task.id)}
|
|
||||||
onMouseLeave={() => onRowHover(null)}
|
|
||||||
>
|
|
||||||
<div className="sticky left-0 z-10 w-8 flex items-center justify-center">
|
|
||||||
{!isSubtask && (
|
|
||||||
<div {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing">
|
|
||||||
<HolderOutlined />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{visibleColumns.map(column => (
|
|
||||||
<div
|
|
||||||
key={column.key}
|
|
||||||
className={`flex items-center px-3 border-r ${
|
|
||||||
hoverRow === task.id ? 'bg-gray-50 dark:bg-gray-800' : ''
|
|
||||||
}`}
|
|
||||||
style={{ width: column.width }}
|
|
||||||
>
|
|
||||||
{renderCell(column.key, task, isSubtask)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TaskListTable = ({
|
const TaskListTable = ({
|
||||||
taskListGroup,
|
taskListGroup,
|
||||||
tableId,
|
|
||||||
visibleColumns,
|
visibleColumns,
|
||||||
onTaskSelect,
|
onTaskSelect,
|
||||||
onTaskExpand,
|
onTaskExpand,
|
||||||
@@ -116,38 +18,11 @@ const TaskListTable = ({
|
|||||||
onTaskExpand?: (taskId: string) => void;
|
onTaskExpand?: (taskId: string) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
||||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
|
|
||||||
const tableRef = useRef<HTMLDivElement | null>(null);
|
const tableRef = useRef<HTMLDivElement | null>(null);
|
||||||
const parentRef = useRef<HTMLDivElement | null>(null);
|
const parentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
|
||||||
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
// Memoize all tasks including subtasks for virtualization
|
||||||
const { socket } = useSocket();
|
|
||||||
const currentSession = useAuthService().getCurrentSession();
|
|
||||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
|
||||||
|
|
||||||
// Configure sensors for drag and drop
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 8,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
}),
|
|
||||||
useSensor(TouchSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
delay: 250,
|
|
||||||
tolerance: 5,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize all tasks including subtasks
|
|
||||||
const flattenedTasks = useMemo(() => {
|
const flattenedTasks = useMemo(() => {
|
||||||
return taskListGroup.tasks.reduce((acc: IProjectTask[], task: IProjectTask) => {
|
return taskListGroup.tasks.reduce((acc: IProjectTask[], task: IProjectTask) => {
|
||||||
acc.push(task);
|
acc.push(task);
|
||||||
@@ -158,10 +33,13 @@ const TaskListTable = ({
|
|||||||
}, []);
|
}, []);
|
||||||
}, [taskListGroup.tasks]);
|
}, [taskListGroup.tasks]);
|
||||||
|
|
||||||
// Get only main tasks for sortable context (exclude subtasks)
|
// Virtual row renderer
|
||||||
const mainTasks = useMemo(() => {
|
const rowVirtualizer = useVirtualizer({
|
||||||
return taskListGroup.tasks.filter(task => !task.isSubtask);
|
count: flattenedTasks.length,
|
||||||
}, [taskListGroup.tasks]);
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => 42, // row height
|
||||||
|
overscan: 5,
|
||||||
|
});
|
||||||
|
|
||||||
// Memoize cell render functions
|
// Memoize cell render functions
|
||||||
const renderCell = useCallback(
|
const renderCell = useCallback(
|
||||||
@@ -176,7 +54,7 @@ const TaskListTable = ({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
task: () => (
|
task: () => (
|
||||||
<Flex align="center" className={isSubtask ? "pl-6" : "pl-2"}>
|
<Flex align="center" className="pl-2">
|
||||||
{task.name}
|
{task.name}
|
||||||
</Flex>
|
</Flex>
|
||||||
),
|
),
|
||||||
@@ -188,77 +66,6 @@ const TaskListTable = ({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle drag start
|
|
||||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
||||||
setActiveId(event.active.id);
|
|
||||||
document.body.style.cursor = 'grabbing';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle drag end with socket integration
|
|
||||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
|
|
||||||
setActiveId(null);
|
|
||||||
document.body.style.cursor = '';
|
|
||||||
|
|
||||||
if (!over || active.id === over.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeIndex = mainTasks.findIndex(task => task.id === active.id);
|
|
||||||
const overIndex = mainTasks.findIndex(task => task.id === over.id);
|
|
||||||
|
|
||||||
if (activeIndex !== -1 && overIndex !== -1) {
|
|
||||||
const activeTask = mainTasks[activeIndex];
|
|
||||||
const overTask = mainTasks[overIndex];
|
|
||||||
|
|
||||||
// Create updated task arrays
|
|
||||||
const updatedTasks = [...mainTasks];
|
|
||||||
updatedTasks.splice(activeIndex, 1);
|
|
||||||
updatedTasks.splice(overIndex, 0, activeTask);
|
|
||||||
|
|
||||||
// Dispatch Redux action for optimistic update
|
|
||||||
dispatch(reorderTasks({
|
|
||||||
activeGroupId: tableId,
|
|
||||||
overGroupId: tableId,
|
|
||||||
fromIndex: activeIndex,
|
|
||||||
toIndex: overIndex,
|
|
||||||
task: activeTask,
|
|
||||||
updatedSourceTasks: updatedTasks,
|
|
||||||
updatedTargetTasks: updatedTasks,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Emit socket event for backend persistence
|
|
||||||
if (socket && projectId && currentSession?.team_id) {
|
|
||||||
const toPos = overTask?.sort_order || mainTasks[mainTasks.length - 1]?.sort_order || -1;
|
|
||||||
|
|
||||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
|
||||||
project_id: projectId,
|
|
||||||
from_index: activeTask.sort_order,
|
|
||||||
to_index: toPos,
|
|
||||||
to_last_index: overIndex === mainTasks.length - 1,
|
|
||||||
from_group: tableId,
|
|
||||||
to_group: tableId,
|
|
||||||
group_by: groupBy,
|
|
||||||
task: activeTask,
|
|
||||||
team_id: currentSession.team_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track analytics event
|
|
||||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
mainTasks,
|
|
||||||
tableId,
|
|
||||||
dispatch,
|
|
||||||
socket,
|
|
||||||
projectId,
|
|
||||||
currentSession?.team_id,
|
|
||||||
groupBy,
|
|
||||||
trackMixpanelEvent
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Memoize header rendering
|
// Memoize header rendering
|
||||||
const TableHeader = useMemo(
|
const TableHeader = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -287,55 +94,48 @@ const TaskListTable = ({
|
|||||||
target.classList.toggle('show-shadow', hasHorizontalShadow);
|
target.classList.toggle('show-shadow', hasHorizontalShadow);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Find active task for drag overlay
|
|
||||||
const activeTask = activeId ? flattenedTasks.find(task => task.id === activeId) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<div ref={parentRef} className="h-[400px] overflow-auto" onScroll={handleScroll}>
|
||||||
sensors={sensors}
|
{TableHeader}
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<div ref={parentRef} className="h-[400px] overflow-auto" onScroll={handleScroll}>
|
|
||||||
{TableHeader}
|
|
||||||
|
|
||||||
<SortableContext items={mainTasks.map(task => task.id)} strategy={verticalListSortingStrategy}>
|
<div
|
||||||
<div ref={tableRef} style={{ width: '100%' }}>
|
ref={tableRef}
|
||||||
{flattenedTasks.map((task, index) => (
|
style={{
|
||||||
<DraggableRow
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
key={task.id}
|
width: '100%',
|
||||||
task={task}
|
position: 'relative',
|
||||||
visibleColumns={visibleColumns}
|
|
||||||
renderCell={renderCell}
|
|
||||||
hoverRow={hoverRow}
|
|
||||||
onRowHover={setHoverRow}
|
|
||||||
isSubtask={task.isSubtask}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DragOverlay
|
|
||||||
dropAnimation={{
|
|
||||||
duration: 200,
|
|
||||||
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeTask && (
|
{rowVirtualizer.getVirtualItems().map(virtualRow => {
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-lg rounded border">
|
const task = flattenedTasks[virtualRow.index];
|
||||||
<DraggableRow
|
return (
|
||||||
task={activeTask}
|
<div
|
||||||
visibleColumns={visibleColumns}
|
key={task.id}
|
||||||
renderCell={renderCell}
|
className="absolute top-0 left-0 flex w-full border-b hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
hoverRow={null}
|
style={{
|
||||||
onRowHover={() => {}}
|
height: 42,
|
||||||
isSubtask={activeTask.isSubtask}
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
/>
|
}}
|
||||||
</div>
|
>
|
||||||
)}
|
<div className="sticky left-0 z-10 w-8 flex items-center justify-center">
|
||||||
</DragOverlay>
|
{/* <Checkbox checked={task.selected} /> */}
|
||||||
</DndContext>
|
</div>
|
||||||
|
{visibleColumns.map(column => (
|
||||||
|
<div
|
||||||
|
key={column.key}
|
||||||
|
className={`flex items-center px-3 border-r ${
|
||||||
|
hoverRow === task.id ? 'bg-gray-50 dark:bg-gray-800' : ''
|
||||||
|
}`}
|
||||||
|
style={{ width: column.width }}
|
||||||
|
>
|
||||||
|
{renderCell(column.key, task, task.is_sub_task)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Row,
|
Row,
|
||||||
Column,
|
Column,
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -77,6 +78,19 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
|
|||||||
|
|
||||||
const { rows } = table.getRowModel();
|
const { rows } = table.getRowModel();
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => tableContainerRef.current,
|
||||||
|
estimateSize: () => 50,
|
||||||
|
overscan: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||||
|
const totalSize = rowVirtualizer.getTotalSize();
|
||||||
|
const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
|
||||||
|
const paddingBottom =
|
||||||
|
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
|
||||||
|
|
||||||
const columnToggleItems = columns.map(column => ({
|
const columnToggleItems = columns.map(column => ({
|
||||||
key: column.id as string,
|
key: column.id as string,
|
||||||
label: (
|
label: (
|
||||||
@@ -111,7 +125,6 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
overflowY: 'auto',
|
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -148,75 +161,80 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="table-body">
|
<div className="table-body">
|
||||||
{rows.map(row => (
|
{paddingTop > 0 && <div style={{ height: `${paddingTop}px` }} />}
|
||||||
<React.Fragment key={row.id}>
|
{virtualRows.map(virtualRow => {
|
||||||
<div
|
const row = rows[virtualRow.index];
|
||||||
className="table-row"
|
return (
|
||||||
style={{
|
<React.Fragment key={row.id}>
|
||||||
'&:hover div': {
|
<div
|
||||||
background: `${token.colorFillAlter} !important`,
|
className="table-row"
|
||||||
},
|
style={{
|
||||||
}}
|
'&:hover div': {
|
||||||
>
|
background: `${token.colorFillAlter} !important`,
|
||||||
{row.getVisibleCells().map((cell, index) => (
|
},
|
||||||
<div
|
}}
|
||||||
key={cell.id}
|
>
|
||||||
className={`${cell.column.getIsPinned() === 'left' ? 'sticky left-0 z-10' : ''}`}
|
{row.getVisibleCells().map((cell, index) => (
|
||||||
style={{
|
<div
|
||||||
width: cell.column.getSize(),
|
key={cell.id}
|
||||||
position: index < 2 ? 'sticky' : 'relative',
|
className={`${cell.column.getIsPinned() === 'left' ? 'sticky left-0 z-10' : ''}`}
|
||||||
left: 'auto',
|
style={{
|
||||||
background: token.colorBgContainer,
|
width: cell.column.getSize(),
|
||||||
color: token.colorText,
|
position: index < 2 ? 'sticky' : 'relative',
|
||||||
height: '42px',
|
left: 'auto',
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
background: token.colorBgContainer,
|
||||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
color: token.colorText,
|
||||||
padding: '8px 0px 8px 8px',
|
height: '42px',
|
||||||
}}
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
>
|
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
padding: '8px 0px 8px 8px',
|
||||||
</div>
|
}}
|
||||||
))}
|
>
|
||||||
</div>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
{expandedRows[row.id] &&
|
</div>
|
||||||
row.original.sub_tasks?.map(subTask => (
|
))}
|
||||||
<div
|
</div>
|
||||||
key={subTask.task_key}
|
{expandedRows[row.id] &&
|
||||||
className="table-row"
|
row.original.sub_tasks?.map(subTask => (
|
||||||
style={{
|
<div
|
||||||
'&:hover div': {
|
key={subTask.task_key}
|
||||||
background: `${token.colorFillAlter} !important`,
|
className="table-row"
|
||||||
},
|
style={{
|
||||||
}}
|
'&:hover div': {
|
||||||
>
|
background: `${token.colorFillAlter} !important`,
|
||||||
{columns.map((col, index) => (
|
},
|
||||||
<div
|
}}
|
||||||
key={`${subTask.task_key}-${col.id}`}
|
>
|
||||||
style={{
|
{columns.map((col, index) => (
|
||||||
width: col.getSize(),
|
<div
|
||||||
position: index < 2 ? 'sticky' : 'relative',
|
key={`${subTask.task_key}-${col.id}`}
|
||||||
left: index < 2 ? `${index * col.getSize()}px` : 'auto',
|
style={{
|
||||||
background: token.colorBgContainer,
|
width: col.getSize(),
|
||||||
color: token.colorText,
|
position: index < 2 ? 'sticky' : 'relative',
|
||||||
height: '42px',
|
left: index < 2 ? `${index * col.getSize()}px` : 'auto',
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
background: token.colorBgContainer,
|
||||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
color: token.colorText,
|
||||||
paddingLeft: index === 3 ? '32px' : '8px',
|
height: '42px',
|
||||||
paddingRight: '8px',
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
}}
|
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||||
>
|
paddingLeft: index === 3 ? '32px' : '8px',
|
||||||
{flexRender(col.cell, {
|
paddingRight: '8px',
|
||||||
getValue: () => subTask[col.id as keyof typeof subTask] ?? null,
|
}}
|
||||||
row: { original: subTask } as Row<IProjectTask>,
|
>
|
||||||
column: col as Column<IProjectTask>,
|
{flexRender(col.cell, {
|
||||||
table,
|
getValue: () => subTask[col.id as keyof typeof subTask] ?? null,
|
||||||
})}
|
row: { original: subTask } as Row<IProjectTask>,
|
||||||
</div>
|
column: col as Column<IProjectTask>,
|
||||||
))}
|
table,
|
||||||
</div>
|
})}
|
||||||
))}
|
</div>
|
||||||
</React.Fragment>
|
))}
|
||||||
))}
|
</div>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{paddingBottom > 0 && <div style={{ height: `${paddingBottom}px` }} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { TaskType } from '@/types/task.types';
|
|||||||
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
|
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
import './task-list-table-wrapper.css';
|
import './task-list-table-wrapper.css';
|
||||||
import TaskListTable from '../table-v2';
|
import TaskListTable from '../task-list-table-old/task-list-table-old';
|
||||||
import { MenuProps } from 'antd/lib';
|
import { MenuProps } from 'antd/lib';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||||
import { columnList as defaultColumnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList';
|
import TaskListCustom from '../task-list-custom';
|
||||||
|
|
||||||
type TaskListTableWrapperProps = {
|
type TaskListTableWrapperProps = {
|
||||||
taskList: ITaskListGroup;
|
taskList: ITaskListGroup;
|
||||||
@@ -37,22 +37,6 @@ const TaskListTableWrapper = ({
|
|||||||
// localization
|
// localization
|
||||||
const { t } = useTranslation('task-list-table');
|
const { t } = useTranslation('task-list-table');
|
||||||
|
|
||||||
// Get column visibility from Redux
|
|
||||||
const columnVisibilityList = useAppSelector(
|
|
||||||
state => state.projectViewTaskListColumnsReducer.columnList
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter visible columns and format them for table-v2
|
|
||||||
const visibleColumns = defaultColumnList
|
|
||||||
.filter(column => {
|
|
||||||
const visibilityConfig = columnVisibilityList.find(col => col.key === column.key);
|
|
||||||
return visibilityConfig?.isVisible ?? false;
|
|
||||||
})
|
|
||||||
.map(column => ({
|
|
||||||
key: column.key,
|
|
||||||
width: column.width,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// function to handle toggle expand
|
// function to handle toggle expand
|
||||||
const handlToggleExpand = () => {
|
const handlToggleExpand = () => {
|
||||||
setIsExpanded(!isExpanded);
|
setIsExpanded(!isExpanded);
|
||||||
@@ -114,14 +98,6 @@ const TaskListTableWrapper = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleTaskSelect = (taskId: string) => {
|
|
||||||
console.log('Task selected:', taskId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTaskExpand = (taskId: string) => {
|
|
||||||
console.log('Task expanded:', taskId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
wave={{ disabled: true }}
|
wave={{ disabled: true }}
|
||||||
@@ -196,13 +172,11 @@ const TaskListTableWrapper = ({
|
|||||||
key: groupId || '1',
|
key: groupId || '1',
|
||||||
className: `custom-collapse-content-box relative after:content after:absolute after:h-full after:w-1 ${color} after:z-10 after:top-0 after:left-0`,
|
className: `custom-collapse-content-box relative after:content after:absolute after:h-full after:w-1 ${color} after:z-10 after:top-0 after:left-0`,
|
||||||
children: (
|
children: (
|
||||||
<TaskListTable
|
<TaskListCustom
|
||||||
key={groupId}
|
key={groupId}
|
||||||
taskListGroup={taskList}
|
groupId={groupId}
|
||||||
tableId={groupId || ''}
|
tasks={taskList.tasks}
|
||||||
visibleColumns={visibleColumns}
|
color={color || ''}
|
||||||
onTaskSelect={handleTaskSelect}
|
|
||||||
onTaskExpand={handleTaskExpand}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { ITaskListConfigV2, ITaskListGroup } from '@/types/tasks/taskList.types'
|
|||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { fetchTaskGroups } from '@/features/tasks/taskSlice';
|
import { fetchTaskGroups } from '@/features/tasks/taskSlice';
|
||||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||||
import TaskListTableWrapper from './task-list-table-wrapper/task-list-table-wrapper';
|
|
||||||
|
import { columnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList';
|
||||||
|
import StatusGroupTables from '../taskList/statusTables/StatusGroupTables';
|
||||||
|
|
||||||
const TaskList = () => {
|
const TaskList = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -29,7 +31,6 @@ const TaskList = () => {
|
|||||||
const onTaskExpand = (taskId: string) => {
|
const onTaskExpand = (taskId: string) => {
|
||||||
console.log('taskId:', taskId);
|
console.log('taskId:', taskId);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
const config: ITaskListConfigV2 = {
|
const config: ITaskListConfigV2 = {
|
||||||
@@ -53,15 +54,9 @@ const TaskList = () => {
|
|||||||
<Flex vertical gap={16}>
|
<Flex vertical gap={16}>
|
||||||
<TaskListFilters position="list" />
|
<TaskListFilters position="list" />
|
||||||
<Skeleton active loading={loadingGroups}>
|
<Skeleton active loading={loadingGroups}>
|
||||||
{taskGroups.map((group: ITaskListGroup) => (
|
{/* {taskGroups.map((group: ITaskListGroup) => (
|
||||||
<TaskListTableWrapper
|
|
||||||
key={group.id}
|
))} */}
|
||||||
taskList={group}
|
|
||||||
groupId={group.id}
|
|
||||||
name={group.name}
|
|
||||||
color={group.color_code}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user