Compare commits

..

10 Commits

Author SHA1 Message Date
chamiakJ
c1067d87fe refactor(reporting): update total working hours calculation in allocation controller
- Replaced project-specific hours per day with organization-wide working hours for total working hours calculation.
- Streamlined the SQL query to fetch organization working hours, ensuring accurate reporting based on organizational settings.
2025-05-20 16:49:07 +05:30
chamiakJ
97feef5982 refactor(reporting): improve utilization calculations in allocation controller
- Updated utilization percentage and utilized hours calculations to handle cases where total working hours are zero, providing 'N/A' for utilization percent when applicable.
- Adjusted logic for over/under utilized hours to ensure accurate reporting based on logged time and total working hours.
2025-05-20 16:18:16 +05:30
chamiakJ
76c92b1cc6 refactor(reporting): optimize date handling and organization working days logic
- Simplified date parsing by removing unnecessary start and end of day adjustments.
- Streamlined the fetching of organization working days from the database, consolidating queries for improved performance.
- Updated the calculation of total working hours to utilize project-specific hours per day, enhancing accuracy in reporting.
2025-05-20 15:51:33 +05:30
chamiakJ
67c62fc69b refactor(schedule): streamline organization working days update query
- Simplified the SQL update query for organization working days by removing unnecessary line breaks and improving readability.
- Adjusted the subquery to directly select organization IDs, enhancing clarity and maintainability.
2025-05-20 11:55:29 +05:30
chamiakJ
14d8f43001 refactor(reporting): clarify date parsing in allocation controller and frontend
- Updated comments to specify date parsing format as 'YYYY-MM-DD'.
- Modified date range handling in the frontend to format dates using date-fns for consistency.
2025-05-20 09:23:22 +05:30
chamiakJ
3b59a8560b refactor(reporting): simplify date parsing and improve logging format
- Updated date parsing to remove UTC conversion, maintaining local date context.
- Enhanced console logging to display dates in 'YYYY-MM-DD' format for clarity.
- Adjusted date range clause to directly use formatted dates for improved query accuracy.
2025-05-20 08:08:34 +05:30
chamiakJ
819252cedd refactor(reporting): update date handling and logging in allocation controller
- Removed UTC conversion for start and end dates to maintain local date context.
- Enhanced console logging to reflect local date values for better debugging.
2025-05-20 08:06:05 +05:30
chamiakJ
1dade05f54 feat(reporting): enhance date range handling in reporting allocation
- Added support for 'LAST_7_DAYS' and 'LAST_30_DAYS' date ranges in the reporting allocation logic.
- Updated date parsing to convert input dates to UTC while preserving the intended local date.
- Included console logs for debugging date values during processing.
2025-05-20 07:59:49 +05:30
chamiakJ
34613e5e0c Merge branch 'fix/performance-improvements' of https://github.com/Worklenz/worklenz into feature/member-time-progress-and-utilization 2025-05-20 07:07:28 +05:30
chamikaJ
a8b20680e5 feat: implement organization working days and hours settings
- Added functionality to fetch and update organization working days and hours in the admin center.
- Introduced a form for saving working days and hours, with validation and error handling.
- Updated the reporting allocation logic to utilize organization-specific working hours for accurate calculations.
- Enhanced localization files to support new settings in English, Spanish, and Portuguese.
2025-05-19 16:07:35 +05:30
133 changed files with 9512 additions and 12879 deletions

View File

@@ -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:

View File

@@ -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.

View File

@@ -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
View File

@@ -1,6 +0,0 @@
{
"name": "worklenz",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -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")

View File

@@ -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"

View 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", []);
};

View File

@@ -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;

View File

@@ -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
) )

View File

@@ -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;
$$;

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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();

View File

@@ -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",

View File

@@ -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();

View File

@@ -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: [] }));
}
} }

View File

@@ -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));
} }

View File

@@ -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));

View File

@@ -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]);

View File

@@ -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;

View File

@@ -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(

View File

@@ -7,5 +7,5 @@ export function startCronJobs() {
startNotificationsJob(); startNotificationsJob();
startDailyDigestJob(); startDailyDigestJob();
startProjectDigestJob(); startProjectDigestJob();
if (process.env.ENABLE_RECURRING_JOBS === "true") startRecurringTasksJob(); // startRecurringTasksJob();
} }

View File

@@ -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.`);

View File

@@ -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);

View File

@@ -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
}
}
}

View File

@@ -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:"
} }
} }

View 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));

View File

@@ -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"

View File

@@ -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;

View File

@@ -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>
&copy; 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>

View File

@@ -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>
&copy; 2025 Worklenz. All rights reserved. </tbody>
</div> </table><!-- End -->
</div>
</body> </body>
</html> </html>

View File

@@ -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>
&copy; 2025 Worklenz. All rights reserved. </tbody>
</div> </table><!-- End -->
</div>
</body> </body>
</html> </html>

View File

@@ -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>
&copy; 2025 Worklenz. All rights reserved. </td>
</div> </tr>
</div> </tbody>
</table><!-- End -->
</body> </body>
</html> </html>

View File

@@ -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>
&copy; 2025 Worklenz. All rights reserved. </td>
</div> </tr>
</div> </tbody>
</table><!-- End -->
</body> </body>
</html> </html>

View File

@@ -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

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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."
} }

View File

@@ -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"
}
} }

View File

@@ -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"
} }

View File

@@ -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",

View File

@@ -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"
} }

View File

@@ -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."
} }

View File

@@ -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"
}
} }

View File

@@ -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"
} }

View File

@@ -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",

View File

@@ -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"
} }

View File

@@ -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."
} }

View File

@@ -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"
}
} }

View File

@@ -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"
} }

View File

@@ -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",

View File

@@ -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"
} }

View File

@@ -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>

View File

@@ -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';

View File

@@ -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',
}), }),

View File

@@ -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}`);

View File

@@ -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',
}), }),

View File

@@ -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}`;
}, },

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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>}
/> />

View File

@@ -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' }}>

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View 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;

View File

@@ -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;

View File

@@ -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));
}
} }
}; };

View File

@@ -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

View File

@@ -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 }) => {
&nbsp; &nbsp;
{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>

View File

@@ -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,

View File

@@ -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} />

View File

@@ -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

View File

@@ -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();

View File

@@ -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([]);

View File

@@ -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;
}
} }
} }
}, },

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
};
};

View File

@@ -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,
]);
};

View File

@@ -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;
} }

View File

@@ -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>
); );

View File

@@ -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',

View File

@@ -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')}

View File

@@ -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',

View File

@@ -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));

View File

@@ -89,7 +89,7 @@ const TasksList: React.FC = React.memo(() => {
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true })); dispatch(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}
/> />

View File

@@ -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')}
/> />
) : ( ) : (

View File

@@ -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;
}

View File

@@ -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')}

View File

@@ -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>
); );
}; };

View File

@@ -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>

View File

@@ -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}
/> />
), ),
}, },

View File

@@ -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