Compare commits

..

35 Commits

Author SHA1 Message Date
Chamika J
84b69e36d3 Merge pull request #226 from OminduHirushka/update/event-tracking
update event integrations
2025-07-04 13:36:54 +05:30
Omindu Hirushka
08ee87da17 updated 2025-07-03 08:36:41 +05:30
Chamika J
f80ec9797e Merge pull request #216 from Dev-Tanaay/bug/Documentation
Solved the documentation error with issue number
2025-07-02 09:09:27 +05:30
Dev-Tanaay
fbbd820512 Updation in Documentation 2025-07-01 17:29:02 +05:30
Chamika J
5d0777f67c Merge pull request #183 from Worklenz/fix/added-i18n-alb-and-de
Update LANGUAGE_TYPE enum to include 'alb' and 'de' for Albanian and …
2025-06-24 22:39:29 +05:30
chamiakJ
f1d504f985 Update LANGUAGE_TYPE enum to include 'alb' and 'de' for Albanian and German languages 2025-06-24 22:28:33 +05:30
Chamika J
6a4d77d904 Merge pull request #182 from Worklenz/feature/german-i18n
Feature/german i18n
2025-06-24 21:47:45 +05:30
Chamika J
c35d53266a Merge pull request #179 from OminduHirushka/language/de
German Language
2025-06-24 21:47:06 +05:30
Omindu Hirushka
dc096f5e12 german language switch 2025-06-24 14:17:23 +05:30
Omindu Hirushka
a681aadcfa german language 2025-06-24 14:17:08 +05:30
Chamika J
f15f3f5110 Merge pull request #176 from Worklenz/feature/albanian-i18n
Feature/albanian i18n
2025-06-23 07:24:12 +05:30
Chamika J
07ae71fd23 Merge pull request #168 from OminduHirushka/upstream/language/alb
albanian language
2025-06-23 07:23:42 +05:30
Chamika J
26270b2842 Merge pull request #166 from kithmina1999/chore/docker-image-size-reduction
Chore/docker image size reduction
2025-06-23 06:56:14 +05:30
kithmina1999
0d0596b767 ci(docker): update postgres healthcheck and retry settings
- Modify healthcheck command to use DB_NAME and DB_USER environment variables
- Reduce retries from 10 to 5 to fail faster if database is unavailable
2025-06-20 08:48:51 +05:30
kithmina1999
eca7af2d6f chore: remove obsolete git file with process information
The file contained outdated process information that was no longer relevant to the project. This cleanup helps maintain a cleaner codebase.
2025-06-19 22:32:17 +05:30
kithmina1999
3ace14fcdb build(docker): optimize docker setup and compose configuration
- Split Dockerfile into multi-stage build to reduce final image size
- Update docker-compose.yml with restart policies and health checks
- Improve .dockerignore files for both frontend and backend
- Fix MinIO bucket creation script to use 'alias set' instead of deprecated command
- Enhance PostgreSQL healthcheck configuration
2025-06-19 22:26:20 +05:30
Omindu Hirushka
99bec6c7f9 albanian language switch 2025-06-18 17:04:58 +05:30
Omindu Hirushka
ef299f1f4a albanian language 2025-06-17 16:03:08 +05:30
Chamika J
66b0709e6e Merge pull request #154 from Worklenz/chore/added-google-analytics
Chore/added google analytics
2025-06-12 10:10:00 +05:30
chamiakJ
a2ed33214d Merge branch 'main' of https://github.com/Worklenz/worklenz into chore/added-google-analytics 2025-06-12 09:49:32 +05:30
Chamika J
a3dccd690d Merge pull request #141 from gdevenyi/patch-1
Fix use of deprecated mc command for bucket creation
2025-06-12 09:46:45 +05:30
chamiakJ
69313fba34 Enhance privacy notice functionality in index.html
- Updated the privacy notice button to use an ID for easier event handling.
- Added an event listener to the button to manage the display of the notice and store user consent in localStorage.
2025-06-12 09:38:24 +05:30
chamiakJ
1889c58598 Refactor privacy notice implementation in index.html
- Introduced a dedicated function to display the privacy notice for users of the open source version.
- Updated the styling and content of the privacy notice for improved visibility and clarity.
- Added a DOMContentLoaded event listener to conditionally show the notice based on the environment and previous interactions.
2025-06-12 09:34:53 +05:30
chamiakJ
e9f0162439 Enhance documentation and integrate Google Analytics
- Added an Analytics section to the README.md, detailing what is tracked, privacy measures, and opt-out instructions.
- Implemented Google Analytics in index.html, including a privacy notice for open source users and environment-specific tracking IDs.
- Updated worklenz-frontend README.md to include a License section.
2025-06-12 09:26:00 +05:30
Chamika J
323b17185c Merge pull request #148 from kithmina1999/fix/postgres-password-auth
fix: change DB_PASSWORD to static value
2025-06-05 13:18:41 +05:30
kithmina1999
09f44a5685 fix: change DB_PASSWORD to static value for development
Using a static password simplifies development environment setup. The previous random password generation caused issues during local testing and debugging.
2025-06-05 10:40:06 +05:30
Chamika J
f4ab7841fb Merge pull request #105 from MRNafisiA/main
increase the memory limit to prevent crashing during build time.
2025-05-30 10:15:47 +05:30
Chamika J
3de4f69a62 Merge pull request #142 from gdevenyi/patch-2
Generate random passwords in update-docker-env.sh
2025-05-30 09:42:46 +05:30
Gabriel A. Devenyi
102be2c24a Generate random passwords in update-docker-env.sh 2025-05-29 15:56:56 -04:00
Chamika J
3a39b25e64 Merge pull request #144 from kithmina1999/docs/add-video-guides
Update README.md to include video guides for local and remote deployment
2025-05-28 13:01:16 +05:30
kithmina1999
32248f8424 Update README.md to include video guides for local and remote deployment
- Added a section for a video guide on local Docker deployment.
- Included a video guide for deploying Worklenz to a remote server.
2025-05-28 09:32:32 +05:30
Gabriel A. Devenyi
a1f8776743 Fix use of deprecated mc command for bucket creation 2025-05-27 22:32:36 -04:00
Chamika J
7e431d645a Merge pull request #134 from Worklenz/chamikaJ-patch-1
Update README.md
2025-05-21 08:28:32 +05:30
Chamika J
cef4bffd69 Update README.md
updated logo URL
2025-05-21 08:28:09 +05:30
MRNafisiA
75391641fd increase the memory limit to prevent crashing during build time. 2025-05-02 15:53:48 +03:30
472 changed files with 13393 additions and 24497 deletions

View File

@@ -1,6 +1,6 @@
<h1 align="center">
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
<img src="https://app.worklenz.com/assets/icons/icon-144x144.png" alt="Worklenz Logo" width="75">
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/icon-144x144.png" alt="Worklenz Logo" width="75">
</a>
<br>
Worklenz
@@ -69,8 +69,7 @@ cd worklenz
2. Set up environment variables
- Copy the example environment files
```bash
cp .env.example .env
cp worklenz-backend/.env.example worklenz-backend/.env
cp worklenz-backend/.env.template worklenz-backend/.env
```
- Update the environment variables with your configuration
@@ -192,6 +191,27 @@ Email [info@worklenz.com](mailto:info@worklenz.com) to disclose any security vul
This project is licensed under the [MIT License](LICENSE).
## Analytics
Worklenz uses Google Analytics to understand how the application is being used. This helps us improve the application and make better decisions about future development.
### What We Track
- Anonymous usage statistics
- Page views and navigation patterns
- Feature usage
- Browser and device information
### Privacy
- Analytics is opt-in only
- No personal information is collected
- Users can opt-out at any time
- Data is stored according to Google's privacy policy
### How to Opt-Out
If you've previously opted in and want to opt-out:
1. Clear your browser's local storage for the Worklenz domain
2. Or click the "Decline" button in the analytics notice if it appears
## Screenshots
<p align="center">
@@ -315,6 +335,7 @@ docker-compose up -d
docker-compose down
```
## MinIO Integration
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
@@ -403,6 +424,10 @@ This script generates properly configured environment files for both development
- Frontend: http://localhost:5000
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
4. Video Guide
For a visual walkthrough of the local Docker deployment process, check out our [step-by-step video guide](https://www.youtube.com/watch?v=AfwAKxJbqLg).
### Remote Server Deployment
When deploying to a remote server:
@@ -428,6 +453,10 @@ When deploying to a remote server:
- Frontend: http://your-server-hostname:5000
- Backend API: http://your-server-hostname:3000
4. Video Guide
For a complete walkthrough of deploying Worklenz to a remote server, check out our [deployment video guide](https://www.youtube.com/watch?v=CAZGu2iOXQs&t=10s).
### Environment Configuration
The Docker setup uses environment variables to configure the services:

View File

@@ -7,8 +7,8 @@ services:
ports:
- "5000:5000"
depends_on:
backend:
condition: service_started
- backend
restart: unless-stopped
env_file:
- ./worklenz-frontend/.env.production
networks:
@@ -26,6 +26,7 @@ services:
condition: service_healthy
minio:
condition: service_started
restart: unless-stopped
env_file:
- ./worklenz-backend/.env
networks:
@@ -37,6 +38,7 @@ services:
ports:
- "9000:9000"
- "9001:9001"
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin}
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin}
@@ -52,13 +54,14 @@ services:
container_name: worklenz_createbuckets
depends_on:
- minio
restart: on-failure
entrypoint: >
/bin/sh -c '
echo "Waiting for MinIO to start...";
sleep 15;
for i in 1 2 3 4 5; do
echo "Attempt $i to connect to MinIO...";
if /usr/bin/mc config host add myminio http://minio:9000 minioadmin minioadmin; then
if /usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin; then
echo "Successfully connected to MinIO!";
/usr/bin/mc mb --ignore-existing myminio/worklenz-bucket;
/usr/bin/mc policy set public myminio/worklenz-bucket;
@@ -84,6 +87,7 @@ services:
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- worklenz
volumes:

View File

@@ -1,195 +0,0 @@
# Task Breakdown API
## Get Task Financial Breakdown
**Endpoint:** `GET /api/project-finance/task/:id/breakdown`
**Description:** Retrieves detailed financial breakdown for a single task, including members grouped by job roles with labor hours and costs.
### Parameters
- `id` (path parameter): UUID of the task
### Response
```json
{
"success": true,
"body": {
"task": {
"id": "uuid",
"name": "Task Name",
"project_id": "uuid",
"billable": true,
"estimated_hours": 10.5,
"logged_hours": 8.25,
"estimated_labor_cost": 525.0,
"actual_labor_cost": 412.5,
"fixed_cost": 100.0,
"total_estimated_cost": 625.0,
"total_actual_cost": 512.5
},
"grouped_members": [
{
"jobRole": "Frontend Developer",
"estimated_hours": 5.25,
"logged_hours": 4.0,
"estimated_cost": 262.5,
"actual_cost": 200.0,
"members": [
{
"team_member_id": "uuid",
"name": "John Doe",
"avatar_url": "https://...",
"hourly_rate": 50.0,
"estimated_hours": 5.25,
"logged_hours": 4.0,
"estimated_cost": 262.5,
"actual_cost": 200.0
}
]
},
{
"jobRole": "Backend Developer",
"estimated_hours": 5.25,
"logged_hours": 4.25,
"estimated_cost": 262.5,
"actual_cost": 212.5,
"members": [
{
"team_member_id": "uuid",
"name": "Jane Smith",
"avatar_url": "https://...",
"hourly_rate": 50.0,
"estimated_hours": 5.25,
"logged_hours": 4.25,
"estimated_cost": 262.5,
"actual_cost": 212.5
}
]
}
],
"members": [
{
"team_member_id": "uuid",
"name": "John Doe",
"avatar_url": "https://...",
"hourly_rate": 50.0,
"job_title_name": "Frontend Developer",
"estimated_hours": 5.25,
"logged_hours": 4.0,
"estimated_cost": 262.5,
"actual_cost": 200.0
},
{
"team_member_id": "uuid",
"name": "Jane Smith",
"avatar_url": "https://...",
"hourly_rate": 50.0,
"job_title_name": "Backend Developer",
"estimated_hours": 5.25,
"logged_hours": 4.25,
"estimated_cost": 262.5,
"actual_cost": 212.5
}
]
}
}
```
### Error Responses
- `404 Not Found`: Task not found
- `400 Bad Request`: Invalid task ID
### Usage
This endpoint is designed to work with the finance drawer component (`@finance-drawer.tsx`) to provide detailed cost breakdown information for individual tasks. The response includes:
1. **Task Summary**: Overall task financial information
2. **Grouped Members**: Members organized by job role with aggregated costs
3. **Individual Members**: Detailed breakdown for each team member
The data structure matches what the finance drawer expects, with members grouped by job roles and individual labor hours and costs calculated based on:
- Estimated hours divided equally among assignees
- Actual logged time per member
- Hourly rates from project rate cards
- Fixed costs added to the totals
### Frontend Usage Example
```typescript
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
// Fetch task breakdown
const fetchTaskBreakdown = async (taskId: string) => {
try {
const response = await projectFinanceApiService.getTaskBreakdown(taskId);
const breakdown = response.body;
console.log('Task:', breakdown.task);
console.log('Grouped Members:', breakdown.grouped_members);
console.log('Individual Members:', breakdown.members);
return breakdown;
} catch (error) {
console.error('Error fetching task breakdown:', error);
throw error;
}
};
// Usage in React component
const TaskBreakdownComponent = ({ taskId }: { taskId: string }) => {
const [breakdown, setBreakdown] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadBreakdown = async () => {
setLoading(true);
try {
const data = await fetchTaskBreakdown(taskId);
setBreakdown(data);
} catch (error) {
// Handle error
} finally {
setLoading(false);
}
};
if (taskId) {
loadBreakdown();
}
}, [taskId]);
if (loading) return <Spin />;
if (!breakdown) return null;
return (
<div>
<h3>{breakdown.task.name}</h3>
<p>Total Estimated Cost: ${breakdown.task.total_estimated_cost}</p>
<p>Total Actual Cost: ${breakdown.task.total_actual_cost}</p>
{breakdown.grouped_members.map(group => (
<div key={group.jobRole}>
<h4>{group.jobRole}</h4>
<p>Hours: {group.estimated_hours} | Cost: ${group.estimated_cost}</p>
{group.members.map(member => (
<div key={member.team_member_id}>
{member.name}: {member.estimated_hours}h @ ${member.hourly_rate}/h
</div>
))}
</div>
))}
</div>
);
};
```
### Integration
This API complements the existing finance endpoints:
- `GET /api/project-finance/project/:project_id/tasks` - Get all tasks for a project
- `PUT /api/project-finance/task/:task_id/fixed-cost` - Update task fixed cost
The finance drawer component has been updated to automatically use this API when a task is selected, providing real-time financial breakdown data.

View File

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

View File

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

View File

@@ -1,223 +0,0 @@
# WorkLenz Task Progress Guide for Users
## Introduction
WorkLenz offers three different ways to track and calculate task progress, each designed for different project management needs. This guide explains how each method works and when to use them.
## Default Progress Method
WorkLenz uses a simple completion-based approach as the default progress calculation method. This method is applied when no special progress methods are enabled.
### Example
If you have a parent task with four subtasks and two of the subtasks are marked complete:
- Parent task: Not done
- 2 subtasks: Done
- 2 subtasks: Not done
The parent task will show as 40% complete (2 completed out of 5 total tasks).
## Available Progress Tracking Methods
WorkLenz provides these progress tracking methods:
1. **Manual Progress** - Directly input progress percentages for tasks
2. **Weighted Progress** - Assign importance levels (weights) to tasks
3. **Time-based Progress** - Calculate progress based on estimated time
Only one method can be enabled at a time for a project. If none are enabled, progress will be calculated based on task completion status.
## How to Select a Progress Method
1. Open the project drawer by clicking on the project settings icon or creating a new project
2. In the project settings, find the "Progress Calculation Method" section
3. Select your preferred method
4. Save your changes
## Manual Progress Method
### How It Works
- You directly enter progress percentages (0-100%) for tasks without subtasks
- Parent task progress is calculated as the average of all subtask progress values
- Progress is updated in real-time as you adjust values
### When to Use Manual Progress
- For creative or subjective work where completion can't be measured objectively
- When task progress doesn't follow a linear path
- For projects where team members need flexibility in reporting progress
### Example
If you have a parent task with three subtasks:
- Subtask A: 30% complete
- Subtask B: 60% complete
- Subtask C: 90% complete
The parent task will show as 60% complete (average of 30%, 60%, and 90%).
## Weighted Progress Method
### How It Works
- You assign "weight" values to tasks to indicate their importance
- More important tasks have higher weights and influence the overall progress more
- You still enter manual progress percentages for tasks without subtasks
- Parent task progress is calculated using a weighted average
### When to Use Weighted Progress
- When some tasks are more important or time-consuming than others
- For projects where all tasks aren't equal
- When you want key deliverables to have more impact on overall progress
### Example
If you have a parent task with three subtasks:
- Subtask A: 50% complete, Weight 60% (important task)
- Subtask B: 75% complete, Weight 20% (less important task)
- Subtask C: 25% complete, Weight 100% (critical task)
The parent task will be approximately 39% complete, with Subtask C having the greatest impact due to its higher weight.
### Important Notes About Weights
- Default weight is 100% if not specified
- Weights range from 0% to 100%
- Setting a weight to 0% removes that task from progress calculations
- Only explicitly set weights for tasks that should have different importance
- Weights are only relevant for subtasks, not for independent tasks
### Detailed Weighted Progress Calculation Example
To understand how weighted progress works with different weight values, consider this example:
For a parent task with two subtasks:
- Subtask A: 80% complete, Weight 50%
- Subtask B: 40% complete, Weight 100%
The calculation works as follows:
1. Each subtask's contribution is: (weight × progress) ÷ (sum of all weights)
2. For Subtask A: (50 × 80%) ÷ (50 + 100) = 26.7%
3. For Subtask B: (100 × 40%) ÷ (50 + 100) = 26.7%
4. Total parent progress: 26.7% + 26.7% = 53.3%
The parent task would be approximately 53% complete.
This shows how the subtask with twice the weight (Subtask B) has twice the influence on the overall progress calculation, even though it has a lower completion percentage.
## Time-based Progress Method
### How It Works
- Use the task's time estimate as its "weight" in the progress calculation
- You still enter manual progress percentages for tasks without subtasks
- Tasks with longer time estimates have more influence on overall progress
- Parent task progress is calculated based on time-weighted averages
### When to Use Time-based Progress
- For projects with well-defined time estimates
- When task importance correlates with its duration
- For billing or time-tracking focused projects
- When you already maintain accurate time estimates
### Example
If you have a parent task with three subtasks:
- Subtask A: 40% complete, Estimated Time 2.5 hours
- Subtask B: 80% complete, Estimated Time 1 hour
- Subtask C: 10% complete, Estimated Time 4 hours
The parent task will be approximately 29% complete, with the lengthy Subtask C pulling down the overall progress despite Subtask B being mostly complete.
### Important Notes About Time Estimates
- Tasks without time estimates don't influence progress calculations
- Time is converted to minutes internally (a 2-hour task = 120 minutes)
- Setting a time estimate to 0 removes that task from progress calculations
- Time estimates serve dual purposes: scheduling/resource planning and progress weighting
### Detailed Time-based Progress Calculation Example
To understand how time-based progress works with different time estimates, consider this example:
For a parent task with three subtasks:
- Subtask A: 40% complete, Estimated Time 2.5 hours
- Subtask B: 80% complete, Estimated Time 1 hour
- Subtask C: 10% complete, Estimated Time 4 hours
The calculation works as follows:
1. Convert hours to minutes: A = 150 min, B = 60 min, C = 240 min
2. Total estimated time: 150 + 60 + 240 = 450 minutes
3. Each subtask's contribution is: (time estimate × progress) ÷ (total time)
4. For Subtask A: (150 × 40%) ÷ 450 = 13.3%
5. For Subtask B: (60 × 80%) ÷ 450 = 10.7%
6. For Subtask C: (240 × 10%) ÷ 450 = 5.3%
7. Total parent progress: 13.3% + 10.7% + 5.3% = 29.3%
The parent task would be approximately 29% complete.
This demonstrates how tasks with longer time estimates (like Subtask C) have more influence on the overall progress calculation. Even though Subtask B is 80% complete, its shorter time estimate means it contributes less to the overall progress than the partially-completed but longer Subtask A.
### How It Works
- Tasks are either 0% (not done) or 100% (done)
- Parent task progress = (completed tasks / total tasks) × 100%
- Both the parent task and all subtasks count in this calculation
### When to Use Default Progress
- For simple projects with clear task completion criteria
- When binary task status (done/not done) is sufficient
- For teams new to project management who want simplicity
### Example
If you have a parent task with four subtasks and two of the subtasks are marked complete:
- Parent task: Not done
- 2 subtasks: Done
- 2 subtasks: Not done
The parent task will show as 40% complete (2 completed out of 5 total tasks).
## Best Practices
1. **Choose the Right Method for Your Project**
- Consider your team's workflow and reporting needs
- Match the method to your project's complexity
2. **Be Consistent**
- Stick with one method throughout the project
- Changing methods mid-project can cause confusion
3. **For Manual Progress**
- Update progress regularly
- Establish guidelines for progress reporting
4. **For Weighted Progress**
- Assign weights based on objective criteria
- Don't overuse extreme weights
5. **For Time-based Progress**
- Keep time estimates accurate and up to date
- Consider using time tracking to validate estimates
## Frequently Asked Questions
**Q: Can I change the progress method mid-project?**
A: Yes, but it may cause progress values to change significantly. It's best to select a method at the project start.
**Q: What happens to task progress when I mark a task complete?**
A: When a task is marked complete, its progress automatically becomes 100%, regardless of the progress method.
**Q: How do I enter progress for a task?**
A: Open the task drawer, go to the Info tab, and use the progress slider for tasks without subtasks.
**Q: Can different projects use different progress methods?**
A: Yes, each project can have its own progress method.
**Q: What if I don't see progress fields in my task drawer?**
A: Progress input is only visible for tasks without subtasks. Parent tasks' progress is automatically calculated.

View File

@@ -1,550 +0,0 @@
# Task Progress Tracking Methods in WorkLenz
## Overview
WorkLenz supports three different methods for tracking task progress, each suitable for different project management approaches:
1. **Manual Progress** - Direct input of progress percentages
2. **Weighted Progress** - Tasks have weights that affect overall progress calculation
3. **Time-based Progress** - Progress calculated based on estimated time vs. time spent
These modes can be selected when creating or editing a project in the project drawer. Only one progress method can be enabled at a time. If none of these methods are enabled, progress will be calculated based on task completion status as described in the "Default Progress Tracking" section below.
## 1. Manual Progress Mode
This mode allows direct input of progress percentages for individual tasks without subtasks.
**Implementation:**
- Enabled by setting `use_manual_progress` to true in the project settings
- Progress is updated through the `on-update-task-progress.ts` socket event handler
- The UI shows a manual progress input slider in the task drawer for tasks without subtasks
- Updates the database with `progress_value` and sets `manual_progress` flag to true
**Calculation Logic:**
- For tasks without subtasks: Uses the manually set progress value
- For parent tasks: Calculates the average of all subtask progress values
- Subtask progress comes from either manual values or completion status (0% or 100%)
**Code Example:**
```typescript
// Manual progress update via socket.io
socket?.emit(SocketEvents.UPDATE_TASK_PROGRESS.toString(), JSON.stringify({
task_id: task.id,
progress_value: value,
parent_task_id: task.parent_task_id
}));
```
## 2. Weighted Progress Mode
This mode allows assigning different weights to subtasks to reflect their relative importance in the overall task or project progress.
**Implementation:**
- Enabled by setting `use_weighted_progress` to true in the project settings
- Weights are updated through the `on-update-task-weight.ts` socket event handler
- The UI shows a weight input for subtasks in the task drawer
- Manual progress input is still required for tasks without subtasks
- Default weight is 100 if not specified
- Weight values range from 0 to 100%
**Calculation Logic:**
- For tasks without subtasks: Uses the manually entered progress value
- Progress is calculated using a weighted average: `SUM(progress_value * weight) / SUM(weight)`
- This gives more influence to tasks with higher weights
- A parent task's progress is the weighted average of its subtasks' progress values
**Code Example:**
```typescript
// Weight update via socket.io
socket?.emit(SocketEvents.UPDATE_TASK_WEIGHT.toString(), JSON.stringify({
task_id: task.id,
weight: value,
parent_task_id: task.parent_task_id
}));
```
## 3. Time-based Progress Mode
This mode calculates progress based on estimated time vs. actual time spent.
**Implementation:**
- Enabled by setting `use_time_progress` to true in the project settings
- Uses task time estimates (hours and minutes) for calculation
- Manual progress input is still required for tasks without subtasks
- No separate socket handler needed as it's calculated automatically
**Calculation Logic:**
- For tasks without subtasks: Uses the manually entered progress value
- Progress is calculated using time as the weight: `SUM(progress_value * estimated_minutes) / SUM(estimated_minutes)`
- For tasks with time tracking, estimated vs. actual time can be factored in
- Parent task progress is weighted by the estimated time of each subtask
**SQL Example:**
```sql
WITH subtask_progress AS (
SELECT
CASE
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
0
)
FROM subtask_progress
INTO _ratio;
```
## Default Progress Tracking (when no special mode is selected)
If no specific progress mode is enabled, the system falls back to a traditional completion-based calculation:
**Implementation:**
- Default mode when all three special modes are disabled
- Based on task completion status only
**Calculation Logic:**
- For tasks without subtasks: 0% if not done, 100% if done
- For parent tasks: `(completed_tasks / total_tasks) * 100`
- Counts both the parent and all subtasks in the calculation
**SQL Example:**
```sql
-- Traditional calculation based on completion status
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
SELECT COUNT(*)
FROM tasks_with_status_view
WHERE parent_task_id = _task_id
AND is_done IS TRUE
INTO _sub_tasks_done;
_total_completed = _parent_task_done + _sub_tasks_done;
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
IF _total_tasks = 0 THEN
_ratio = 0;
ELSE
_ratio = (_total_completed / _total_tasks) * 100;
END IF;
```
## Technical Implementation Details
The progress calculation logic is implemented in PostgreSQL functions, primarily in the `get_task_complete_ratio` function. Progress updates flow through the system as follows:
1. **User Action**: User updates task progress or weight in the UI
2. **Socket Event**: Client emits socket event (UPDATE_TASK_PROGRESS or UPDATE_TASK_WEIGHT)
3. **Server Handler**: Server processes the event in the respective handler function
4. **Database Update**: Progress/weight value is updated in the database
5. **Recalculation**: If needed, parent task progress is recalculated
6. **Broadcast**: Changes are broadcast to all clients in the project room
7. **UI Update**: Client UI updates to reflect the new progress values
This architecture allows for real-time updates and consistent progress calculation across all clients.
## Manual Progress Input Implementation
Regardless of which progress tracking method is selected for a project, tasks without subtasks (leaf tasks) require manual progress input. This section details how manual progress input is implemented and used across all progress tracking methods.
### UI Component
The manual progress input component is implemented in `worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx` and includes:
1. **Progress Slider**: A slider UI control that allows users to set progress values from 0% to 100%
2. **Progress Input Field**: A numeric input field that accepts direct entry of progress percentage
3. **Progress Display**: Visual representation of the current progress value
The component is conditionally rendered in the task drawer for tasks that don't have subtasks.
**Usage Across Progress Methods:**
- In **Manual Progress Mode**: Only the progress slider/input is shown
- In **Weighted Progress Mode**: Both the progress slider/input and weight input are shown
- In **Time-based Progress Mode**: The progress slider/input is shown alongside time estimate fields
### Progress Update Flow
When a user updates a task's progress manually, the following process occurs:
1. **User Input**: User adjusts the progress slider or enters a value in the input field
2. **UI Event Handler**: The UI component captures the change event and validates the input
3. **Socket Event Emission**: The component emits a `UPDATE_TASK_PROGRESS` socket event with:
```typescript
{
task_id: task.id,
progress_value: value, // The new progress value (0-100)
parent_task_id: task.parent_task_id // For recalculation
}
```
4. **Server Processing**: The socket event handler on the server:
- Updates the task's `progress_value` in the database
- Sets the `manual_progress` flag to true
- Triggers recalculation of parent task progress
### Progress Calculation Across Methods
The calculation of progress differs based on the active progress method:
1. **For Leaf Tasks (no subtasks)** in all methods:
- Progress is always the manually entered value (`progress_value`)
- If the task is marked as completed, progress is automatically set to 100%
2. **For Parent Tasks**:
- **Manual Progress Mode**: Simple average of all subtask progress values
- **Weighted Progress Mode**: Weighted average where each subtask's progress is multiplied by its weight
- **Time-based Progress Mode**: Weighted average where each subtask's progress is multiplied by its estimated time
- **Default Mode**: Percentage of completed tasks (including parent) vs. total tasks
### Detailed Calculation for Weighted Progress Method
In Weighted Progress mode, both the manual progress input and weight assignment are critical components:
1. **Manual Progress Input**:
- For leaf tasks (without subtasks), users must manually input progress percentages (0-100%)
- If a leaf task is marked as complete, its progress is automatically set to 100%
- If a leaf task's progress is not manually set, it defaults to 0% (or 100% if completed)
2. **Weight Assignment**:
- Each task can be assigned a weight value between 0-100% (default 100% if not specified)
- Higher weight values give tasks more influence in parent task progress calculations
- A weight of 0% means the task doesn't contribute to the parent's progress calculation
3. **Parent Task Calculation**:
The weighted progress formula is:
```
ParentProgress = ∑(SubtaskProgress * SubtaskWeight) / ∑(SubtaskWeight)
```
**Example Calculation**:
Consider a parent task with three subtasks:
- Subtask A: Progress 50%, Weight 60%
- Subtask B: Progress 75%, Weight 20%
- Subtask C: Progress 25%, Weight 100%
Calculation:
```
ParentProgress = ((50 * 60) + (75 * 20) + (25 * 100)) / (60 + 20 + 100)
ParentProgress = (3000 + 1500 + 2500) / 180
ParentProgress = 7000 / 180
ParentProgress = 38.89%
```
Notice that Subtask C, despite having the lowest progress, has a significant impact on the parent task progress due to its higher weight.
4. **Zero Weight Handling**:
Tasks with zero weight are excluded from the calculation:
- Subtask A: Progress 40%, Weight 50%
- Subtask B: Progress 80%, Weight 0%
Calculation:
```
ParentProgress = ((40 * 50) + (80 * 0)) / (50 + 0)
ParentProgress = 2000 / 50
ParentProgress = 40%
```
In this case, only Subtask A influences the parent task progress because Subtask B has a weight of 0%.
5. **Default Weight Behavior**:
When weights aren't explicitly assigned to some tasks:
- Subtask A: Progress 30%, Weight 60% (explicitly set)
- Subtask B: Progress 70%, Weight not set (defaults to 100%)
- Subtask C: Progress 90%, Weight not set (defaults to 100%)
Calculation:
```
ParentProgress = ((30 * 60) + (70 * 100) + (90 * 100)) / (60 + 100 + 100)
ParentProgress = (1800 + 7000 + 9000) / 260
ParentProgress = 17800 / 260
ParentProgress = 68.46%
```
Note that Subtasks B and C have more influence than Subtask A because they have higher default weights.
6. **All Zero Weights Edge Case**:
If all subtasks have zero weight, the progress is calculated as 0%:
```
ParentProgress = SUM(progress_value * 0) / SUM(0) = 0 / 0 = undefined
```
The SQL implementation handles this with `NULLIF` and `COALESCE` to return 0% in this case.
4. **Actual SQL Implementation**:
The database function implements the weighted calculation as follows:
```sql
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(weight, 100) AS weight
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
0
)
FROM subtask_progress
INTO _ratio;
```
This SQL implementation:
- Gets all non-archived subtasks of the parent task
- For each subtask, determines its progress value:
- If manual progress is set, uses that value
- Otherwise, uses 100% if the task is done or 0% if not done
- Uses COALESCE to default weight to 100 if not specified
- Calculates the weighted average, handling the case where sum of weights might be zero
- Returns 0 if there are no subtasks with weights
### Detailed Calculation for Time-based Progress Method
In Time-based Progress mode, the task's estimated time serves as its weight in progress calculations:
1. **Manual Progress Input**:
- As with weighted progress, leaf tasks require manual progress input
- Progress is entered as a percentage (0-100%)
- Completed tasks are automatically set to 100% progress
2. **Time Estimation**:
- Each task has an estimated time in hours and minutes
- These values are stored in `total_hours` and `total_minutes` fields
- Time estimates effectively function as weights in progress calculations
- Tasks with longer estimated durations have more influence on parent task progress
- Tasks with zero or no time estimate don't contribute to the parent's progress calculation
3. **Parent Task Calculation**:
The time-based progress formula is:
```
ParentProgress = ∑(SubtaskProgress * SubtaskEstimatedMinutes) / ∑(SubtaskEstimatedMinutes)
```
where `SubtaskEstimatedMinutes = (SubtaskHours * 60) + SubtaskMinutes`
**Example Calculation**:
Consider a parent task with three subtasks:
- Subtask A: Progress 40%, Estimated Time 2h 30m (150 minutes)
- Subtask B: Progress 80%, Estimated Time 1h (60 minutes)
- Subtask C: Progress 10%, Estimated Time 4h (240 minutes)
Calculation:
```
ParentProgress = ((40 * 150) + (80 * 60) + (10 * 240)) / (150 + 60 + 240)
ParentProgress = (6000 + 4800 + 2400) / 450
ParentProgress = 13200 / 450
ParentProgress = 29.33%
```
Note how Subtask C, with its large time estimate, significantly pulls down the overall progress despite Subtask B being mostly complete.
4. **Zero Time Estimate Handling**:
Tasks with zero time estimate are excluded from the calculation:
- Subtask A: Progress 40%, Estimated Time 3h (180 minutes)
- Subtask B: Progress 80%, Estimated Time 0h (0 minutes)
Calculation:
```
ParentProgress = ((40 * 180) + (80 * 0)) / (180 + 0)
ParentProgress = 7200 / 180
ParentProgress = 40%
```
In this case, only Subtask A influences the parent task progress because Subtask B has no time estimate.
5. **All Zero Time Estimates Edge Case**:
If all subtasks have zero time estimates, the progress is calculated as 0%:
```
ParentProgress = SUM(progress_value * 0) / SUM(0) = 0 / 0 = undefined
```
The SQL implementation handles this with `NULLIF` and `COALESCE` to return 0% in this case.
6. **Actual SQL Implementation**:
The SQL function for this calculation first converts hours to minutes for consistent measurement:
```sql
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
0
)
FROM subtask_progress
INTO _ratio;
```
This implementation:
- Gets all non-archived subtasks of the parent task
- Determines each subtask's progress value (manual or completion-based)
- Calculates total minutes by converting hours to minutes and adding them together
- Uses COALESCE to treat NULL time estimates as 0 minutes
- Uses NULLIF to handle cases where all time estimates are zero
- Returns 0% progress if there are no subtasks with time estimates
### Common Implementation Considerations
For both weighted and time-based progress calculation:
1. **Null Handling**:
- Tasks with NULL progress values are treated as 0% progress (unless completed)
- Tasks with NULL weights default to 100 in weighted mode
- Tasks with NULL time estimates are treated as 0 minutes in time-based mode
2. **Progress Propagation**:
- When a leaf task's progress changes, all ancestor tasks are recalculated
- Progress updates are propagated through socket events to all connected clients
- The recalculation happens server-side to ensure consistency
3. **Edge Cases**:
- If all subtasks have zero weight/time, the system falls back to a simple average
- If a parent task has no subtasks, its own manual progress value is used
- If a task is archived, it's excluded from parent task calculations
### Database Implementation
The manual progress value is stored in the `tasks` table with these relevant fields:
```sql
tasks (
-- other fields
progress_value FLOAT, -- The manually entered progress value (0-100)
manual_progress BOOLEAN, -- Flag indicating if progress was manually set
weight INTEGER DEFAULT 100, -- For weighted progress calculation
total_hours INTEGER, -- For time-based progress calculation
total_minutes INTEGER -- For time-based progress calculation
)
```
### Integration with Parent Task Calculation
When a subtask's progress is updated manually, the parent task's progress is automatically recalculated based on the active progress method:
```typescript
// Pseudocode for parent task recalculation
function recalculateParentTaskProgress(taskId, parentTaskId) {
if (!parentTaskId) return;
// Get project settings to determine active progress method
const project = getProjectByTaskId(taskId);
if (project.use_manual_progress) {
// Calculate average of all subtask progress values
updateParentProgress(parentTaskId, calculateAverageProgress(parentTaskId));
}
else if (project.use_weighted_progress) {
// Calculate weighted average using subtask weights
updateParentProgress(parentTaskId, calculateWeightedProgress(parentTaskId));
}
else if (project.use_time_progress) {
// Calculate weighted average using time estimates
updateParentProgress(parentTaskId, calculateTimeBasedProgress(parentTaskId));
}
else {
// Default: Calculate based on task completion
updateParentProgress(parentTaskId, calculateCompletionBasedProgress(parentTaskId));
}
// If this parent has a parent, continue recalculation up the tree
const grandparentId = getParentTaskId(parentTaskId);
if (grandparentId) {
recalculateParentTaskProgress(parentTaskId, grandparentId);
}
}
```
This recursive approach ensures that changes to any task's progress are properly propagated up the task hierarchy.
## Associated Files and Components
### Backend Files
1. **Socket Event Handlers**:
- `worklenz-backend/src/socket.io/commands/on-update-task-progress.ts` - Handles manual progress updates
- `worklenz-backend/src/socket.io/commands/on-update-task-weight.ts` - Handles task weight updates
2. **Database Functions**:
- `worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql` - Contains the `get_task_complete_ratio` function that calculates progress based on the selected method
- Functions that support project creation/updates with progress mode settings:
- `create_project`
- `update_project`
3. **Controllers**:
- `worklenz-backend/src/controllers/project-workload/workload-gannt-base.ts` - Contains the `calculateTaskCompleteRatio` method
- `worklenz-backend/src/controllers/projects-controller.ts` - Handles project-level progress calculations
### Frontend Files
1. **Project Configuration**:
- `worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx` - Contains UI for selecting progress method when creating/editing projects
2. **Progress Visualization Components**:
- `worklenz-frontend/src/components/project-list/project-list-table/project-list-progress/progress-list-progress.tsx` - Displays project progress
- `worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.tsx` - Displays task progress
- `worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx` - Alternative task progress cell
3. **Progress Input Components**:
- `worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx` - Component for inputting task progress/weight
## Choosing the Right Progress Method
Each progress method is suitable for different types of projects:
- **Manual Progress**: Best for creative work where progress is subjective
- **Weighted Progress**: Ideal for projects where some tasks are more significant than others
- **Time-based Progress**: Perfect for projects where time estimates are reliable and important
Project managers can choose the appropriate method when creating or editing a project in the project drawer, based on their team's workflow and project requirements.

View File

@@ -1,244 +0,0 @@
# Task Progress Tracking Methods in WorkLenz
## Overview
WorkLenz supports three different methods for tracking task progress, each suitable for different project management approaches:
1. **Manual Progress** - Direct input of progress percentages
2. **Weighted Progress** - Tasks have weights that affect overall progress calculation
3. **Time-based Progress** - Progress calculated based on estimated time vs. time spent
These modes can be selected when creating or editing a project in the project drawer.
## 1. Manual Progress Mode
This mode allows direct input of progress percentages for individual tasks without subtasks.
**Implementation:**
- Enabled by setting `use_manual_progress` to true in the project settings
- Progress is updated through the `on-update-task-progress.ts` socket event handler
- The UI shows a manual progress input slider in the task drawer for tasks without subtasks
- Updates the database with `progress_value` and sets `manual_progress` flag to true
**Calculation Logic:**
- For tasks without subtasks: Uses the manually set progress value
- For parent tasks: Calculates the average of all subtask progress values
- Subtask progress comes from either manual values or completion status (0% or 100%)
**Code Example:**
```typescript
// Manual progress update via socket.io
socket?.emit(SocketEvents.UPDATE_TASK_PROGRESS.toString(), JSON.stringify({
task_id: task.id,
progress_value: value,
parent_task_id: task.parent_task_id
}));
```
### Showing Progress in Subtask Rows
When manual progress is enabled in a project, progress is shown in the following ways:
1. **In Task List Views**:
- Subtasks display their individual progress values in the progress column
- Parent tasks display the calculated average progress of all subtasks
2. **Implementation Details**:
- The progress values are stored in the `progress_value` column in the database
- For subtasks with manual progress set, the value is shown directly
- For subtasks without manual progress, the completion status determines the value (0% or 100%)
- The task view model includes both `progress` and `complete_ratio` properties
**Relevant Components:**
```typescript
// From task-list-progress-cell.tsx
const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => {
return task.is_sub_task ? null : (
<Tooltip title={`${task.completed_count || 0} / ${task.total_tasks_count || 0}`}>
<Progress
percent={task.complete_ratio || 0}
type="circle"
size={24}
style={{ cursor: 'default' }}
/>
</Tooltip>
);
};
```
**Task Progress Calculation in Backend:**
```typescript
// From tasks-controller-base.ts
// For tasks without subtasks, respect manual progress if set
if (task.manual_progress === true && task.progress_value !== null) {
// For manually set progress, use that value directly
task.progress = parseInt(task.progress_value);
task.complete_ratio = parseInt(task.progress_value);
}
```
## 2. Weighted Progress Mode
This mode allows assigning different weights to subtasks to reflect their relative importance in the overall task or project progress.
**Implementation:**
- Enabled by setting `use_weighted_progress` to true in the project settings
- Weights are updated through the `on-update-task-weight.ts` socket event handler
- The UI shows a weight input for subtasks in the task drawer
- Default weight is 100 if not specified
**Calculation Logic:**
- Progress is calculated using a weighted average: `SUM(progress_value * weight) / SUM(weight)`
- This gives more influence to tasks with higher weights
- A parent task's progress is the weighted average of its subtasks' progress
**Code Example:**
```typescript
// Weight update via socket.io
socket?.emit(SocketEvents.UPDATE_TASK_WEIGHT.toString(), JSON.stringify({
task_id: task.id,
weight: value,
parent_task_id: task.parent_task_id
}));
```
## 3. Time-based Progress Mode
This mode calculates progress based on estimated time vs. actual time spent.
**Implementation:**
- Enabled by setting `use_time_progress` to true in the project settings
- Uses task time estimates (hours and minutes) for calculation
- No separate socket handler needed as it's calculated automatically
**Calculation Logic:**
- Progress is calculated using time as the weight: `SUM(progress_value * estimated_minutes) / SUM(estimated_minutes)`
- For tasks with time tracking, estimated vs. actual time can be factored in
- Parent task progress is weighted by the estimated time of each subtask
**SQL Example:**
```sql
WITH subtask_progress AS (
SELECT
CASE
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
0
)
FROM subtask_progress
INTO _ratio;
```
## Default Progress Tracking (when no special mode is selected)
If no specific progress mode is enabled, the system falls back to a traditional completion-based calculation:
**Implementation:**
- Default mode when all three special modes are disabled
- Based on task completion status only
**Calculation Logic:**
- For tasks without subtasks: 0% if not done, 100% if done
- For parent tasks: `(completed_tasks / total_tasks) * 100`
- Counts both the parent and all subtasks in the calculation
**SQL Example:**
```sql
-- Traditional calculation based on completion status
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
SELECT COUNT(*)
FROM tasks_with_status_view
WHERE parent_task_id = _task_id
AND is_done IS TRUE
INTO _sub_tasks_done;
_total_completed = _parent_task_done + _sub_tasks_done;
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
IF _total_tasks = 0 THEN
_ratio = 0;
ELSE
_ratio = (_total_completed / _total_tasks) * 100;
END IF;
```
## Technical Implementation Details
The progress calculation logic is implemented in PostgreSQL functions, primarily in the `get_task_complete_ratio` function. Progress updates flow through the system as follows:
1. **User Action**: User updates task progress or weight in the UI
2. **Socket Event**: Client emits socket event (UPDATE_TASK_PROGRESS or UPDATE_TASK_WEIGHT)
3. **Server Handler**: Server processes the event in the respective handler function
4. **Database Update**: Progress/weight value is updated in the database
5. **Recalculation**: If needed, parent task progress is recalculated
6. **Broadcast**: Changes are broadcast to all clients in the project room
7. **UI Update**: Client UI updates to reflect the new progress values
This architecture allows for real-time updates and consistent progress calculation across all clients.
## Associated Files and Components
### Backend Files
1. **Socket Event Handlers**:
- `worklenz-backend/src/socket.io/commands/on-update-task-progress.ts` - Handles manual progress updates
- `worklenz-backend/src/socket.io/commands/on-update-task-weight.ts` - Handles task weight updates
2. **Database Functions**:
- `worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql` - Contains the `get_task_complete_ratio` function that calculates progress based on the selected method
- Functions that support project creation/updates with progress mode settings:
- `create_project`
- `update_project`
3. **Controllers**:
- `worklenz-backend/src/controllers/project-workload/workload-gannt-base.ts` - Contains the `calculateTaskCompleteRatio` method
- `worklenz-backend/src/controllers/projects-controller.ts` - Handles project-level progress calculations
- `worklenz-backend/src/controllers/tasks-controller-base.ts` - Handles task progress calculation and updates task view models
### Frontend Files
1. **Project Configuration**:
- `worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx` - Contains UI for selecting progress method when creating/editing projects
2. **Progress Visualization Components**:
- `worklenz-frontend/src/components/project-list/project-list-table/project-list-progress/progress-list-progress.tsx` - Displays project progress
- `worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.tsx` - Displays task progress
- `worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx` - Alternative task progress cell
- `worklenz-frontend/src/components/task-list-common/task-row/task-row-progress/task-row-progress.tsx` - Displays progress in task rows
3. **Progress Input Components**:
- `worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx` - Component for inputting task progress/weight
## Choosing the Right Progress Method
Each progress method is suitable for different types of projects:
- **Manual Progress**: Best for creative work where progress is subjective
- **Weighted Progress**: Ideal for projects where some tasks are more significant than others
- **Time-based Progress**: Perfect for projects where time estimates are reliable and important
Project managers can choose the appropriate method when creating or editing a project in the project drawer, based on their team's workflow and project requirements.

View File

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

View File

@@ -1,5 +1,9 @@
node_modules
npm-debug.log
build
.scannerwork
coverage
.dockerignore
.git
*.md
tests

View File

@@ -47,17 +47,12 @@ FRONTEND_URL=http://localhost:5000
# STORAGE
STORAGE_PROVIDER=s3 # values s3 or azure
# AWS - SES
# AWS
AWS_REGION="your_aws_region"
AWS_ACCESS_KEY_ID="your_aws_access_key_id"
AWS_SECRET_ACCESS_KEY="your_aws_secret_access_key"
# S3
S3_REGION="S3_REGION"
S3_BUCKET="your_s3_bucket"
AWS_BUCKET="your_s3_bucket"
S3_URL="your_s3_url"
S3_ACCESS_KEY_ID="S3_ACCESS_KEY_ID"
S3_SECRET_ACCESS_KEY="S3_SECRET_ACCESS_KEY"
# Azure Storage
AZURE_STORAGE_ACCOUNT_NAME="your_storage_account_name"
@@ -78,8 +73,4 @@ GOOGLE_CAPTCHA_SECRET_KEY=your_captcha_secret_key
GOOGLE_CAPTCHA_PASS_SCORE=0.8
# Email Cronjobs
ENABLE_EMAIL_CRONJOBS=true
# RECURRING_JOBS
ENABLE_RECURRING_JOBS=true
RECURRING_JOBS_INTERVAL="0 11 */1 * 1-5"
ENABLE_EMAIL_CRONJOBS=true

View File

@@ -1,26 +1,39 @@
# Use the official Node.js 20 image as a base
FROM node:20
# --- Stage 1: Build ---
FROM node:20-slim AS builder
ARG RELEASE_VERSION
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
curl \
postgresql-server-dev-all \
&& rm -rf /var/lib/apt/lists/*
# Create and set the working directory
WORKDIR /usr/src/app
# Install global dependencies
RUN npm install -g ts-node typescript grunt grunt-cli
# Copy package.json and package-lock.json (if available)
COPY package*.json ./
# Install app dependencies
RUN npm ci
# Copy the rest of the application code
COPY . .
# Run the build script to compile TypeScript to JavaScript
RUN npm run build
# Expose the port the app runs on
EXPOSE 3000
RUN echo "$RELEASE_VERSION" > release
# --- Stage 2: Production Image ---
FROM node:20-slim
RUN apt-get update && apt-get install -y libpq5 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /usr/src/app/package*.json ./
COPY --from=builder /usr/src/app/build ./build
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/release ./release
COPY --from=builder /usr/src/app/worklenz-email-templates ./worklenz-email-templates
EXPOSE 3000
CMD ["node", "build/bin/www"]
# Start the application
CMD ["npm", "start"]

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

@@ -1,228 +0,0 @@
-- Migration: Add recursive task estimation functionality
-- This migration adds a function to calculate recursive task estimation including all subtasks
-- and modifies the get_task_form_view_model function to include this data
BEGIN;
-- Function to calculate recursive task estimation (including all subtasks)
CREATE OR REPLACE FUNCTION get_task_recursive_estimation(_task_id UUID) RETURNS JSON
LANGUAGE plpgsql
AS
$$
DECLARE
_result JSON;
_has_subtasks BOOLEAN;
BEGIN
-- First check if this task has any subtasks
SELECT EXISTS(
SELECT 1 FROM tasks
WHERE parent_task_id = _task_id
AND archived = false
) INTO _has_subtasks;
-- If task has subtasks, calculate recursive estimation excluding parent's own estimation
IF _has_subtasks THEN
WITH RECURSIVE task_tree AS (
-- Start with direct subtasks only (exclude the parent task itself)
SELECT
id,
parent_task_id,
COALESCE(total_minutes, 0) as total_minutes,
1 as level -- Start at level 1 (subtasks)
FROM tasks
WHERE parent_task_id = _task_id
AND archived = false
UNION ALL
-- Recursive case: Get all descendant tasks
SELECT
t.id,
t.parent_task_id,
COALESCE(t.total_minutes, 0) as total_minutes,
tt.level + 1 as level
FROM tasks t
INNER JOIN task_tree tt ON t.parent_task_id = tt.id
WHERE t.archived = false
),
task_counts AS (
SELECT
COUNT(*) as sub_tasks_count,
SUM(total_minutes) as subtasks_total_minutes -- Sum all subtask estimations
FROM task_tree
)
SELECT JSON_BUILD_OBJECT(
'sub_tasks_count', COALESCE(tc.sub_tasks_count, 0),
'own_total_minutes', 0, -- Always 0 for parent tasks
'subtasks_total_minutes', COALESCE(tc.subtasks_total_minutes, 0),
'recursive_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), -- Only subtasks total
'recursive_total_hours', FLOOR(COALESCE(tc.subtasks_total_minutes, 0) / 60),
'recursive_remaining_minutes', COALESCE(tc.subtasks_total_minutes, 0) % 60
)
INTO _result
FROM task_counts tc;
ELSE
-- If task has no subtasks, use its own estimation
SELECT JSON_BUILD_OBJECT(
'sub_tasks_count', 0,
'own_total_minutes', COALESCE(total_minutes, 0),
'subtasks_total_minutes', 0,
'recursive_total_minutes', COALESCE(total_minutes, 0), -- Use own estimation
'recursive_total_hours', FLOOR(COALESCE(total_minutes, 0) / 60),
'recursive_remaining_minutes', COALESCE(total_minutes, 0) % 60
)
INTO _result
FROM tasks
WHERE id = _task_id;
END IF;
RETURN COALESCE(_result, JSON_BUILD_OBJECT(
'sub_tasks_count', 0,
'own_total_minutes', 0,
'subtasks_total_minutes', 0,
'recursive_total_minutes', 0,
'recursive_total_hours', 0,
'recursive_remaining_minutes', 0
));
END;
$$;
-- Update the get_task_form_view_model function to include recursive estimation
CREATE OR REPLACE FUNCTION public.get_task_form_view_model(_user_id UUID, _team_id UUID, _task_id UUID, _project_id UUID) RETURNS JSON
LANGUAGE plpgsql
AS
$$
DECLARE
_task JSON;
_priorities JSON;
_projects JSON;
_statuses JSON;
_team_members JSON;
_assignees JSON;
_phases JSON;
BEGIN
-- Select task info
SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
INTO _task
FROM (WITH RECURSIVE task_hierarchy AS (
-- Base case: Start with the given task
SELECT id,
parent_task_id,
0 AS level
FROM tasks
WHERE id = _task_id
UNION ALL
-- Recursive case: Traverse up to parent tasks
SELECT t.id,
t.parent_task_id,
th.level + 1 AS level
FROM tasks t
INNER JOIN task_hierarchy th ON t.id = th.parent_task_id
WHERE th.parent_task_id IS NOT NULL)
SELECT id,
name,
description,
start_date,
end_date,
done,
total_minutes,
priority_id,
project_id,
created_at,
updated_at,
status_id,
parent_task_id,
sort_order,
(SELECT phase_id FROM task_phase WHERE task_id = tasks.id) AS phase_id,
CONCAT((SELECT key FROM projects WHERE id = tasks.project_id), '-', task_no) AS task_key,
(SELECT start_time
FROM task_timers
WHERE task_id = tasks.id
AND user_id = _user_id) AS timer_start_time,
parent_task_id IS NOT NULL AS is_sub_task,
(SELECT COUNT('*')
FROM tasks
WHERE parent_task_id = tasks.id
AND archived IS FALSE) AS sub_tasks_count,
(SELECT COUNT(*)
FROM tasks_with_status_view tt
WHERE (tt.parent_task_id = tasks.id OR tt.task_id = tasks.id)
AND tt.is_done IS TRUE)
AS completed_count,
(SELECT COUNT(*) FROM task_attachments WHERE task_id = tasks.id) AS attachments_count,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON)
FROM (SELECT task_labels.label_id AS id,
(SELECT name FROM team_labels WHERE id = task_labels.label_id),
(SELECT color_code FROM team_labels WHERE id = task_labels.label_id)
FROM task_labels
WHERE task_id = tasks.id
ORDER BY name) r) AS labels,
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color,
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id) AS sub_tasks_count,
(SELECT name FROM users WHERE id = tasks.reporter_id) AS reporter,
(SELECT get_task_assignees(tasks.id)) AS assignees,
(SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id) AS team_member_id,
billable,
schedule_id,
progress_value,
weight,
(SELECT MAX(level) FROM task_hierarchy) AS task_level,
(SELECT get_task_recursive_estimation(tasks.id)) AS recursive_estimation
FROM tasks
WHERE id = _task_id) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _priorities
FROM (SELECT id, name FROM task_priorities ORDER BY value) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _phases
FROM (SELECT id, name FROM project_phases WHERE project_id = _project_id ORDER BY name) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _projects
FROM (SELECT id, name
FROM projects
WHERE team_id = _team_id
AND (CASE
WHEN (is_owner(_user_id, _team_id) OR is_admin(_user_id, _team_id) IS TRUE) THEN TRUE
ELSE is_member_of_project(projects.id, _user_id, _team_id) END)
ORDER BY name) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _statuses
FROM (SELECT id, name FROM task_statuses WHERE project_id = _project_id) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _team_members
FROM (SELECT team_members.id,
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
(SELECT avatar_url
FROM team_member_info_view
WHERE team_member_info_view.team_member_id = team_members.id)
FROM team_members
LEFT JOIN users u ON team_members.user_id = u.id
WHERE team_id = _team_id
AND team_members.active IS TRUE) rec;
SELECT get_task_assignees(_task_id) INTO _assignees;
RETURN JSON_BUILD_OBJECT(
'task', _task,
'priorities', _priorities,
'projects', _projects,
'statuses', _statuses,
'team_members', _team_members,
'assignees', _assignees,
'phases', _phases
);
END;
$$;
COMMIT;

View File

@@ -1,20 +0,0 @@
-- Migration: Add currency column to projects table
-- Date: 2025-01-17
-- Description: Adds project-specific currency support to allow different projects to use different currencies
-- Add currency column to projects table
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'USD';
-- Add comment for documentation
COMMENT ON COLUMN projects.currency IS 'Project-specific currency code (e.g., USD, EUR, GBP, JPY, etc.)';
-- Add constraint to ensure currency codes are uppercase and 3 characters
ALTER TABLE projects
ADD CONSTRAINT projects_currency_format_check
CHECK (currency ~ '^[A-Z]{3}$');
-- Update existing projects to have a default currency if they don't have one
UPDATE projects
SET currency = 'USD'
WHERE currency IS NULL;

View File

@@ -1,78 +0,0 @@
-- Migration: Add manual task progress
-- Date: 2025-04-22
-- Version: 1.0.0
BEGIN;
-- Add manual progress fields to tasks table
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS manual_progress BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS progress_value INTEGER DEFAULT NULL,
ADD COLUMN IF NOT EXISTS weight INTEGER DEFAULT NULL;
-- Update function to consider manual progress
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_parent_task_done FLOAT = 0;
_sub_tasks_done FLOAT = 0;
_sub_tasks_count FLOAT = 0;
_total_completed FLOAT = 0;
_total_tasks FLOAT = 0;
_ratio FLOAT = 0;
_is_manual BOOLEAN = FALSE;
_manual_value INTEGER = NULL;
BEGIN
-- Check if manual progress is set
SELECT manual_progress, progress_value
FROM tasks
WHERE id = _task_id
INTO _is_manual, _manual_value;
-- If manual progress is enabled and has a value, use it directly
IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN
RETURN JSON_BUILD_OBJECT(
'ratio', _manual_value,
'total_completed', 0,
'total_tasks', 0,
'is_manual', TRUE
);
END IF;
-- Otherwise calculate automatically as before
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id AND archived IS FALSE INTO _sub_tasks_count;
SELECT COUNT(*)
FROM tasks_with_status_view
WHERE parent_task_id = _task_id
AND is_done IS TRUE
INTO _sub_tasks_done;
_total_completed = _parent_task_done + _sub_tasks_done;
_total_tasks = _sub_tasks_count; -- +1 for the parent task
IF _total_tasks > 0 THEN
_ratio = (_total_completed / _total_tasks) * 100;
ELSE
_ratio = _parent_task_done * 100;
END IF;
RETURN JSON_BUILD_OBJECT(
'ratio', _ratio,
'total_completed', _total_completed,
'total_tasks', _total_tasks,
'is_manual', FALSE
);
END
$$;
COMMIT;

View File

@@ -1,818 +0,0 @@
-- Migration: Enhance manual task progress with subtask support
-- Date: 2025-04-23
-- Version: 1.0.0
BEGIN;
-- Update function to consider subtask manual progress when calculating parent task progress
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_parent_task_done FLOAT = 0;
_sub_tasks_done FLOAT = 0;
_sub_tasks_count FLOAT = 0;
_total_completed FLOAT = 0;
_total_tasks FLOAT = 0;
_ratio FLOAT = 0;
_is_manual BOOLEAN = FALSE;
_manual_value INTEGER = NULL;
_project_id UUID;
_use_manual_progress BOOLEAN = FALSE;
_use_weighted_progress BOOLEAN = FALSE;
_use_time_progress BOOLEAN = FALSE;
BEGIN
-- Check if manual progress is set for this task
SELECT manual_progress, progress_value, project_id
FROM tasks
WHERE id = _task_id
INTO _is_manual, _manual_value, _project_id;
-- Check if the project uses manual progress
IF _project_id IS NOT NULL THEN
SELECT COALESCE(use_manual_progress, FALSE),
COALESCE(use_weighted_progress, FALSE),
COALESCE(use_time_progress, FALSE)
FROM projects
WHERE id = _project_id
INTO _use_manual_progress, _use_weighted_progress, _use_time_progress;
END IF;
-- Get all subtasks
SELECT COUNT(*)
FROM tasks
WHERE parent_task_id = _task_id AND archived IS FALSE
INTO _sub_tasks_count;
-- If manual progress is enabled and has a value AND there are no subtasks, use it directly
IF _is_manual IS TRUE AND _manual_value IS NOT NULL AND _sub_tasks_count = 0 THEN
RETURN JSON_BUILD_OBJECT(
'ratio', _manual_value,
'total_completed', 0,
'total_tasks', 0,
'is_manual', TRUE
);
END IF;
-- If there are no subtasks, just use the parent task's status
IF _sub_tasks_count = 0 THEN
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
_ratio = _parent_task_done * 100;
ELSE
-- If project uses manual progress, calculate based on subtask manual progress values
IF _use_manual_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(AVG(progress_value), 0)
FROM subtask_progress
INTO _ratio;
-- If project uses weighted progress, calculate based on subtask weights
ELSIF _use_weighted_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(weight, 100) AS weight
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
0
)
FROM subtask_progress
INTO _ratio;
-- If project uses time-based progress, calculate based on estimated time
ELSIF _use_time_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(total_minutes, 0) AS estimated_minutes
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
0
)
FROM subtask_progress
INTO _ratio;
ELSE
-- Traditional calculation based on completion status
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
SELECT COUNT(*)
FROM tasks_with_status_view
WHERE parent_task_id = _task_id
AND is_done IS TRUE
INTO _sub_tasks_done;
_total_completed = _parent_task_done + _sub_tasks_done;
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
IF _total_tasks = 0 THEN
_ratio = 0;
ELSE
_ratio = (_total_completed / _total_tasks) * 100;
END IF;
END IF;
END IF;
-- Ensure ratio is between 0 and 100
IF _ratio < 0 THEN
_ratio = 0;
ELSIF _ratio > 100 THEN
_ratio = 100;
END IF;
RETURN JSON_BUILD_OBJECT(
'ratio', _ratio,
'total_completed', _total_completed,
'total_tasks', _total_tasks,
'is_manual', _is_manual
);
END
$$;
CREATE OR REPLACE FUNCTION update_project(_body json) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_user_id UUID;
_team_id UUID;
_client_id UUID;
_project_id UUID;
_project_manager_team_member_id UUID;
_client_name TEXT;
_project_name TEXT;
BEGIN
-- need a test, can be throw errors
_client_name = TRIM((_body ->> 'client_name')::TEXT);
_project_name = TRIM((_body ->> 'name')::TEXT);
-- add inside the controller
_user_id = (_body ->> 'user_id')::UUID;
_team_id = (_body ->> 'team_id')::UUID;
_project_manager_team_member_id = (_body ->> 'team_member_id')::UUID;
-- cache exists client if exists
SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id;
-- insert client if not exists
IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE
THEN
INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id;
END IF;
-- check whether the project name is already in
IF EXISTS(
SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name)
AND team_id = _team_id AND id != (_body ->> 'id')::UUID
)
THEN
RAISE 'PROJECT_EXISTS_ERROR:%', _project_name;
END IF;
-- update the project
UPDATE projects
SET name = _project_name,
notes = (_body ->> 'notes')::TEXT,
color_code = (_body ->> 'color_code')::TEXT,
status_id = (_body ->> 'status_id')::UUID,
health_id = (_body ->> 'health_id')::UUID,
key = (_body ->> 'key')::TEXT,
start_date = (_body ->> 'start_date')::TIMESTAMPTZ,
end_date = (_body ->> 'end_date')::TIMESTAMPTZ,
client_id = _client_id,
folder_id = (_body ->> 'folder_id')::UUID,
category_id = (_body ->> 'category_id')::UUID,
updated_at = CURRENT_TIMESTAMP,
estimated_working_days = (_body ->> 'working_days')::INTEGER,
estimated_man_days = (_body ->> 'man_days')::INTEGER,
hours_per_day = (_body ->> 'hours_per_day')::INTEGER,
use_manual_progress = COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE),
use_weighted_progress = COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE),
use_time_progress = COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE)
WHERE id = (_body ->> 'id')::UUID
AND team_id = _team_id
RETURNING id INTO _project_id;
UPDATE project_members SET project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'MEMBER') WHERE project_id = _project_id;
IF NOT (_project_manager_team_member_id IS NULL)
THEN
PERFORM update_project_manager(_project_manager_team_member_id, _project_id::UUID);
END IF;
RETURN JSON_BUILD_OBJECT(
'id', _project_id,
'name', (_body ->> 'name')::TEXT,
'project_manager_id', _project_manager_team_member_id::UUID
);
END;
$$;
-- 3. Also modify the create_project function to handle the new fields during project creation
CREATE OR REPLACE FUNCTION create_project(_body json) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_project_id UUID;
_user_id UUID;
_team_id UUID;
_team_member_id UUID;
_client_id UUID;
_client_name TEXT;
_project_name TEXT;
_project_created_log TEXT;
_project_member_added_log TEXT;
_project_created_log_id UUID;
_project_manager_team_member_id UUID;
_project_key TEXT;
BEGIN
_client_name = TRIM((_body ->> 'client_name')::TEXT);
_project_name = TRIM((_body ->> 'name')::TEXT);
_project_key = TRIM((_body ->> 'key')::TEXT);
_project_created_log = (_body ->> 'project_created_log')::TEXT;
_project_member_added_log = (_body ->> 'project_member_added_log')::TEXT;
_user_id = (_body ->> 'user_id')::UUID;
_team_id = (_body ->> 'team_id')::UUID;
_project_manager_team_member_id = (_body ->> 'project_manager_id')::UUID;
SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id INTO _team_member_id;
-- cache exists client if exists
SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id;
-- insert client if not exists
IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE
THEN
INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id;
END IF;
-- check whether the project name is already in
IF EXISTS(SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name) AND team_id = _team_id)
THEN
RAISE 'PROJECT_EXISTS_ERROR:%', _project_name;
END IF;
-- create the project
INSERT
INTO projects (name, key, color_code, start_date, end_date, team_id, notes, owner_id, status_id, health_id, folder_id,
category_id, estimated_working_days, estimated_man_days, hours_per_day,
use_manual_progress, use_weighted_progress, use_time_progress, client_id)
VALUES (_project_name,
UPPER(_project_key),
(_body ->> 'color_code')::TEXT,
(_body ->> 'start_date')::TIMESTAMPTZ,
(_body ->> 'end_date')::TIMESTAMPTZ,
_team_id,
(_body ->> 'notes')::TEXT,
_user_id,
(_body ->> 'status_id')::UUID,
(_body ->> 'health_id')::UUID,
(_body ->> 'folder_id')::UUID,
(_body ->> 'category_id')::UUID,
(_body ->> 'working_days')::INTEGER,
(_body ->> 'man_days')::INTEGER,
(_body ->> 'hours_per_day')::INTEGER,
COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE),
COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE),
COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE),
_client_id)
RETURNING id INTO _project_id;
-- register the project log
INSERT INTO project_logs (project_id, team_id, description)
VALUES (_project_id, _team_id, _project_created_log)
RETURNING id INTO _project_created_log_id;
-- insert the project creator as a project member
INSERT INTO project_members (team_member_id, project_access_level_id, project_id, role_id)
VALUES (_team_member_id, (SELECT id FROM project_access_levels WHERE key = 'ADMIN'),
_project_id,
(SELECT id FROM roles WHERE team_id = _team_id AND default_role IS TRUE));
-- insert statuses
INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order)
VALUES ('To Do', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE), 0);
INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order)
VALUES ('Doing', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_doing IS TRUE), 1);
INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order)
VALUES ('Done', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE), 2);
-- insert default project columns
PERFORM insert_task_list_columns(_project_id);
-- add project manager role if exists
IF NOT is_null_or_empty(_project_manager_team_member_id) THEN
PERFORM update_project_manager(_project_manager_team_member_id, _project_id);
END IF;
RETURN JSON_BUILD_OBJECT(
'id', _project_id,
'name', _project_name,
'project_created_log_id', _project_created_log_id
);
END;
$$;
-- 4. Update the getById function to include the new fields in the response
CREATE OR REPLACE FUNCTION getProjectById(_project_id UUID, _team_id UUID) RETURNS JSON
LANGUAGE plpgsql
AS
$$
DECLARE
_result JSON;
BEGIN
SELECT ROW_TO_JSON(rec) INTO _result
FROM (SELECT p.id,
p.name,
p.key,
p.color_code,
p.start_date,
p.end_date,
c.name AS client_name,
c.id AS client_id,
p.notes,
p.created_at,
p.updated_at,
ts.name AS status,
ts.color_code AS status_color,
ts.icon AS status_icon,
ts.id AS status_id,
h.name AS health,
h.color_code AS health_color,
h.icon AS health_icon,
h.id AS health_id,
pc.name AS category_name,
pc.color_code AS category_color,
pc.id AS category_id,
p.phase_label,
p.estimated_man_days AS man_days,
p.estimated_working_days AS working_days,
p.hours_per_day,
p.use_manual_progress,
p.use_weighted_progress,
-- Additional fields
COALESCE((SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t)))
FROM (SELECT pm.id,
pm.project_id,
tm.id AS team_member_id,
tm.user_id,
u.name,
u.email,
u.avatar_url,
u.phone_number,
pal.name AS access_level,
pal.key AS access_level_key,
pal.id AS access_level_id,
EXISTS(SELECT 1
FROM project_members
INNER JOIN project_access_levels ON
project_members.project_access_level_id = project_access_levels.id
WHERE project_id = p.id
AND project_access_levels.key = 'PROJECT_MANAGER'
AND team_member_id = tm.id) AS is_project_manager
FROM project_members pm
INNER JOIN team_members tm ON pm.team_member_id = tm.id
INNER JOIN users u ON tm.user_id = u.id
INNER JOIN project_access_levels pal ON pm.project_access_level_id = pal.id
WHERE pm.project_id = p.id) t), '[]'::JSON) AS members,
(SELECT COUNT(DISTINCT (id))
FROM tasks
WHERE archived IS FALSE
AND project_id = p.id) AS task_count,
(SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t)))
FROM (SELECT project_members.id,
project_members.project_id,
team_members.id AS team_member_id,
team_members.user_id,
users.name,
users.email,
users.avatar_url,
project_access_levels.name AS access_level,
project_access_levels.key AS access_level_key,
project_access_levels.id AS access_level_id
FROM project_members
INNER JOIN team_members ON project_members.team_member_id = team_members.id
INNER JOIN users ON team_members.user_id = users.id
INNER JOIN project_access_levels
ON project_members.project_access_level_id = project_access_levels.id
WHERE project_id = p.id
AND project_access_levels.key = 'PROJECT_MANAGER'
LIMIT 1) t) AS project_manager,
(SELECT EXISTS(SELECT 1
FROM project_subscribers
WHERE project_id = p.id
AND user_id = (SELECT user_id
FROM project_members
WHERE team_member_id = (SELECT id
FROM team_members
WHERE user_id IN
(SELECT user_id FROM is_member_of_project_cte))
AND project_id = p.id))) AS subscribed,
(SELECT name
FROM users
WHERE id =
(SELECT owner_id FROM projects WHERE id = p.id)) AS project_owner,
(SELECT default_view
FROM project_members
WHERE project_id = p.id
AND team_member_id IN (SELECT id FROM is_member_of_project_cte)) AS team_member_default_view,
(SELECT EXISTS(SELECT user_id
FROM archived_projects
WHERE user_id IN (SELECT user_id FROM is_member_of_project_cte)
AND project_id = p.id)) AS archived,
(SELECT EXISTS(SELECT user_id
FROM favorite_projects
WHERE user_id IN (SELECT user_id FROM is_member_of_project_cte)
AND project_id = p.id)) AS favorite
FROM projects p
LEFT JOIN sys_project_statuses ts ON p.status_id = ts.id
LEFT JOIN sys_project_healths h ON p.health_id = h.id
LEFT JOIN project_categories pc ON p.category_id = pc.id
LEFT JOIN clients c ON p.client_id = c.id,
LATERAL (SELECT id, user_id
FROM team_members
WHERE id = (SELECT team_member_id
FROM project_members
WHERE project_id = p.id
AND team_member_id IN (SELECT id
FROM team_members
WHERE team_id = _team_id)
LIMIT 1)) is_member_of_project_cte
WHERE p.id = _project_id
AND p.team_id = _team_id) rec;
RETURN _result;
END
$$;
CREATE OR REPLACE FUNCTION public.get_task_form_view_model(_user_id UUID, _team_id UUID, _task_id UUID, _project_id UUID) RETURNS JSON
LANGUAGE plpgsql
AS
$$
DECLARE
_task JSON;
_priorities JSON;
_projects JSON;
_statuses JSON;
_team_members JSON;
_assignees JSON;
_phases JSON;
BEGIN
-- Select task info
SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
INTO _task
FROM (WITH RECURSIVE task_hierarchy AS (
-- Base case: Start with the given task
SELECT id,
parent_task_id,
0 AS level
FROM tasks
WHERE id = _task_id
UNION ALL
-- Recursive case: Traverse up to parent tasks
SELECT t.id,
t.parent_task_id,
th.level + 1 AS level
FROM tasks t
INNER JOIN task_hierarchy th ON t.id = th.parent_task_id
WHERE th.parent_task_id IS NOT NULL)
SELECT id,
name,
description,
start_date,
end_date,
done,
total_minutes,
priority_id,
project_id,
created_at,
updated_at,
status_id,
parent_task_id,
sort_order,
(SELECT phase_id FROM task_phase WHERE task_id = tasks.id) AS phase_id,
CONCAT((SELECT key FROM projects WHERE id = tasks.project_id), '-', task_no) AS task_key,
(SELECT start_time
FROM task_timers
WHERE task_id = tasks.id
AND user_id = _user_id) AS timer_start_time,
parent_task_id IS NOT NULL AS is_sub_task,
(SELECT COUNT('*')
FROM tasks
WHERE parent_task_id = tasks.id
AND archived IS FALSE) AS sub_tasks_count,
(SELECT COUNT(*)
FROM tasks_with_status_view tt
WHERE (tt.parent_task_id = tasks.id OR tt.task_id = tasks.id)
AND tt.is_done IS TRUE)
AS completed_count,
(SELECT COUNT(*) FROM task_attachments WHERE task_id = tasks.id) AS attachments_count,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON)
FROM (SELECT task_labels.label_id AS id,
(SELECT name FROM team_labels WHERE id = task_labels.label_id),
(SELECT color_code FROM team_labels WHERE id = task_labels.label_id)
FROM task_labels
WHERE task_id = tasks.id
ORDER BY name) r) AS labels,
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color,
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id) AS sub_tasks_count,
(SELECT name FROM users WHERE id = tasks.reporter_id) AS reporter,
(SELECT get_task_assignees(tasks.id)) AS assignees,
(SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id) AS team_member_id,
billable,
schedule_id,
progress_value,
weight,
(SELECT MAX(level) FROM task_hierarchy) AS task_level,
(SELECT get_task_recursive_estimation(tasks.id)) AS recursive_estimation
FROM tasks
WHERE id = _task_id) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _priorities
FROM (SELECT id, name FROM task_priorities ORDER BY value) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _phases
FROM (SELECT id, name FROM project_phases WHERE project_id = _project_id ORDER BY name) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _projects
FROM (SELECT id, name
FROM projects
WHERE team_id = _team_id
AND (CASE
WHEN (is_owner(_user_id, _team_id) OR is_admin(_user_id, _team_id) IS TRUE) THEN TRUE
ELSE is_member_of_project(projects.id, _user_id, _team_id) END)
ORDER BY name) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _statuses
FROM (SELECT id, name FROM task_statuses WHERE project_id = _project_id) rec;
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
INTO _team_members
FROM (SELECT team_members.id,
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
(SELECT avatar_url
FROM team_member_info_view
WHERE team_member_info_view.team_member_id = team_members.id)
FROM team_members
LEFT JOIN users u ON team_members.user_id = u.id
WHERE team_id = _team_id
AND team_members.active IS TRUE) rec;
SELECT get_task_assignees(_task_id) INTO _assignees;
RETURN JSON_BUILD_OBJECT(
'task', _task,
'priorities', _priorities,
'projects', _projects,
'statuses', _statuses,
'team_members', _team_members,
'assignees', _assignees,
'phases', _phases
);
END;
$$;
-- Add use_manual_progress, use_weighted_progress, and use_time_progress to projects table if they don't exist
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS use_weighted_progress BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE;
-- Function to calculate recursive task estimation (including all subtasks)
CREATE OR REPLACE FUNCTION get_task_recursive_estimation(_task_id UUID) RETURNS JSON
LANGUAGE plpgsql
AS
$$
DECLARE
_result JSON;
_has_subtasks BOOLEAN;
BEGIN
-- First check if this task has any subtasks
SELECT EXISTS(
SELECT 1 FROM tasks
WHERE parent_task_id = _task_id
AND archived = false
) INTO _has_subtasks;
-- If task has subtasks, calculate recursive estimation excluding parent's own estimation
IF _has_subtasks THEN
WITH RECURSIVE task_tree AS (
-- Start with direct subtasks only (exclude the parent task itself)
SELECT
id,
parent_task_id,
COALESCE(total_minutes, 0) as total_minutes,
1 as level -- Start at level 1 (subtasks)
FROM tasks
WHERE parent_task_id = _task_id
AND archived = false
UNION ALL
-- Recursive case: Get all descendant tasks
SELECT
t.id,
t.parent_task_id,
COALESCE(t.total_minutes, 0) as total_minutes,
tt.level + 1 as level
FROM tasks t
INNER JOIN task_tree tt ON t.parent_task_id = tt.id
WHERE t.archived = false
),
task_counts AS (
SELECT
COUNT(*) as sub_tasks_count,
SUM(total_minutes) as subtasks_total_minutes -- Sum all subtask estimations
FROM task_tree
)
SELECT JSON_BUILD_OBJECT(
'sub_tasks_count', COALESCE(tc.sub_tasks_count, 0),
'own_total_minutes', 0, -- Always 0 for parent tasks
'subtasks_total_minutes', COALESCE(tc.subtasks_total_minutes, 0),
'recursive_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), -- Only subtasks total
'recursive_total_hours', FLOOR(COALESCE(tc.subtasks_total_minutes, 0) / 60),
'recursive_remaining_minutes', COALESCE(tc.subtasks_total_minutes, 0) % 60
)
INTO _result
FROM task_counts tc;
ELSE
-- If task has no subtasks, use its own estimation
SELECT JSON_BUILD_OBJECT(
'sub_tasks_count', 0,
'own_total_minutes', COALESCE(total_minutes, 0),
'subtasks_total_minutes', 0,
'recursive_total_minutes', COALESCE(total_minutes, 0), -- Use own estimation
'recursive_total_hours', FLOOR(COALESCE(total_minutes, 0) / 60),
'recursive_remaining_minutes', COALESCE(total_minutes, 0) % 60
)
INTO _result
FROM tasks
WHERE id = _task_id;
END IF;
RETURN COALESCE(_result, JSON_BUILD_OBJECT(
'sub_tasks_count', 0,
'own_total_minutes', 0,
'subtasks_total_minutes', 0,
'recursive_total_minutes', 0,
'recursive_total_hours', 0,
'recursive_remaining_minutes', 0
));
END;
$$;
-- Add a trigger to reset manual progress when a task gets a new subtask
CREATE OR REPLACE FUNCTION reset_parent_task_manual_progress() RETURNS TRIGGER AS
$$
BEGIN
-- When a task gets a new subtask (parent_task_id is set), reset the parent's manual_progress flag
IF NEW.parent_task_id IS NOT NULL THEN
UPDATE tasks
SET manual_progress = false
WHERE id = NEW.parent_task_id
AND manual_progress = true;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Add a trigger to reset parent task estimation when it gets subtasks
CREATE OR REPLACE FUNCTION reset_parent_task_estimation() RETURNS TRIGGER AS
$$
BEGIN
-- When a task gets a new subtask (parent_task_id is set), reset the parent's total_minutes to 0
-- This ensures parent tasks don't have their own estimation when they have subtasks
IF NEW.parent_task_id IS NOT NULL THEN
UPDATE tasks
SET total_minutes = 0
WHERE id = NEW.parent_task_id
AND total_minutes > 0;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create the trigger on the tasks table
DROP TRIGGER IF EXISTS reset_parent_manual_progress_trigger ON tasks;
CREATE TRIGGER reset_parent_manual_progress_trigger
AFTER INSERT OR UPDATE OF parent_task_id ON tasks
FOR EACH ROW
EXECUTE FUNCTION reset_parent_task_manual_progress();
-- Create the trigger to reset parent task estimation
DROP TRIGGER IF EXISTS reset_parent_estimation_trigger ON tasks;
CREATE TRIGGER reset_parent_estimation_trigger
AFTER INSERT OR UPDATE OF parent_task_id ON tasks
FOR EACH ROW
EXECUTE FUNCTION reset_parent_task_estimation();
-- Function to reset all existing parent tasks' estimations to 0
CREATE OR REPLACE FUNCTION reset_all_parent_task_estimations() RETURNS INTEGER AS
$$
DECLARE
_updated_count INTEGER;
BEGIN
-- Update all tasks that have subtasks to have 0 estimation
UPDATE tasks
SET total_minutes = 0
WHERE id IN (
SELECT DISTINCT parent_task_id
FROM tasks
WHERE parent_task_id IS NOT NULL
AND archived = false
)
AND total_minutes > 0
AND archived = false;
GET DIAGNOSTICS _updated_count = ROW_COUNT;
RETURN _updated_count;
END;
$$ LANGUAGE plpgsql;
COMMIT;

View File

@@ -1,157 +0,0 @@
-- Migration: Add progress and weight activity types support
-- Date: 2025-04-24
-- Version: 1.0.0
BEGIN;
-- Update the get_activity_logs_by_task function to handle progress and weight attribute types
CREATE OR REPLACE FUNCTION get_activity_logs_by_task(_task_id uuid) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_result JSON;
BEGIN
SELECT ROW_TO_JSON(rec)
INTO _result
FROM (SELECT (SELECT tasks.created_at FROM tasks WHERE tasks.id = _task_id),
(SELECT name
FROM users
WHERE id = (SELECT reporter_id FROM tasks WHERE id = _task_id)),
(SELECT avatar_url
FROM users
WHERE id = (SELECT reporter_id FROM tasks WHERE id = _task_id)),
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec2))), '[]'::JSON)
FROM (SELECT task_id,
created_at,
attribute_type,
log_type,
-- Case for previous value
(CASE
WHEN (attribute_type = 'status')
THEN (SELECT name FROM task_statuses WHERE id = old_value::UUID)
WHEN (attribute_type = 'priority')
THEN (SELECT name FROM task_priorities WHERE id = old_value::UUID)
WHEN (attribute_type = 'phase' AND old_value <> 'Unmapped')
THEN (SELECT name FROM project_phases WHERE id = old_value::UUID)
WHEN (attribute_type = 'progress' OR attribute_type = 'weight')
THEN old_value
ELSE (old_value) END) AS previous,
-- Case for current value
(CASE
WHEN (attribute_type = 'assignee')
THEN (SELECT name FROM users WHERE id = new_value::UUID)
WHEN (attribute_type = 'label')
THEN (SELECT name FROM team_labels WHERE id = new_value::UUID)
WHEN (attribute_type = 'status')
THEN (SELECT name FROM task_statuses WHERE id = new_value::UUID)
WHEN (attribute_type = 'priority')
THEN (SELECT name FROM task_priorities WHERE id = new_value::UUID)
WHEN (attribute_type = 'phase' AND new_value <> 'Unmapped')
THEN (SELECT name FROM project_phases WHERE id = new_value::UUID)
WHEN (attribute_type = 'progress' OR attribute_type = 'weight')
THEN new_value
ELSE (new_value) END) AS current,
-- Case for assigned user
(CASE
WHEN (attribute_type = 'assignee')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (CASE
WHEN (new_value IS NOT NULL)
THEN (SELECT name FROM users WHERE users.id = new_value::UUID)
ELSE (next_string) END) AS name,
(SELECT avatar_url FROM users WHERE users.id = new_value::UUID)) rec)
ELSE (NULL) END) AS assigned_user,
-- Case for label data
(CASE
WHEN (attribute_type = 'label')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM team_labels WHERE id = new_value::UUID),
(SELECT color_code FROM team_labels WHERE id = new_value::UUID)) rec)
ELSE (NULL) END) AS label_data,
-- Case for previous status
(CASE
WHEN (attribute_type = 'status')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM task_statuses WHERE id = old_value::UUID),
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = old_value::UUID)),
(SELECT color_code_dark
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = old_value::UUID))) rec)
ELSE (NULL) END) AS previous_status,
-- Case for next status
(CASE
WHEN (attribute_type = 'status')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM task_statuses WHERE id = new_value::UUID),
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = new_value::UUID)),
(SELECT color_code_dark
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = new_value::UUID))) rec)
ELSE (NULL) END) AS next_status,
-- Case for previous priority
(CASE
WHEN (attribute_type = 'priority')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM task_priorities WHERE id = old_value::UUID),
(SELECT color_code FROM task_priorities WHERE id = old_value::UUID)) rec)
ELSE (NULL) END) AS previous_priority,
-- Case for next priority
(CASE
WHEN (attribute_type = 'priority')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM task_priorities WHERE id = new_value::UUID),
(SELECT color_code FROM task_priorities WHERE id = new_value::UUID)) rec)
ELSE (NULL) END) AS next_priority,
-- Case for previous phase
(CASE
WHEN (attribute_type = 'phase' AND old_value <> 'Unmapped')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM project_phases WHERE id = old_value::UUID),
(SELECT color_code FROM project_phases WHERE id = old_value::UUID)) rec)
ELSE (NULL) END) AS previous_phase,
-- Case for next phase
(CASE
WHEN (attribute_type = 'phase' AND new_value <> 'Unmapped')
THEN (SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM project_phases WHERE id = new_value::UUID),
(SELECT color_code FROM project_phases WHERE id = new_value::UUID)) rec)
ELSE (NULL) END) AS next_phase,
-- Case for done by
(SELECT ROW_TO_JSON(rec)
FROM (SELECT (SELECT name FROM users WHERE users.id = tal.user_id),
(SELECT avatar_url FROM users WHERE users.id = tal.user_id)) rec) AS done_by,
-- Add log text for progress and weight
(CASE
WHEN (attribute_type = 'progress')
THEN 'updated the progress of'
WHEN (attribute_type = 'weight')
THEN 'updated the weight of'
ELSE ''
END) AS log_text
FROM task_activity_logs tal
WHERE task_id = _task_id
ORDER BY created_at DESC) rec2) AS logs) rec;
RETURN _result;
END;
$$;
COMMIT;

View File

@@ -1,243 +0,0 @@
-- Migration: Update time-based progress mode to work for all tasks
-- Date: 2025-04-25
-- Version: 1.0.0
BEGIN;
-- Update function to use time-based progress for all tasks
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_parent_task_done FLOAT = 0;
_sub_tasks_done FLOAT = 0;
_sub_tasks_count FLOAT = 0;
_total_completed FLOAT = 0;
_total_tasks FLOAT = 0;
_ratio FLOAT = 0;
_is_manual BOOLEAN = FALSE;
_manual_value INTEGER = NULL;
_project_id UUID;
_use_manual_progress BOOLEAN = FALSE;
_use_weighted_progress BOOLEAN = FALSE;
_use_time_progress BOOLEAN = FALSE;
_task_complete BOOLEAN = FALSE;
BEGIN
-- Check if manual progress is set for this task
SELECT manual_progress, progress_value, project_id,
EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = tasks.id
AND is_done IS TRUE
) AS is_complete
FROM tasks
WHERE id = _task_id
INTO _is_manual, _manual_value, _project_id, _task_complete;
-- Check if the project uses manual progress
IF _project_id IS NOT NULL THEN
SELECT COALESCE(use_manual_progress, FALSE),
COALESCE(use_weighted_progress, FALSE),
COALESCE(use_time_progress, FALSE)
FROM projects
WHERE id = _project_id
INTO _use_manual_progress, _use_weighted_progress, _use_time_progress;
END IF;
-- Get all subtasks
SELECT COUNT(*)
FROM tasks
WHERE parent_task_id = _task_id AND archived IS FALSE
INTO _sub_tasks_count;
-- If task is complete, always return 100%
IF _task_complete IS TRUE THEN
RETURN JSON_BUILD_OBJECT(
'ratio', 100,
'total_completed', 1,
'total_tasks', 1,
'is_manual', FALSE
);
END IF;
-- Use manual progress value in two cases:
-- 1. When task has manual_progress = TRUE and progress_value is set
-- 2. When project has use_manual_progress = TRUE and progress_value is set
IF (_is_manual IS TRUE AND _manual_value IS NOT NULL) OR
(_use_manual_progress IS TRUE AND _manual_value IS NOT NULL) THEN
RETURN JSON_BUILD_OBJECT(
'ratio', _manual_value,
'total_completed', 0,
'total_tasks', 0,
'is_manual', TRUE
);
END IF;
-- If there are no subtasks, just use the parent task's status (unless in time-based mode)
IF _sub_tasks_count = 0 THEN
-- Use time-based estimation for tasks without subtasks if enabled
IF _use_time_progress IS TRUE THEN
-- For time-based tasks without subtasks, we still need some progress calculation
-- If the task is completed, return 100%
-- Otherwise, use the progress value if set manually, or 0
SELECT
CASE
WHEN _task_complete IS TRUE THEN 100
ELSE COALESCE(_manual_value, 0)
END
INTO _ratio;
ELSE
-- Traditional calculation for non-time-based tasks
SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END)
INTO _parent_task_done;
_ratio = _parent_task_done * 100;
END IF;
ELSE
-- If project uses manual progress, calculate based on subtask manual progress values
IF _use_manual_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
t.id,
t.manual_progress,
t.progress_value,
EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) AS is_complete
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
),
subtask_with_values AS (
SELECT
CASE
-- For completed tasks, always use 100%
WHEN is_complete IS TRUE THEN 100
-- For tasks with progress value set, use it regardless of manual_progress flag
WHEN progress_value IS NOT NULL THEN progress_value
-- Default to 0 for incomplete tasks with no progress value
ELSE 0
END AS progress_value
FROM subtask_progress
)
SELECT COALESCE(AVG(progress_value), 0)
FROM subtask_with_values
INTO _ratio;
-- If project uses weighted progress, calculate based on subtask weights
ELSIF _use_weighted_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
t.id,
t.manual_progress,
t.progress_value,
EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) AS is_complete,
COALESCE(t.weight, 100) AS weight
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
),
subtask_with_values AS (
SELECT
CASE
-- For completed tasks, always use 100%
WHEN is_complete IS TRUE THEN 100
-- For tasks with progress value set, use it regardless of manual_progress flag
WHEN progress_value IS NOT NULL THEN progress_value
-- Default to 0 for incomplete tasks with no progress value
ELSE 0
END AS progress_value,
weight
FROM subtask_progress
)
SELECT COALESCE(
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
0
)
FROM subtask_with_values
INTO _ratio;
-- If project uses time-based progress, calculate based on estimated time
ELSIF _use_time_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
t.id,
t.manual_progress,
t.progress_value,
EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) AS is_complete,
COALESCE(t.total_minutes, 0) AS estimated_minutes
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
),
subtask_with_values AS (
SELECT
CASE
-- For completed tasks, always use 100%
WHEN is_complete IS TRUE THEN 100
-- For tasks with progress value set, use it regardless of manual_progress flag
WHEN progress_value IS NOT NULL THEN progress_value
-- Default to 0 for incomplete tasks with no progress value
ELSE 0
END AS progress_value,
estimated_minutes
FROM subtask_progress
)
SELECT COALESCE(
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
0
)
FROM subtask_with_values
INTO _ratio;
ELSE
-- Traditional calculation based on completion status
SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END)
INTO _parent_task_done;
SELECT COUNT(*)
FROM tasks_with_status_view
WHERE parent_task_id = _task_id
AND is_done IS TRUE
INTO _sub_tasks_done;
_total_completed = _parent_task_done + _sub_tasks_done;
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
IF _total_tasks = 0 THEN
_ratio = 0;
ELSE
_ratio = (_total_completed / _total_tasks) * 100;
END IF;
END IF;
END IF;
-- Ensure ratio is between 0 and 100
IF _ratio < 0 THEN
_ratio = 0;
ELSIF _ratio > 100 THEN
_ratio = 100;
END IF;
RETURN JSON_BUILD_OBJECT(
'ratio', _ratio,
'total_completed', _total_completed,
'total_tasks', _total_tasks,
'is_manual', _is_manual
);
END
$$;
COMMIT;

View File

@@ -1,289 +0,0 @@
-- Migration: Improve parent task progress calculation using weights and time estimation
-- Date: 2025-04-26
-- Version: 1.0.0
BEGIN;
-- Update function to better calculate parent task progress based on subtask weights or time estimations
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_parent_task_done FLOAT = 0;
_sub_tasks_done FLOAT = 0;
_sub_tasks_count FLOAT = 0;
_total_completed FLOAT = 0;
_total_tasks FLOAT = 0;
_ratio FLOAT = 0;
_is_manual BOOLEAN = FALSE;
_manual_value INTEGER = NULL;
_project_id UUID;
_use_manual_progress BOOLEAN = FALSE;
_use_weighted_progress BOOLEAN = FALSE;
_use_time_progress BOOLEAN = FALSE;
BEGIN
-- Check if manual progress is set for this task
SELECT manual_progress, progress_value, project_id
FROM tasks
WHERE id = _task_id
INTO _is_manual, _manual_value, _project_id;
-- Check if the project uses manual progress
IF _project_id IS NOT NULL THEN
SELECT COALESCE(use_manual_progress, FALSE),
COALESCE(use_weighted_progress, FALSE),
COALESCE(use_time_progress, FALSE)
FROM projects
WHERE id = _project_id
INTO _use_manual_progress, _use_weighted_progress, _use_time_progress;
END IF;
-- Get all subtasks
SELECT COUNT(*)
FROM tasks
WHERE parent_task_id = _task_id AND archived IS FALSE
INTO _sub_tasks_count;
-- Only respect manual progress for tasks without subtasks
IF _is_manual IS TRUE AND _manual_value IS NOT NULL AND _sub_tasks_count = 0 THEN
RETURN JSON_BUILD_OBJECT(
'ratio', _manual_value,
'total_completed', 0,
'total_tasks', 0,
'is_manual', TRUE
);
END IF;
-- If there are no subtasks, just use the parent task's status
IF _sub_tasks_count = 0 THEN
-- For tasks without subtasks in time-based mode
IF _use_time_progress IS TRUE THEN
SELECT
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE
) THEN 100
ELSE COALESCE(_manual_value, 0)
END
INTO _ratio;
ELSE
-- Traditional calculation for non-time-based tasks
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
_ratio = _parent_task_done * 100;
END IF;
ELSE
-- For parent tasks with subtasks, always use the appropriate calculation based on project mode
-- If project uses manual progress, calculate based on subtask manual progress values
IF _use_manual_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(AVG(progress_value), 0)
FROM subtask_progress
INTO _ratio;
-- If project uses weighted progress, calculate based on subtask weights
ELSIF _use_weighted_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(weight, 100) AS weight -- Default weight is 100 if not specified
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
0
)
FROM subtask_progress
INTO _ratio;
-- If project uses time-based progress, calculate based on estimated time (total_minutes)
ELSIF _use_time_progress IS TRUE THEN
WITH subtask_progress AS (
SELECT
CASE
-- If subtask has manual progress, use that value
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
progress_value
-- Otherwise use completion status (0 or 100)
ELSE
CASE
WHEN EXISTS(
SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = t.id
AND is_done IS TRUE
) THEN 100
ELSE 0
END
END AS progress_value,
COALESCE(total_minutes, 0) AS estimated_minutes -- Use time estimation for weighting
FROM tasks t
WHERE t.parent_task_id = _task_id
AND t.archived IS FALSE
)
SELECT COALESCE(
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
0
)
FROM subtask_progress
INTO _ratio;
ELSE
-- Traditional calculation based on completion status when no special mode is enabled
SELECT (CASE
WHEN EXISTS(SELECT 1
FROM tasks_with_status_view
WHERE tasks_with_status_view.task_id = _task_id
AND is_done IS TRUE) THEN 1
ELSE 0 END)
INTO _parent_task_done;
SELECT COUNT(*)
FROM tasks_with_status_view
WHERE parent_task_id = _task_id
AND is_done IS TRUE
INTO _sub_tasks_done;
_total_completed = _parent_task_done + _sub_tasks_done;
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
IF _total_tasks = 0 THEN
_ratio = 0;
ELSE
_ratio = (_total_completed / _total_tasks) * 100;
END IF;
END IF;
END IF;
-- Ensure ratio is between 0 and 100
IF _ratio < 0 THEN
_ratio = 0;
ELSIF _ratio > 100 THEN
_ratio = 100;
END IF;
RETURN JSON_BUILD_OBJECT(
'ratio', _ratio,
'total_completed', _total_completed,
'total_tasks', _total_tasks,
'is_manual', _is_manual
);
END
$$;
-- Make sure we recalculate parent task progress when subtask progress changes
CREATE OR REPLACE FUNCTION update_parent_task_progress() RETURNS TRIGGER AS
$$
DECLARE
_parent_task_id UUID;
_project_id UUID;
_ratio FLOAT;
BEGIN
-- Check if this is a subtask
IF NEW.parent_task_id IS NOT NULL THEN
_parent_task_id := NEW.parent_task_id;
-- Force any parent task with subtasks to NOT use manual progress
UPDATE tasks
SET manual_progress = FALSE
WHERE id = _parent_task_id;
END IF;
-- If this task has progress value of 100 and doesn't have subtasks, we might want to prompt the user
-- to mark it as done. We'll annotate this in a way that the socket handler can detect.
IF NEW.progress_value = 100 OR NEW.weight = 100 OR NEW.total_minutes > 0 THEN
-- Check if task has status in "done" category
SELECT project_id FROM tasks WHERE id = NEW.id INTO _project_id;
-- Get the progress ratio for this task
SELECT get_task_complete_ratio(NEW.id)->>'ratio' INTO _ratio;
IF _ratio::FLOAT >= 100 THEN
-- Log that this task is at 100% progress
RAISE NOTICE 'Task % progress is at 100%%, may need status update', NEW.id;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger for updates to task progress
DROP TRIGGER IF EXISTS update_parent_task_progress_trigger ON tasks;
CREATE TRIGGER update_parent_task_progress_trigger
AFTER UPDATE OF progress_value, weight, total_minutes ON tasks
FOR EACH ROW
EXECUTE FUNCTION update_parent_task_progress();
-- Create a function to ensure parent tasks never have manual progress when they have subtasks
CREATE OR REPLACE FUNCTION ensure_parent_task_without_manual_progress() RETURNS TRIGGER AS
$$
BEGIN
-- If this is a new subtask being created or a task is being converted to a subtask
IF NEW.parent_task_id IS NOT NULL THEN
-- Force the parent task to NOT use manual progress
UPDATE tasks
SET manual_progress = FALSE
WHERE id = NEW.parent_task_id;
-- Log that we've reset manual progress for a parent task
RAISE NOTICE 'Reset manual progress for parent task % because it has subtasks', NEW.parent_task_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger for when tasks are created or updated with a parent_task_id
DROP TRIGGER IF EXISTS ensure_parent_task_without_manual_progress_trigger ON tasks;
CREATE TRIGGER ensure_parent_task_without_manual_progress_trigger
AFTER INSERT OR UPDATE OF parent_task_id ON tasks
FOR EACH ROW
EXECUTE FUNCTION ensure_parent_task_without_manual_progress();
COMMIT;

View File

@@ -1,150 +0,0 @@
-- Migration: Update socket event handlers to set progress-mode handlers
-- Date: 2025-04-26
-- Version: 1.0.0
BEGIN;
-- Create ENUM type for progress modes
CREATE TYPE progress_mode_type AS ENUM ('manual', 'weighted', 'time', 'default');
-- Alter tasks table to use ENUM type
ALTER TABLE tasks
ALTER COLUMN progress_mode TYPE progress_mode_type
USING progress_mode::text::progress_mode_type;
-- Update the on_update_task_progress function to set progress_mode
CREATE OR REPLACE FUNCTION on_update_task_progress(_body json) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_task_id UUID;
_progress_value INTEGER;
_parent_task_id UUID;
_project_id UUID;
_current_mode progress_mode_type;
BEGIN
_task_id = (_body ->> 'task_id')::UUID;
_progress_value = (_body ->> 'progress_value')::INTEGER;
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
-- Get the project ID and determine the current progress mode
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
IF _project_id IS NOT NULL THEN
SELECT
CASE
WHEN use_manual_progress IS TRUE THEN 'manual'
WHEN use_weighted_progress IS TRUE THEN 'weighted'
WHEN use_time_progress IS TRUE THEN 'time'
ELSE 'default'
END
INTO _current_mode
FROM projects
WHERE id = _project_id;
ELSE
_current_mode := 'default';
END IF;
-- Update the task with progress value and set the progress mode
UPDATE tasks
SET progress_value = _progress_value,
manual_progress = TRUE,
progress_mode = _current_mode,
updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id;
-- Return the updated task info
RETURN JSON_BUILD_OBJECT(
'task_id', _task_id,
'progress_value', _progress_value,
'progress_mode', _current_mode
);
END;
$$;
-- Update the on_update_task_weight function to set progress_mode when weight is updated
CREATE OR REPLACE FUNCTION on_update_task_weight(_body json) RETURNS json
LANGUAGE plpgsql
AS
$$
DECLARE
_task_id UUID;
_weight INTEGER;
_parent_task_id UUID;
_project_id UUID;
BEGIN
_task_id = (_body ->> 'task_id')::UUID;
_weight = (_body ->> 'weight')::INTEGER;
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
-- Get the project ID
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
-- Update the task with weight value and set progress_mode to 'weighted'
UPDATE tasks
SET weight = _weight,
progress_mode = 'weighted',
updated_at = CURRENT_TIMESTAMP
WHERE id = _task_id;
-- Return the updated task info
RETURN JSON_BUILD_OBJECT(
'task_id', _task_id,
'weight', _weight
);
END;
$$;
-- Create a function to reset progress values when switching project progress modes
CREATE OR REPLACE FUNCTION reset_project_progress_values() RETURNS TRIGGER
LANGUAGE plpgsql
AS
$$
DECLARE
_old_mode progress_mode_type;
_new_mode progress_mode_type;
_project_id UUID;
BEGIN
_project_id := NEW.id;
-- Determine old and new modes
_old_mode :=
CASE
WHEN OLD.use_manual_progress IS TRUE THEN 'manual'
WHEN OLD.use_weighted_progress IS TRUE THEN 'weighted'
WHEN OLD.use_time_progress IS TRUE THEN 'time'
ELSE 'default'
END;
_new_mode :=
CASE
WHEN NEW.use_manual_progress IS TRUE THEN 'manual'
WHEN NEW.use_weighted_progress IS TRUE THEN 'weighted'
WHEN NEW.use_time_progress IS TRUE THEN 'time'
ELSE 'default'
END;
-- If mode has changed, reset progress values for tasks with the old mode
IF _old_mode <> _new_mode THEN
-- Reset progress values for tasks that were set in the old mode
UPDATE tasks
SET progress_value = NULL,
progress_mode = NULL
WHERE project_id = _project_id
AND progress_mode = _old_mode;
END IF;
RETURN NEW;
END;
$$;
-- Create trigger to reset progress values when project progress mode changes
DROP TRIGGER IF EXISTS reset_progress_on_mode_change ON projects;
CREATE TRIGGER reset_progress_on_mode_change
AFTER UPDATE OF use_manual_progress, use_weighted_progress, use_time_progress
ON projects
FOR EACH ROW
EXECUTE FUNCTION reset_project_progress_values();
COMMIT;

View File

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

View File

@@ -1,166 +0,0 @@
-- Migration: Fix multilevel subtask progress calculation for weighted and manual progress
-- Date: 2025-05-06
-- Version: 1.0.0
BEGIN;
-- Update the trigger function to recursively recalculate parent task progress up the entire hierarchy
CREATE OR REPLACE FUNCTION update_parent_task_progress() RETURNS TRIGGER AS
$$
DECLARE
_parent_task_id UUID;
_project_id UUID;
_ratio FLOAT;
BEGIN
-- Check if this is a subtask
IF NEW.parent_task_id IS NOT NULL THEN
_parent_task_id := NEW.parent_task_id;
-- Force any parent task with subtasks to NOT use manual progress
UPDATE tasks
SET manual_progress = FALSE
WHERE id = _parent_task_id;
-- Calculate and update the parent's progress value
SELECT (get_task_complete_ratio(_parent_task_id)->>'ratio')::FLOAT INTO _ratio;
-- Update the parent's progress value
UPDATE tasks
SET progress_value = _ratio
WHERE id = _parent_task_id;
-- Recursively propagate changes up the hierarchy by using a recursive CTE
WITH RECURSIVE task_hierarchy AS (
-- Base case: Start with the parent task
SELECT
id,
parent_task_id
FROM tasks
WHERE id = _parent_task_id
UNION ALL
-- Recursive case: Go up to each ancestor
SELECT
t.id,
t.parent_task_id
FROM tasks t
JOIN task_hierarchy th ON t.id = th.parent_task_id
WHERE t.id IS NOT NULL
)
-- For each ancestor, recalculate its progress
UPDATE tasks
SET
manual_progress = FALSE,
progress_value = (SELECT (get_task_complete_ratio(task_hierarchy.id)->>'ratio')::FLOAT)
FROM task_hierarchy
WHERE tasks.id = task_hierarchy.id
AND task_hierarchy.parent_task_id IS NOT NULL;
-- Log the recalculation for debugging
RAISE NOTICE 'Updated progress for task % to %', _parent_task_id, _ratio;
END IF;
-- If this task has progress value of 100 and doesn't have subtasks, we might want to prompt the user
-- to mark it as done. We'll annotate this in a way that the socket handler can detect.
IF NEW.progress_value = 100 OR NEW.weight = 100 OR NEW.total_minutes > 0 THEN
-- Check if task has status in "done" category
SELECT project_id FROM tasks WHERE id = NEW.id INTO _project_id;
-- Get the progress ratio for this task
SELECT (get_task_complete_ratio(NEW.id)->>'ratio')::FLOAT INTO _ratio;
IF _ratio >= 100 THEN
-- Log that this task is at 100% progress
RAISE NOTICE 'Task % progress is at 100%%, may need status update', NEW.id;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Update existing trigger or create a new one to handle more changes
DROP TRIGGER IF EXISTS update_parent_task_progress_trigger ON tasks;
CREATE TRIGGER update_parent_task_progress_trigger
AFTER UPDATE OF progress_value, weight, total_minutes, parent_task_id, manual_progress ON tasks
FOR EACH ROW
EXECUTE FUNCTION update_parent_task_progress();
-- Also add a trigger for when a new task is inserted
DROP TRIGGER IF EXISTS update_parent_task_progress_on_insert_trigger ON tasks;
CREATE TRIGGER update_parent_task_progress_on_insert_trigger
AFTER INSERT ON tasks
FOR EACH ROW
WHEN (NEW.parent_task_id IS NOT NULL)
EXECUTE FUNCTION update_parent_task_progress();
-- Add a comment to explain the fix
COMMENT ON FUNCTION update_parent_task_progress() IS
'This function recursively updates progress values for all ancestors when a task''s progress changes.
The previous version only updated the immediate parent, which led to incorrect progress values for
higher-level parent tasks when using weighted or manual progress calculations with multi-level subtasks.';
-- Add a function to immediately recalculate all task progress values in the correct order
-- This will fix existing data where parent tasks don't have proper progress values
CREATE OR REPLACE FUNCTION recalculate_all_task_progress() RETURNS void AS
$$
BEGIN
-- First, reset manual_progress flag for all tasks that have subtasks
UPDATE tasks AS t
SET manual_progress = FALSE
WHERE EXISTS (
SELECT 1
FROM tasks
WHERE parent_task_id = t.id
AND archived IS FALSE
);
-- Start recalculation from leaf tasks (no subtasks) and propagate upward
-- This ensures calculations are done in the right order
WITH RECURSIVE task_hierarchy AS (
-- Base case: Start with all leaf tasks (no subtasks)
SELECT
id,
parent_task_id,
0 AS level
FROM tasks
WHERE NOT EXISTS (
SELECT 1 FROM tasks AS sub
WHERE sub.parent_task_id = tasks.id
AND sub.archived IS FALSE
)
AND archived IS FALSE
UNION ALL
-- Recursive case: Move up to parent tasks, but only after processing all their children
SELECT
t.id,
t.parent_task_id,
th.level + 1
FROM tasks t
JOIN task_hierarchy th ON t.id = th.parent_task_id
WHERE t.archived IS FALSE
)
-- Sort by level to ensure we calculate in the right order (leaves first, then parents)
-- This ensures we're using already updated progress values
UPDATE tasks
SET progress_value = (SELECT (get_task_complete_ratio(tasks.id)->>'ratio')::FLOAT)
FROM (
SELECT id, level
FROM task_hierarchy
ORDER BY level
) AS ordered_tasks
WHERE tasks.id = ordered_tasks.id
AND (manual_progress IS FALSE OR manual_progress IS NULL);
-- Log the completion of the recalculation
RAISE NOTICE 'Finished recalculating all task progress values';
END;
$$ LANGUAGE plpgsql;
-- Execute the function to fix existing data
SELECT recalculate_all_task_progress();
COMMIT;

View File

@@ -1,48 +0,0 @@
-- Dropping existing finance_rate_cards table
DROP TABLE IF EXISTS finance_rate_cards;
-- Creating table to store rate card details
CREATE TABLE finance_rate_cards
(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
name VARCHAR NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Dropping existing finance_project_rate_card_roles table
DROP TABLE IF EXISTS finance_project_rate_card_roles CASCADE;
-- Creating table with single id primary key
CREATE TABLE finance_project_rate_card_roles
(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
job_title_id UUID NOT NULL REFERENCES job_titles (id) ON DELETE CASCADE,
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_project_role UNIQUE (project_id, job_title_id)
);
-- Dropping existing finance_rate_card_roles table
DROP TABLE IF EXISTS finance_rate_card_roles;
-- Creating table to store role-specific rates for rate cards
CREATE TABLE finance_rate_card_roles
(
rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE,
job_title_id UUID REFERENCES job_titles(id) ON DELETE SET NULL,
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Adding project_rate_card_role_id column to project_members
ALTER TABLE project_members
ADD COLUMN project_rate_card_role_id UUID REFERENCES finance_project_rate_card_roles(id) ON DELETE SET NULL;
-- Adding rate_card column to projects
ALTER TABLE projects
ADD COLUMN rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL;
ALTER TABLE finance_rate_cards
ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD';

View File

@@ -1,6 +0,0 @@
-- Add fixed_cost column to tasks table for project finance functionality
ALTER TABLE tasks
ADD COLUMN fixed_cost DECIMAL(10, 2) DEFAULT 0 CHECK (fixed_cost >= 0);
-- Add comment to explain the column
COMMENT ON COLUMN tasks.fixed_cost IS 'Fixed cost for the task in addition to hourly rate calculations';

View File

@@ -12,10 +12,7 @@ CREATE TYPE DEPENDENCY_TYPE AS ENUM ('blocked_by');
CREATE TYPE SCHEDULE_TYPE AS ENUM ('daily', 'weekly', 'yearly', 'monthly', 'every_x_days', 'every_x_weeks', 'every_x_months');
CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt');
-- Add progress mode type for tasks progress tracking
CREATE TYPE PROGRESS_MODE_TYPE AS ENUM ('manual', 'weighted', 'time', 'default');
CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt', 'alb', 'de');
-- START: Users
CREATE SEQUENCE IF NOT EXISTS users_user_no_seq START 1;
@@ -780,15 +777,9 @@ CREATE TABLE IF NOT EXISTS projects (
estimated_man_days INTEGER DEFAULT 0,
hours_per_day INTEGER DEFAULT 8,
health_id UUID,
estimated_working_days INTEGER DEFAULT 0,
use_manual_progress BOOLEAN DEFAULT FALSE,
use_weighted_progress BOOLEAN DEFAULT FALSE,
use_time_progress BOOLEAN DEFAULT FALSE,
currency VARCHAR(3) DEFAULT 'USD'
estimated_working_days INTEGER DEFAULT 0
);
COMMENT ON COLUMN projects.currency IS 'Project-specific currency code (e.g., USD, EUR, GBP, JPY, etc.)';
ALTER TABLE projects
ADD CONSTRAINT projects_pk
PRIMARY KEY (id);
@@ -1420,16 +1411,9 @@ CREATE TABLE IF NOT EXISTS tasks (
sort_order INTEGER DEFAULT 0 NOT NULL,
roadmap_sort_order INTEGER DEFAULT 0 NOT NULL,
billable BOOLEAN DEFAULT TRUE,
schedule_id UUID,
manual_progress BOOLEAN DEFAULT FALSE,
progress_value INTEGER DEFAULT NULL,
progress_mode PROGRESS_MODE_TYPE DEFAULT 'default',
weight INTEGER DEFAULT NULL,
fixed_cost DECIMAL(10, 2) DEFAULT 0 CHECK (fixed_cost >= 0)
schedule_id UUID
);
COMMENT ON COLUMN tasks.fixed_cost IS 'Fixed cost for the task in addition to hourly rate calculations';
ALTER TABLE tasks
ADD CONSTRAINT tasks_pk
PRIMARY KEY (id);
@@ -2295,37 +2279,3 @@ ALTER TABLE organization_working_days
ALTER TABLE organization_working_days
ADD CONSTRAINT org_organization_id_fk
FOREIGN KEY (organization_id) REFERENCES organizations;
-- Finance module tables
CREATE TABLE IF NOT EXISTS finance_rate_cards (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
name VARCHAR NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
currency TEXT NOT NULL DEFAULT 'USD'
);
CREATE TABLE IF NOT EXISTS finance_project_rate_card_roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
job_title_id UUID NOT NULL REFERENCES job_titles (id) ON DELETE CASCADE,
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_project_role UNIQUE (project_id, job_title_id)
);
CREATE TABLE IF NOT EXISTS finance_rate_card_roles (
rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE,
job_title_id UUID REFERENCES job_titles(id) ON DELETE SET NULL,
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE project_members
ADD COLUMN IF NOT EXISTS project_rate_card_role_id UUID REFERENCES finance_project_rate_card_roles(id) ON DELETE SET NULL;
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL;

View File

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

View File

@@ -1,77 +0,0 @@
-- Fix task hierarchy and reset parent estimations
-- This script ensures proper parent-child relationships and resets parent estimations
-- First, let's see the current task hierarchy
SELECT
t.id,
t.name,
t.parent_task_id,
t.total_minutes,
(SELECT name FROM tasks WHERE id = t.parent_task_id) as parent_name,
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as actual_subtask_count,
t.archived
FROM tasks t
WHERE (t.name LIKE '%sub%' OR t.name LIKE '%test task%')
ORDER BY t.name, t.created_at;
-- Reset all parent task estimations to 0
-- This ensures parent tasks don't have their own estimation when they have subtasks
UPDATE tasks
SET total_minutes = 0
WHERE id IN (
SELECT DISTINCT parent_task_id
FROM tasks
WHERE parent_task_id IS NOT NULL
AND archived = false
)
AND archived = false;
-- Verify the results after the update
SELECT
t.id,
t.name,
t.parent_task_id,
t.total_minutes as current_estimation,
(SELECT name FROM tasks WHERE id = t.parent_task_id) as parent_name,
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as subtask_count,
get_task_recursive_estimation(t.id) as recursive_estimation
FROM tasks t
WHERE (t.name LIKE '%sub%' OR t.name LIKE '%test task%')
AND t.archived = false
ORDER BY t.name;
-- Show the hierarchy in tree format
WITH RECURSIVE task_hierarchy AS (
-- Top level tasks (no parent)
SELECT
id,
name,
parent_task_id,
total_minutes,
0 as level,
name as path
FROM tasks
WHERE parent_task_id IS NULL
AND (name LIKE '%sub%' OR name LIKE '%test task%')
AND archived = false
UNION ALL
-- Child tasks
SELECT
t.id,
t.name,
t.parent_task_id,
t.total_minutes,
th.level + 1,
th.path || ' > ' || t.name
FROM tasks t
INNER JOIN task_hierarchy th ON t.parent_task_id = th.id
WHERE t.archived = false
)
SELECT
REPEAT(' ', level) || name as indented_name,
total_minutes,
get_task_recursive_estimation(id) as recursive_estimation
FROM task_hierarchy
ORDER BY path;

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,29 +0,0 @@
-- Reset all existing parent task estimations to 0
-- This script updates all tasks that have subtasks to have 0 estimation
UPDATE tasks
SET total_minutes = 0
WHERE id IN (
SELECT DISTINCT parent_task_id
FROM tasks
WHERE parent_task_id IS NOT NULL
AND archived = false
)
AND total_minutes > 0
AND archived = false;
-- Show the results
SELECT
t.id,
t.name,
t.total_minutes as current_estimation,
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as subtask_count
FROM tasks t
WHERE id IN (
SELECT DISTINCT parent_task_id
FROM tasks
WHERE parent_task_id IS NOT NULL
AND archived = false
)
AND archived = false
ORDER BY t.name;

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

View File

@@ -5,7 +5,7 @@ import db from "../config/db";
import {ServerResponse} from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import {calculateMonthDays, getColor, log_error, megabytesToBytes} from "../shared/utils";
import {calculateMonthDays, getColor, megabytesToBytes} from "../shared/utils";
import moment from "moment";
import {calculateStorage} from "../shared/s3";
import {checkTeamSubscriptionStatus, getActiveTeamMemberCount, getCurrentProjectsCount, getFreePlanSettings, getOwnerIdByTeam, getTeamMemberCount, getUsedStorage} from "../shared/paddle-utils";
@@ -232,11 +232,7 @@ export default class AdminCenterController extends WorklenzControllerBase {
FROM team_member_info_view
WHERE team_member_info_view.team_member_id = tm.id),
role_id,
r.name AS role_name,
EXISTS(SELECT email
FROM email_invitations
WHERE team_member_id = tm.id
AND email_invitations.team_id = tm.team_id) AS pending_invitation
r.name AS role_name
FROM team_members tm
LEFT JOIN users u on tm.user_id = u.id
LEFT JOIN roles r on tm.role_id = r.id
@@ -259,33 +255,22 @@ export default class AdminCenterController extends WorklenzControllerBase {
const {id} = req.params;
const {name, teamMembers} = req.body;
try {
// Update team name
const updateNameQuery = `UPDATE teams SET name = $1 WHERE id = $2 RETURNING id;`;
const nameResult = await db.query(updateNameQuery, [name, id]);
if (!nameResult.rows.length) {
return res.status(404).send(new ServerResponse(false, null, "Team not found"));
}
const updateNameQuery = `UPDATE teams
SET name = $1
WHERE id = $2;`;
await db.query(updateNameQuery, [name, id]);
// Update team member roles if provided
if (teamMembers?.length) {
// Use Promise.all to handle all role updates concurrently
await Promise.all(teamMembers.map(async (member: { role_name: string; user_id: string; }) => {
const roleQuery = `
UPDATE team_members
SET role_id = (SELECT id FROM roles WHERE roles.team_id = $1 AND name = $2)
WHERE user_id = $3 AND team_id = $1
RETURNING id;`;
await db.query(roleQuery, [id, member.role_name, member.user_id]);
}));
}
return res.status(200).send(new ServerResponse(true, null, "Team updated successfully"));
} catch (error) {
log_error("Error updating team:", error);
return res.status(500).send(new ServerResponse(false, null, "Failed to update team"));
if (teamMembers.length) {
teamMembers.forEach(async (element: { role_name: string; user_id: string; }) => {
const q = `UPDATE team_members
SET role_id = (SELECT id FROM roles WHERE roles.team_id = $1 AND name = $2)
WHERE user_id = $3
AND team_id = $1;`;
await db.query(q, [id, element.role_name, element.user_id]);
});
}
return res.status(200).send(new ServerResponse(true, [], "Team updated successfully"));
}
@HandleExceptions()

View File

@@ -31,23 +31,12 @@ export default class AuthController extends WorklenzControllerBase {
// Flash messages sent from passport-local-signup.ts and passport-local-login.ts
const errors = req.flash()["error"] || [];
const messages = req.flash()["success"] || [];
// If there are multiple messages, we will send one at a time.
const auth_error = errors.length > 0 ? errors[0] : null;
const message = messages.length > 0 ? messages[0] : null;
// Determine title based on authentication status and strategy
let title = null;
if (req.query.strategy) {
if (auth_error) {
// Show failure title only when there's an actual error
title = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
} else if (req.isAuthenticated() && message) {
// Show success title when authenticated and there's a success message
title = req.query.strategy === "login" ? "Login Successful!" : "Signup Successful!";
}
// If no error and not authenticated, don't show any title (this might be a redirect without completion)
}
const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
const title = req.query.strategy ? midTitle : null;
if (req.user)
req.user.build_v = FileConstants.getRelease();

File diff suppressed because it is too large Load Diff

View File

@@ -322,7 +322,7 @@ export default class ProjectInsightsController extends WorklenzControllerBase {
(SELECT get_task_assignees(tasks.id)) AS assignees
FROM tasks
JOIN work_log ON work_log.task_id = tasks.id
WHERE project_id = $1 AND total_minutes <> 0 AND (total_minutes * 60) <> work_log.total_time_spent
WHERE project_id = $1
AND CASE
WHEN ($2 IS TRUE) THEN project_id IS NOT NULL
ELSE archived IS FALSE END

View File

@@ -1,262 +0,0 @@
import db from "../config/db";
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import { ServerResponse } from "../models/server-response";
import HandleExceptions from "../decorators/handle-exceptions";
import WorklenzControllerBase from "./worklenz-controller-base";
export default class ProjectRateCardController extends WorklenzControllerBase {
// Insert a single role for a project
@HandleExceptions()
public static async createOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id, job_title_id, rate } = req.body;
if (!project_id || !job_title_id || typeof rate !== "number") {
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
}
const q = `
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
VALUES ($1, $2, $3)
ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate
RETURNING *,
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle;
`;
const result = await db.query(q, [project_id, job_title_id, rate]);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
// Insert multiple roles for a project
@HandleExceptions()
public static async createMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id, roles } = req.body;
if (!Array.isArray(roles) || !project_id) {
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
}
const values = roles.map((role: any) => [
project_id,
role.job_title_id,
role.rate
]);
const q = `
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")}
ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate
RETURNING *,
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS Jobtitle;
`;
const flatValues = values.flat();
const result = await db.query(q, flatValues);
return res.status(200).send(new ServerResponse(true, result.rows));
}
// Get all roles for a project
@HandleExceptions()
public static async getByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id } = req.params;
const q = `
SELECT
fprr.*,
jt.name as jobtitle,
(
SELECT COALESCE(json_agg(pm.id), '[]'::json)
FROM project_members pm
WHERE pm.project_rate_card_role_id = fprr.id
) AS members
FROM finance_project_rate_card_roles fprr
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE fprr.project_id = $1
ORDER BY fprr.created_at;
`;
const result = await db.query(q, [project_id]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
// Get a single role by id
@HandleExceptions()
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const q = `
SELECT
fprr.*,
jt.name as jobtitle,
(
SELECT COALESCE(json_agg(pm.id), '[]'::json)
FROM project_members pm
WHERE pm.project_rate_card_role_id = fprr.id
) AS members
FROM finance_project_rate_card_roles fprr
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE fprr.id = $1;
`;
const result = await db.query(q, [id]);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
// Update a single role by id
@HandleExceptions()
public static async updateById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const { job_title_id, rate } = req.body;
const q = `
WITH updated AS (
UPDATE finance_project_rate_card_roles
SET job_title_id = $1, rate = $2, updated_at = NOW()
WHERE id = $3
RETURNING *
),
jobtitles AS (
SELECT u.*, jt.name AS jobtitle
FROM updated u
JOIN job_titles jt ON jt.id = u.job_title_id
),
members AS (
SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id
FROM project_members pm
WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles)
GROUP BY pm.project_rate_card_role_id
)
SELECT jt.*, m.members
FROM jobtitles jt
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
`;
const result = await db.query(q, [job_title_id, rate, id]);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
// update project member rate for a project with members
@HandleExceptions()
public static async updateProjectMemberByProjectIdAndMemberId(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const { project_id, id } = req.params;
const { project_rate_card_role_id } = req.body;
if (!project_id || !id || !project_rate_card_role_id) {
return res.status(400).send(new ServerResponse(false, null, "Missing values"));
}
try {
// Step 1: Check current role assignment
const checkQuery = `
SELECT project_rate_card_role_id
FROM project_members
WHERE id = $1 AND project_id = $2;
`;
const { rows: checkRows } = await db.query(checkQuery, [id, project_id]);
const currentRoleId = checkRows[0]?.project_rate_card_role_id;
if (currentRoleId !== null && currentRoleId !== project_rate_card_role_id) {
// Step 2: Fetch members with the requested role
const membersQuery = `
SELECT COALESCE(json_agg(id), '[]'::json) AS members
FROM project_members
WHERE project_id = $1 AND project_rate_card_role_id = $2;
`;
const { rows: memberRows } = await db.query(membersQuery, [project_id, project_rate_card_role_id]);
return res.status(200).send(
new ServerResponse(false, memberRows[0], "Already Assigned !")
);
}
// Step 3: Perform the update
const updateQuery = `
UPDATE project_members
SET project_rate_card_role_id = CASE
WHEN project_rate_card_role_id = $1 THEN NULL
ELSE $1
END
WHERE id = $2
AND project_id = $3
AND EXISTS (
SELECT 1
FROM finance_project_rate_card_roles
WHERE id = $1 AND project_id = $3
)
RETURNING project_rate_card_role_id;
`;
const { rows: updateRows } = await db.query(updateQuery, [project_rate_card_role_id, id, project_id]);
if (updateRows.length === 0) {
return res.status(200).send(new ServerResponse(true, [], "Project member not found or invalid project_rate_card_role_id"));
}
const updatedRoleId = updateRows[0].project_rate_card_role_id || project_rate_card_role_id;
// Step 4: Fetch updated members list
const membersQuery = `
SELECT COALESCE(json_agg(id), '[]'::json) AS members
FROM project_members
WHERE project_id = $1 AND project_rate_card_role_id = $2;
`;
const { rows: finalMembers } = await db.query(membersQuery, [project_id, updatedRoleId]);
return res.status(200).send(new ServerResponse(true, finalMembers[0]));
} catch (error) {
return res.status(500).send(new ServerResponse(false, null, "Internal server error"));
}
}
// Update all roles for a project (delete then insert)
@HandleExceptions()
public static async updateByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id, roles } = req.body;
if (!Array.isArray(roles) || !project_id) {
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
}
if (roles.length === 0) {
// If no roles provided, do nothing and return empty array
return res.status(200).send(new ServerResponse(true, []));
}
// Build upsert query for all roles
const values = roles.map((role: any) => [
project_id,
role.job_title_id,
role.rate
]);
const q = `
WITH upserted AS (
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")}
ON CONFLICT (project_id, job_title_id)
DO UPDATE SET rate = EXCLUDED.rate, updated_at = NOW()
RETURNING *
),
jobtitles AS (
SELECT upr.*, jt.name AS jobtitle
FROM upserted upr
JOIN job_titles jt ON jt.id = upr.job_title_id
),
members AS (
SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id
FROM project_members pm
WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles)
GROUP BY pm.project_rate_card_role_id
)
SELECT jt.*, m.members
FROM jobtitles jt
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
`;
const flatValues = values.flat();
const result = await db.query(q, flatValues);
return res.status(200).send(new ServerResponse(true, result.rows));
}
// Delete a single role by id
@HandleExceptions()
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`;
const result = await db.query(q, [id]);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
// Delete all roles for a project
@HandleExceptions()
public static async deleteByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id } = req.params;
const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`;
const result = await db.query(q, [project_id]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
}

View File

@@ -395,7 +395,6 @@ export default class ProjectsController extends WorklenzControllerBase {
projects.folder_id,
projects.phase_label,
projects.category_id,
projects.currency,
(projects.estimated_man_days) AS man_days,
(projects.estimated_working_days) AS working_days,
(projects.hours_per_day) AS hours_per_day,
@@ -409,9 +408,6 @@ export default class ProjectsController extends WorklenzControllerBase {
sps.color_code AS status_color,
sps.icon AS status_icon,
(SELECT name FROM clients WHERE id = projects.client_id) AS client_name,
projects.use_manual_progress,
projects.use_weighted_progress,
projects.use_time_progress,
(SELECT COALESCE(ROW_TO_JSON(pm), '{}'::JSON)
FROM (SELECT team_member_id AS id,

View File

@@ -1,157 +0,0 @@
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
export default class RateCardController extends WorklenzControllerBase {
@HandleExceptions()
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `
INSERT INTO finance_rate_cards (team_id, name)
VALUES ($1, $2)
RETURNING id, name, team_id, created_at, updated_at;
`;
const result = await db.query(q, [req.user?.team_id || null, req.body.name]);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data));
}
@HandleExceptions()
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { searchQuery, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, "name");
const q = `
SELECT ROW_TO_JSON(rec) AS rate_cards
FROM (
SELECT COUNT(*) AS total,
(
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
FROM (
SELECT id, name, team_id, currency, created_at, updated_at
FROM finance_rate_cards
WHERE team_id = $1 ${searchQuery}
ORDER BY ${sortField} ${sortOrder}
LIMIT $2 OFFSET $3
) t
) AS data
FROM finance_rate_cards
WHERE team_id = $1 ${searchQuery}
) rec;
`;
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data.rate_cards || this.paginatedDatasetDefaultStruct));
}
@HandleExceptions()
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
// 1. Fetch the rate card
const q = `
SELECT id, name, team_id, currency, created_at, updated_at
FROM finance_rate_cards
WHERE id = $1 AND team_id = $2;
`;
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
const [data] = result.rows;
if (!data) {
return res.status(404).send(new ServerResponse(false, null, "Rate card not found"));
}
// 2. Fetch job roles with job title names
const jobRolesQ = `
SELECT
rcr.job_title_id,
jt.name AS jobTitle,
rcr.rate,
rcr.rate_card_id
FROM finance_rate_card_roles rcr
LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id
WHERE rcr.rate_card_id = $1
`;
const jobRolesResult = await db.query(jobRolesQ, [req.params.id]);
const jobRolesList = jobRolesResult.rows;
// 3. Return the rate card with jobRolesList
return res.status(200).send(
new ServerResponse(true, {
...data,
jobRolesList,
})
);
}
@HandleExceptions()
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
// 1. Update the rate card
const updateRateCardQ = `
UPDATE finance_rate_cards
SET name = $3, currency = $4, updated_at = NOW()
WHERE id = $1 AND team_id = $2
RETURNING id, name, team_id, currency, created_at, updated_at;
`;
const result = await db.query(updateRateCardQ, [
req.params.id,
req.user?.team_id || null,
req.body.name,
req.body.currency,
]);
const [rateCardData] = result.rows;
// 2. Update job roles (delete old, insert new)
if (Array.isArray(req.body.jobRolesList)) {
// Delete existing roles for this rate card
await db.query(
`DELETE FROM finance_rate_card_roles WHERE rate_card_id = $1;`,
[req.params.id]
);
// Insert new roles
for (const role of req.body.jobRolesList) {
if (role.job_title_id) {
await db.query(
`INSERT INTO finance_rate_card_roles (rate_card_id, job_title_id, rate)
VALUES ($1, $2, $3);`,
[req.params.id, role.job_title_id, role.rate ?? 0]
);
}
}
}
// 3. Get jobRolesList with job title names
const jobRolesQ = `
SELECT
rcr.job_title_id,
jt.name AS jobTitle,
rcr.rate
FROM finance_rate_card_roles rcr
LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id
WHERE rcr.rate_card_id = $1
`;
const jobRolesResult = await db.query(jobRolesQ, [req.params.id]);
const jobRolesList = jobRolesResult.rows;
// 4. Return the updated rate card with jobRolesList
return res.status(200).send(
new ServerResponse(true, {
...rateCardData,
jobRolesList,
})
);
}
@HandleExceptions()
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `
DELETE FROM finance_rate_cards
WHERE id = $1 AND team_id = $2
RETURNING id;
`;
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
return res.status(200).send(new ServerResponse(true, result.rows.length > 0));
}
}

View File

@@ -415,20 +415,15 @@ export default class ReportingController extends WorklenzControllerBase {
@HandleExceptions()
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
FROM team_members tm
LEFT JOIN teams ON teams.id = tm.team_id
WHERE tm.user_id = $1
AND tm.team_id = $2
AND role_id IN (SELECT id
FROM roles
WHERE (admin_role IS TRUE OR owner IS TRUE))
ORDER BY name;`;
const result = await db.query(q, [req.user?.id, selectedTeamId]);
const result = await db.query(q, [req.user?.id]);
result.rows.forEach((team: any) => team.selected = true);
return res.status(200).send(new ServerResponse(true, result.rows));
}

View File

@@ -15,25 +15,6 @@ enum IToggleOptions {
}
export default class ReportingAllocationController extends ReportingControllerBase {
// Helper method to build billable query with custom table alias
private static buildBillableQueryWithAlias(selectedStatuses: { billable: boolean; nonBillable: boolean }, tableAlias: string = 'tasks'): string {
const { billable, nonBillable } = selectedStatuses;
if (billable && nonBillable) {
// Both are enabled, no need to filter
return "";
} else if (billable && !nonBillable) {
// Only billable is enabled - show only billable tasks
return ` AND ${tableAlias}.billable IS TRUE`;
} else if (!billable && nonBillable) {
// Only non-billable is enabled - show only non-billable tasks
return ` AND ${tableAlias}.billable IS FALSE`;
} else {
// Neither selected - this shouldn't happen in normal UI flow
return "";
}
}
private static async getTimeLoggedByProjects(projects: string[], users: string[], key: string, dateRange: string[], archived = false, user_id = "", billable: { billable: boolean; nonBillable: boolean }): Promise<any> {
try {
const projectIds = projects.map(p => `'${p}'`).join(",");
@@ -96,8 +77,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
sps.icon AS status_icon,
(SELECT COUNT(*)
FROM tasks
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
AND project_id = projects.id ${billableQuery}) AS all_tasks_count,
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END ${billableQuery}
AND project_id = projects.id) AS all_tasks_count,
(SELECT COUNT(*)
FROM tasks
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
@@ -113,11 +94,10 @@ export default class ReportingAllocationController extends ReportingControllerBa
SELECT name,
(SELECT COALESCE(SUM(time_spent), 0)
FROM task_work_log
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
WHERE user_id = users.id
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
WHERE user_id = users.id ${billableQuery}
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
AND tasks.project_id = projects.id
${billableQuery}
${duration}) AS time_logged
FROM users
WHERE id IN (${userIds})
@@ -141,11 +121,10 @@ export default class ReportingAllocationController extends ReportingControllerBa
const q = `(SELECT id,
(SELECT COALESCE(SUM(time_spent), 0)
FROM task_work_log
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
LEFT JOIN tasks ON task_work_log.task_id = tasks.id ${billableQuery}
WHERE user_id = users.id
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
AND tasks.project_id IN (${projectIds})
${billableQuery}
${duration}) AS time_logged
FROM users
WHERE id IN (${userIds})
@@ -367,8 +346,6 @@ export default class ReportingAllocationController extends ReportingControllerBa
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const categories = (req.body.categories || []) as string[];
const noCategory = req.body.noCategory || false;
const billable = req.body.billable;
if (!teamIds || !projectIds.length)
@@ -384,33 +361,6 @@ export default class ReportingAllocationController extends ReportingControllerBa
const billableQuery = this.buildBillableQuery(billable);
// Prepare projects filter
let projectsFilter = "";
if (projectIds.length > 0) {
projectsFilter = `AND p.id IN (${projectIds})`;
} else {
// If no projects are selected, don't show any data
projectsFilter = `AND 1=0`; // This will match no rows
}
// Prepare categories filter - updated logic
let categoriesFilter = "";
if (categories.length > 0 && noCategory) {
// Both specific categories and "No Category" are selected
const categoryIds = categories.map(id => `'${id}'`).join(",");
categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
} else if (categories.length === 0 && noCategory) {
// Only "No Category" is selected
categoriesFilter = `AND p.category_id IS NULL`;
} else if (categories.length > 0 && !noCategory) {
// Only specific categories are selected
const categoryIds = categories.map(id => `'${id}'`).join(",");
categoriesFilter = `AND p.category_id IN (${categoryIds})`;
} else {
// categories.length === 0 && !noCategory - no categories selected, show nothing
categoriesFilter = `AND 1=0`; // This will match no rows
}
const q = `
SELECT p.id,
p.name,
@@ -418,15 +368,13 @@ export default class ReportingAllocationController extends ReportingControllerBa
SUM(total_minutes) AS estimated,
color_code
FROM projects p
LEFT JOIN tasks ON tasks.project_id = p.id
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} ${categoriesFilter} ${billableQuery}
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
GROUP BY p.id, p.name
ORDER BY logged_time DESC;`;
const result = await db.query(q, []);
const utilization = (req.body.utilization || []) as string[];
const data = [];
for (const project of result.rows) {
@@ -453,271 +401,38 @@ export default class ReportingAllocationController extends ReportingControllerBa
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const categories = (req.body.categories || []) as string[];
const noCategory = req.body.noCategory || false;
const billable = req.body.billable;
if (!teamIds)
return res.status(200).send(new ServerResponse(true, { filteredRows: [], totals: { total_time_logs: "0", total_estimated_hours: "0", total_utilization: "0" } }));
if (!teamIds || !projectIds.length)
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
const { duration, date_range } = req.body;
// Calculate the date range (start and end)
let startDate: moment.Moment;
let endDate: moment.Moment;
if (date_range && date_range.length === 2) {
startDate = moment(date_range[0]);
endDate = moment(date_range[1]);
} else if (duration === DATE_RANGES.ALL_TIME) {
// Fetch the earliest start_date (or created_at if null) from selected projects
const minDateQuery = projectIds.length > 0
? `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`
: `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE team_id IN (${teamIds})`;
const minDateResult = await db.query(minDateQuery, []);
const minDate = minDateResult.rows[0]?.min_date;
startDate = minDate ? moment(minDate) : moment('2000-01-01');
endDate = moment();
} else {
switch (duration) {
case DATE_RANGES.YESTERDAY:
startDate = moment().subtract(1, "day");
endDate = moment().subtract(1, "day");
break;
case DATE_RANGES.LAST_WEEK:
startDate = moment().subtract(1, "week").startOf("isoWeek");
endDate = moment().subtract(1, "week").endOf("isoWeek");
break;
case DATE_RANGES.LAST_MONTH:
startDate = moment().subtract(1, "month").startOf("month");
endDate = moment().subtract(1, "month").endOf("month");
break;
case DATE_RANGES.LAST_QUARTER:
startDate = moment().subtract(3, "months").startOf("quarter");
endDate = moment().subtract(1, "quarter").endOf("quarter");
break;
default:
startDate = moment().startOf("day");
endDate = moment().endOf("day");
}
}
// 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 current = startDate.clone();
while (current.isSameOrBefore(endDate, 'day')) {
const day = current.isoWeekday();
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');
}
// Get organization working hours
const orgWorkingHoursQuery = `SELECT working_hours FROM organizations WHERE id = (SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1)`;
const orgWorkingHoursResult = await db.query(orgWorkingHoursQuery, []);
const orgWorkingHours = orgWorkingHoursResult.rows[0]?.working_hours || 8;
// Calculate total working hours with minimum baseline for non-working day scenarios
let totalWorkingHours = workingDays * orgWorkingHours;
let isNonWorkingPeriod = false;
// If no working days but there might be logged time, set minimum baseline
// This ensures that time logged on non-working days is treated as over-utilization
// Business Logic: If someone works on weekends/holidays when workingDays = 0,
// we use a minimal baseline (1 hour) so any logged time results in >100% utilization
if (totalWorkingHours === 0) {
totalWorkingHours = 1; // Minimal baseline to ensure over-utilization
isNonWorkingPeriod = true;
}
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
const archivedClause = archived
? ""
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
const billableQuery = this.buildBillableQueryWithAlias(billable, 't');
const members = (req.body.members || []) as string[];
// Prepare members filter - updated logic to handle Clear All scenario
let membersFilter = "";
if (members.length > 0) {
const memberIds = members.map(id => `'${id}'`).join(",");
membersFilter = `AND tmiv.team_member_id IN (${memberIds})`;
} else {
// No members selected - show no data (Clear All scenario)
membersFilter = `AND 1=0`; // This will match no rows
}
const billableQuery = this.buildBillableQuery(billable);
// Prepare projects filter
let projectsFilter = "";
if (projectIds.length > 0) {
projectsFilter = `AND p.id IN (${projectIds})`;
} else {
// If no projects are selected, don't show any data
projectsFilter = `AND 1=0`; // This will match no rows
}
// Prepare categories filter - updated logic
let categoriesFilter = "";
if (categories.length > 0 && noCategory) {
// Both specific categories and "No Category" are selected
const categoryIds = categories.map(id => `'${id}'`).join(",");
categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
} else if (categories.length === 0 && noCategory) {
// Only "No Category" is selected
categoriesFilter = `AND p.category_id IS NULL`;
} else if (categories.length > 0 && !noCategory) {
// Only specific categories are selected
const categoryIds = categories.map(id => `'${id}'`).join(",");
categoriesFilter = `AND p.category_id IN (${categoryIds})`;
} else {
// categories.length === 0 && !noCategory - no categories selected, show nothing
categoriesFilter = `AND 1=0`; // This will match no rows
}
// Create custom duration clause for twl table alias
let customDurationClause = "";
if (date_range && date_range.length === 2) {
const start = moment(date_range[0]).format("YYYY-MM-DD");
const end = moment(date_range[1]).format("YYYY-MM-DD");
if (start === end) {
customDurationClause = `AND twl.created_at::DATE = '${start}'::DATE`;
} else {
customDurationClause = `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`;
}
} else {
const key = duration || DATE_RANGES.LAST_WEEK;
if (key === DATE_RANGES.YESTERDAY)
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE";
else if (key === DATE_RANGES.LAST_WEEK)
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
else if (key === DATE_RANGES.LAST_MONTH)
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
else if (key === DATE_RANGES.LAST_QUARTER)
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
}
// Modified query to start from team members and calculate filtered time logs
const q = `
SELECT
tmiv.team_member_id,
tmiv.email,
tmiv.name,
COALESCE(
(SELECT SUM(twl.time_spent)
FROM task_work_log twl
LEFT JOIN tasks t ON t.id = twl.task_id
LEFT JOIN projects p ON p.id = t.project_id
WHERE twl.user_id = tmiv.user_id
${customDurationClause}
${projectsFilter}
${categoriesFilter}
${archivedClause}
${billableQuery}
AND p.team_id = tmiv.team_id
), 0
) AS logged_time
FROM team_member_info_view tmiv
WHERE tmiv.team_id IN (${teamIds})
AND tmiv.active = TRUE
${membersFilter}
GROUP BY tmiv.email, tmiv.name, tmiv.team_member_id, tmiv.user_id, tmiv.team_id
ORDER BY logged_time DESC;`;
SELECT tmiv.email, tmiv.name, SUM(time_spent) AS logged_time
FROM team_member_info_view tmiv
LEFT JOIN task_work_log ON task_work_log.user_id = tmiv.user_id
LEFT JOIN tasks ON tasks.id = task_work_log.task_id ${billableQuery}
LEFT JOIN projects p ON p.id = tasks.project_id AND p.team_id = tmiv.team_id
WHERE p.id IN (${projectIds})
${durationClause} ${archivedClause}
GROUP BY tmiv.email, tmiv.name
ORDER BY logged_time DESC;`;
const result = await db.query(q, []);
const utilization = (req.body.utilization || []) as string[];
// Precompute totalWorkingHours * 3600 for efficiency
const totalWorkingSeconds = totalWorkingHours * 3600;
// calculate utilization state
for (let i = 0, len = result.rows.length; i < len; i++) {
const member = result.rows[i];
const loggedSeconds = member.logged_time ? parseFloat(member.logged_time) : 0;
const utilizedHours = loggedSeconds / 3600;
// For individual members, use the same logic as total calculation
let memberWorkingHours = totalWorkingHours;
if (isNonWorkingPeriod && loggedSeconds > 0) {
// Any time logged during non-working period should be treated as over-utilization
memberWorkingHours = Math.min(utilizedHours, 1); // Use actual time or 1 hour, whichever is smaller
}
const utilizationPercent = memberWorkingHours > 0 && loggedSeconds
? ((loggedSeconds / (memberWorkingHours * 3600)) * 100)
: 0;
const overUnder = utilizedHours - memberWorkingHours;
member.value = utilizedHours ? parseFloat(utilizedHours.toFixed(2)) : 0;
for (const member of result.rows) {
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
member.color_code = getColor(member.name);
member.total_working_hours = memberWorkingHours;
member.utilization_percent = utilizationPercent.toFixed(2);
member.utilized_hours = utilizedHours.toFixed(2);
member.over_under_utilized_hours = overUnder.toFixed(2);
if (utilizationPercent < 90) {
member.utilization_state = 'under';
} else if (utilizationPercent <= 110) {
member.utilization_state = 'optimal';
} else {
member.utilization_state = 'over';
}
}
// Apply utilization filter
let filteredRows;
if (utilization.length > 0) {
// Filter to only show selected utilization states
filteredRows = result.rows.filter(member => utilization.includes(member.utilization_state));
} else {
// No utilization states selected - show no data (Clear All scenario)
filteredRows = [];
}
// Calculate totals
const total_time_logs = filteredRows.reduce((sum, member) => sum + parseFloat(member.logged_time || '0'), 0);
const total_estimated_hours = totalWorkingHours * filteredRows.length; // Total for all members
const total_utilization = total_time_logs > 0 && total_estimated_hours > 0
? ((total_time_logs / (total_estimated_hours * 3600)) * 100).toFixed(1)
: '0';
return res.status(200).send(new ServerResponse(true, {
filteredRows,
totals: {
total_time_logs: ((total_time_logs / 3600).toFixed(2)).toString(),
total_estimated_hours: total_estimated_hours.toString(),
total_utilization: total_utilization.toString(),
},
}));
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
@@ -800,9 +515,6 @@ export default class ReportingAllocationController extends ReportingControllerBa
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const categories = (req.body.categories || []) as string[];
const noCategory = req.body.noCategory || false;
const { type, billable } = req.body;
if (!teamIds || !projectIds.length)
@@ -818,33 +530,6 @@ export default class ReportingAllocationController extends ReportingControllerBa
const billableQuery = this.buildBillableQuery(billable);
// Prepare projects filter
let projectsFilter = "";
if (projectIds.length > 0) {
projectsFilter = `AND p.id IN (${projectIds})`;
} else {
// If no projects are selected, don't show any data
projectsFilter = `AND 1=0`; // This will match no rows
}
// Prepare categories filter - updated logic
let categoriesFilter = "";
if (categories.length > 0 && noCategory) {
// Both specific categories and "No Category" are selected
const categoryIds = categories.map(id => `'${id}'`).join(",");
categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
} else if (categories.length === 0 && noCategory) {
// Only "No Category" is selected
categoriesFilter = `AND p.category_id IS NULL`;
} else if (categories.length > 0 && !noCategory) {
// Only specific categories are selected
const categoryIds = categories.map(id => `'${id}'`).join(",");
categoriesFilter = `AND p.category_id IN (${categoryIds})`;
} else {
// categories.length === 0 && !noCategory - no categories selected, show nothing
categoriesFilter = `AND 1=0`; // This will match no rows
}
const q = `
SELECT p.id,
p.name,
@@ -858,9 +543,9 @@ export default class ReportingAllocationController extends ReportingControllerBa
WHERE project_id = p.id) AS estimated,
color_code
FROM projects p
LEFT JOIN tasks ON tasks.project_id = p.id
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause} ${categoriesFilter} ${billableQuery}
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
GROUP BY p.id, p.name
ORDER BY logged_time DESC;`;
const result = await db.query(q, []);

View File

@@ -31,7 +31,6 @@ export default class ReportingMembersController extends ReportingControllerBase
const completedDurationClasue = this.completedDurationFilter(key, dateRange);
const overdueActivityLogsClause = this.getActivityLogsOverdue(key, dateRange);
const activityLogCreationFilter = this.getActivityLogsCreationClause(key, dateRange);
const timeLogDateRangeClause = this.getTimeLogDateRangeClause(key, dateRange);
const q = `SELECT COUNT(DISTINCT email) AS total,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
@@ -101,25 +100,7 @@ export default class ReportingMembersController extends ReportingControllerBase
FROM tasks t
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
WHERE team_member_id = tmiv.team_member_id
AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs,
(SELECT COALESCE(SUM(twl.time_spent), 0)
FROM task_work_log twl
LEFT JOIN tasks t ON twl.task_id = t.id
WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id)
AND t.billable IS TRUE
AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1)
${timeLogDateRangeClause}
${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS billable_time,
(SELECT COALESCE(SUM(twl.time_spent), 0)
FROM task_work_log twl
LEFT JOIN tasks t ON twl.task_id = t.id
WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id)
AND t.billable IS FALSE
AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1)
${timeLogDateRangeClause}
${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS non_billable_time
AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs
FROM team_member_info_view tmiv
WHERE tmiv.team_id = $1 ${teamsClause}
AND tmiv.team_member_id IN (SELECT team_member_id
@@ -330,30 +311,6 @@ export default class ReportingMembersController extends ReportingControllerBase
return "";
}
protected static getTimeLogDateRangeClause(key: string, dateRange: string[]) {
if (dateRange.length === 2) {
const start = moment(dateRange[0]).format("YYYY-MM-DD");
const end = moment(dateRange[1]).format("YYYY-MM-DD");
if (start === end) {
return `AND twl.created_at::DATE = '${start}'::DATE`;
}
return `AND twl.created_at::DATE >= '${start}'::DATE AND twl.created_at < '${end}'::DATE + INTERVAL '1 day'`;
}
if (key === DATE_RANGES.YESTERDAY)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE`;
if (key === DATE_RANGES.LAST_WEEK)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
if (key === DATE_RANGES.LAST_MONTH)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
if (key === DATE_RANGES.LAST_QUARTER)
return `AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`;
return "";
}
private static formatDuration(duration: moment.Duration) {
const empty = "0h 0m";
let format = "";
@@ -466,8 +423,6 @@ export default class ReportingMembersController extends ReportingControllerBase
{ header: "Overdue Tasks", key: "overdue_tasks", width: 20 },
{ header: "Completed Tasks", key: "completed_tasks", width: 20 },
{ header: "Ongoing Tasks", key: "ongoing_tasks", width: 20 },
{ header: "Billable Time (seconds)", key: "billable_time", width: 25 },
{ header: "Non-Billable Time (seconds)", key: "non_billable_time", width: 25 },
{ header: "Done Tasks(%)", key: "done_tasks", width: 20 },
{ header: "Doing Tasks(%)", key: "doing_tasks", width: 20 },
{ header: "Todo Tasks(%)", key: "todo_tasks", width: 20 }
@@ -475,14 +430,14 @@ export default class ReportingMembersController extends ReportingControllerBase
// set title
sheet.getCell("A1").value = `Members from ${teamName}`;
sheet.mergeCells("A1:M1");
sheet.mergeCells("A1:K1");
sheet.getCell("A1").alignment = { horizontal: "center" };
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
sheet.getCell("A1").font = { size: 16 };
// set export date
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
sheet.mergeCells("A2:M2");
sheet.mergeCells("A2:K2");
sheet.getCell("A2").alignment = { horizontal: "center" };
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
sheet.getCell("A2").font = { size: 12 };
@@ -492,7 +447,7 @@ export default class ReportingMembersController extends ReportingControllerBase
sheet.mergeCells("A3:D3");
// set table headers
sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Billable Time (seconds)", "Non-Billable Time (seconds)", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"];
sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"];
sheet.getRow(5).font = { bold: true };
for (const member of result.members) {
@@ -503,8 +458,6 @@ export default class ReportingMembersController extends ReportingControllerBase
overdue_tasks: member.overdue,
completed_tasks: member.completed,
ongoing_tasks: member.ongoing,
billable_time: member.billable_time || 0,
non_billable_time: member.non_billable_time || 0,
done_tasks: member.completed,
doing_tasks: member.ongoing_by_activity_logs,
todo_tasks: member.todo_by_activity_logs

View File

@@ -74,9 +74,14 @@ export default class ScheduleControllerV2 extends WorklenzControllerBase {
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
.join(", ");
const updateQuery = `UPDATE public.organization_working_days
const updateQuery = `
UPDATE public.organization_working_days
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
WHERE organization_id IN (SELECT id FROM organizations WHERE user_id = $1);`;
WHERE organization_id IN (
SELECT organization_id FROM organizations
WHERE user_id = $1
);
`;
await db.query(updateQuery, [req.user?.owner_id]);

View File

@@ -28,50 +28,32 @@ export default class TaskWorklogController extends WorklenzControllerBase {
if (!id) return [];
const q = `
WITH RECURSIVE task_hierarchy AS (
-- Base case: Start with the given task
SELECT id, name, 0 as level
FROM tasks
WHERE id = $1
UNION ALL
-- Recursive case: Get all subtasks
SELECT t.id, t.name, th.level + 1
FROM tasks t
INNER JOIN task_hierarchy th ON t.parent_task_id = th.id
WHERE t.archived IS FALSE
),
time_logs AS (
SELECT
twl.id,
twl.description,
twl.time_spent,
twl.created_at,
twl.user_id,
twl.logged_by_timer,
twl.task_id,
th.name AS task_name,
(SELECT name FROM users WHERE users.id = twl.user_id) AS user_name,
(SELECT email FROM users WHERE users.id = twl.user_id) AS user_email,
(SELECT avatar_url FROM users WHERE users.id = twl.user_id) AS avatar_url
FROM task_work_log twl
INNER JOIN task_hierarchy th ON twl.task_id = th.id
WITH time_logs AS (
--
SELECT id,
description,
time_spent,
created_at,
user_id,
logged_by_timer,
(SELECT name FROM users WHERE users.id = task_work_log.user_id) AS user_name,
(SELECT email FROM users WHERE users.id = task_work_log.user_id) AS user_email,
(SELECT avatar_url FROM users WHERE users.id = task_work_log.user_id) AS avatar_url
FROM task_work_log
WHERE task_id = $1
--
)
SELECT
id,
time_spent,
description,
created_at,
user_id,
logged_by_timer,
task_id,
task_name,
created_at AS start_time,
(created_at + INTERVAL '1 second' * time_spent) AS end_time,
user_name,
user_email,
avatar_url
SELECT id,
time_spent,
description,
created_at,
user_id,
logged_by_timer,
created_at AS start_time,
(created_at + INTERVAL '1 second' * time_spent) AS end_time,
user_name,
user_email,
avatar_url
FROM time_logs
ORDER BY created_at DESC;
`;
@@ -161,7 +143,6 @@ export default class TaskWorklogController extends WorklenzControllerBase {
};
sheet.columns = [
{header: "Task Name", key: "task_name", width: 30},
{header: "Reporter Name", key: "user_name", width: 25},
{header: "Reporter Email", key: "user_email", width: 25},
{header: "Start Time", key: "start_time", width: 25},
@@ -172,15 +153,14 @@ export default class TaskWorklogController extends WorklenzControllerBase {
];
sheet.getCell("A1").value = metadata.project_name;
sheet.mergeCells("A1:H1");
sheet.mergeCells("A1:G1");
sheet.getCell("A1").alignment = {horizontal: "center"};
sheet.getCell("A2").value = `${metadata.name} (${exportDate})`;
sheet.mergeCells("A2:H2");
sheet.mergeCells("A2:G2");
sheet.getCell("A2").alignment = {horizontal: "center"};
sheet.getRow(4).values = [
"Task Name",
"Reporter Name",
"Reporter Email",
"Start Time",
@@ -196,7 +176,6 @@ export default class TaskWorklogController extends WorklenzControllerBase {
for (const item of results) {
totalLogged += parseFloat((item.time_spent || 0).toString());
const data = {
task_name: item.task_name,
user_name: item.user_name,
user_email: item.user_email,
start_time: moment(item.start_time).add(timezone.hours || 0, "hours").add(timezone.minutes || 0, "minutes").format(timeFormat),
@@ -231,7 +210,6 @@ export default class TaskWorklogController extends WorklenzControllerBase {
};
sheet.addRow({
task_name: "",
user_name: "",
user_email: "",
start_time: "Total",
@@ -241,7 +219,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
time_spent: formatDuration(moment.duration(totalLogged, "seconds")),
});
sheet.mergeCells(`A${sheet.rowCount}:G${sheet.rowCount}`);
sheet.mergeCells(`A${sheet.rowCount}:F${sheet.rowCount}`);
sheet.getCell(`A${sheet.rowCount}`).value = "Total";
sheet.getCell(`A${sheet.rowCount}`).alignment = {

View File

@@ -1,6 +1,6 @@
import WorklenzControllerBase from "./worklenz-controller-base";
import { getColor } from "../shared/utils";
import { PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
import {getColor} from "../shared/utils";
import {PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
import moment from "moment/moment";
export const GroupBy = {
@@ -32,46 +32,10 @@ export default class TasksControllerBase extends WorklenzControllerBase {
}
public static updateTaskViewModel(task: any) {
// For parent tasks (with subtasks), always use calculated progress from subtasks
if (task.sub_tasks_count > 0) {
// Ensure progress matches complete_ratio for consistency
task.progress = task.complete_ratio || 0;
// Important: Parent tasks should not have manual progress
// If they somehow do, reset it
if (task.manual_progress) {
task.manual_progress = false;
task.progress_value = null;
}
}
// For tasks without subtasks, respect manual progress if set
else if (task.manual_progress === true && task.progress_value !== null && task.progress_value !== undefined) {
// For manually set progress, use that value directly
task.progress = parseInt(task.progress_value);
task.complete_ratio = parseInt(task.progress_value);
}
// For tasks with no subtasks and no manual progress
else {
// Only calculate progress based on time if time-based progress is enabled for the project
if (task.project_use_time_progress && task.total_minutes_spent && task.total_minutes) {
// Cap the progress at 100% to prevent showing more than 100% progress
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
task.complete_ratio = task.progress;
}
// Ensure numeric values
task.progress = parseInt(task.progress) || 0;
task.complete_ratio = parseInt(task.complete_ratio) || 0;
task.progress = ~~(task.total_minutes_spent / task.total_minutes * 100);
task.overdue = task.total_minutes < task.total_minutes_spent;
task.time_spent = { hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60 };
task.time_spent = {hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60};
task.comments_count = Number(task.comments_count) ? +task.comments_count : 0;
task.attachments_count = Number(task.attachments_count) ? +task.attachments_count : 0;
@@ -81,31 +45,7 @@ export default class TasksControllerBase extends WorklenzControllerBase {
task.is_sub_task = !!task.parent_task_id;
task.time_spent_string = `${task.time_spent.hours}h ${(task.time_spent.minutes)}m`;
// Use recursive estimation for parent tasks, own estimation for leaf tasks
const recursiveEstimation = task.recursive_estimation || {};
const hasSubtasks = (task.sub_tasks_count || 0) > 0;
let displayMinutes;
if (hasSubtasks) {
// For parent tasks, use recursive estimation (sum of all subtasks)
displayMinutes = recursiveEstimation.recursive_total_minutes || 0;
} else {
// For leaf tasks, use their own estimation
displayMinutes = task.total_minutes || 0;
}
// Format time string - show "0h" for zero time instead of "0h 0m"
const hours = ~~(displayMinutes / 60);
const minutes = displayMinutes % 60;
if (displayMinutes === 0) {
task.total_time_string = "0h";
} else if (minutes === 0) {
task.total_time_string = `${hours}h`;
} else {
task.total_time_string = `${hours}h ${minutes}m`;
}
task.total_time_string = `${~~(task.total_minutes / 60)}h ${(task.total_minutes % 60)}m`;
task.name_color = getColor(task.name);
task.priority_color = PriorityColorCodes[task.priority_value] || PriorityColorCodes["0"];
@@ -133,9 +73,9 @@ export default class TasksControllerBase extends WorklenzControllerBase {
if (task.timer_start_time)
task.timer_start_time = moment(task.timer_start_time).valueOf();
// Set completed_count and total_tasks_count regardless of progress calculation method
const totalCompleted = (+task.completed_sub_tasks + +task.parent_task_completed) || 0;
const totalTasks = +task.sub_tasks_count || 0;
const totalTasks = +task.sub_tasks_count || 0; // if needed add +1 for parent
task.complete_ratio = TasksControllerBase.calculateTaskCompleteRatio(totalCompleted, totalTasks);
task.completed_count = totalCompleted;
task.total_tasks_count = totalTasks;

View File

@@ -97,13 +97,9 @@ export default class TasksControllerV2 extends TasksControllerBase {
try {
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
const [data] = result.rows;
if (data && data.info && data.info.ratio !== undefined) {
data.info.ratio = +((data.info.ratio || 0).toFixed());
return data.info;
}
return null;
data.info.ratio = +data.info.ratio.toFixed();
return data.info;
} catch (error) {
log_error(`Error in getTaskCompleteRatio: ${error}`);
return null;
}
}
@@ -196,13 +192,6 @@ export default class TasksControllerV2 extends TasksControllerBase {
t.archived,
t.description,
t.sort_order,
t.progress_value,
t.manual_progress,
t.weight,
(SELECT use_manual_progress FROM projects WHERE id = t.project_id) AS project_use_manual_progress,
(SELECT use_weighted_progress FROM projects WHERE id = t.project_id) AS project_use_weighted_progress,
(SELECT use_time_progress FROM projects WHERE id = t.project_id) AS project_use_time_progress,
(SELECT get_task_complete_ratio(t.id)->>'ratio') AS complete_ratio,
(SELECT phase_id FROM task_phase WHERE task_id = t.id) AS phase_id,
(SELECT name
@@ -258,7 +247,6 @@ export default class TasksControllerV2 extends TasksControllerBase {
(SELECT id FROM task_priorities WHERE id = t.priority_id) AS priority,
(SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value,
total_minutes,
(SELECT get_task_recursive_estimation(t.id)) AS recursive_estimation,
(SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent,
created_at,
updated_at,
@@ -327,11 +315,6 @@ export default class TasksControllerV2 extends TasksControllerBase {
@HandleExceptions()
public static async getList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
// Before doing anything else, refresh task progress values for this project
if (req.params.id) {
await this.refreshProjectTaskProgressValues(req.params.id);
}
const isSubTasks = !!req.query.parent_task;
const groupBy = (req.query.group || GroupBy.STATUS) as string;
@@ -351,7 +334,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
return g;
}, {});
await this.updateMapByGroup(tasks, groupBy, map);
this.updateMapByGroup(tasks, groupBy, map);
const updatedGroups = Object.keys(map).map(key => {
const group = map[key];
@@ -370,28 +353,12 @@ export default class TasksControllerV2 extends TasksControllerBase {
return res.status(200).send(new ServerResponse(true, updatedGroups));
}
public static async updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) {
public static updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) {
let index = 0;
const unmapped = [];
// First, ensure we have the latest progress values for all tasks
for (const task of tasks) {
// For any task with subtasks, ensure we have the latest progress values
if (task.sub_tasks_count > 0) {
const info = await this.getTaskCompleteRatio(task.id);
if (info) {
task.complete_ratio = info.ratio;
task.progress_value = info.ratio; // Ensure progress_value reflects the calculated ratio
console.log(`Updated task ${task.name} (${task.id}): complete_ratio=${task.complete_ratio}`);
}
}
}
// Now group the tasks with their updated progress values
for (const task of tasks) {
task.index = index++;
TasksControllerV2.updateTaskViewModel(task);
if (groupBy === GroupBy.STATUS) {
map[task.status]?.tasks.push(task);
} else if (groupBy === GroupBy.PRIORITY) {
@@ -427,13 +394,8 @@ export default class TasksControllerV2 extends TasksControllerBase {
@HandleExceptions()
public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
// Before doing anything else, refresh task progress values for this project
if (req.params.id) {
await this.refreshProjectTaskProgressValues(req.params.id);
}
const isSubTasks = !!req.query.parent_task;
// Add customColumns flag to query params
req.query.customColumns = "true";
@@ -448,24 +410,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
[data] = result.rows;
} else { // else we return a flat list of tasks
data = [...result.rows];
for (const task of data) {
// For tasks with subtasks, get the complete ratio from the database function
if (task.sub_tasks_count > 0) {
try {
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [task.id]);
const [ratioData] = result.rows;
if (ratioData && ratioData.info) {
task.complete_ratio = +(ratioData.info.ratio || 0).toFixed();
task.completed_count = ratioData.info.total_completed;
task.total_tasks_count = ratioData.info.total_tasks;
console.log(`Updated task ${task.id} (${task.name}) from DB: complete_ratio=${task.complete_ratio}`);
}
} catch (error) {
// Proceed with default calculation if database call fails
}
}
TasksControllerV2.updateTaskViewModel(task);
}
}
@@ -498,53 +443,6 @@ export default class TasksControllerV2 extends TasksControllerBase {
return res.status(200).send(new ServerResponse(true, task));
}
@HandleExceptions()
public static async resetParentTaskManualProgress(parentTaskId: string): Promise<void> {
try {
// Check if this task has subtasks
const subTasksResult = await db.query(
"SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1 AND archived IS FALSE",
[parentTaskId]
);
const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || "0");
// If it has subtasks, reset the manual_progress flag to false
if (subtaskCount > 0) {
await db.query(
"UPDATE tasks SET manual_progress = false WHERE id = $1",
[parentTaskId]
);
console.log(`Reset manual progress for parent task ${parentTaskId} with ${subtaskCount} subtasks`);
// Get the project settings to determine which calculation method to use
const projectResult = await db.query(
"SELECT project_id FROM tasks WHERE id = $1",
[parentTaskId]
);
const projectId = projectResult.rows[0]?.project_id;
if (projectId) {
// Recalculate the parent task's progress based on its subtasks
const progressResult = await db.query(
"SELECT get_task_complete_ratio($1) AS ratio",
[parentTaskId]
);
const progressRatio = progressResult.rows[0]?.ratio?.ratio || 0;
// Emit the updated progress value to all clients
// Note: We don't have socket context here, so we can't directly emit
// This will be picked up on the next client refresh
console.log(`Recalculated progress for parent task ${parentTaskId}: ${progressRatio}%`);
}
}
} catch (error) {
log_error(`Error resetting parent task manual progress: ${error}`);
}
}
@HandleExceptions()
public static async convertToSubtask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
@@ -584,11 +482,6 @@ export default class TasksControllerV2 extends TasksControllerBase {
? [req.body.id, req.body.to_group_id]
: [req.body.id, req.body.project_id, req.body.parent_task_id, req.body.to_group_id];
await db.query(q, params);
// Reset the parent task's manual progress when converting a task to a subtask
if (req.body.parent_task_id) {
await this.resetParentTaskManualProgress(req.body.parent_task_id);
}
const result = await db.query("SELECT get_single_task($1) AS task;", [req.body.id]);
const [data] = result.rows;
@@ -831,126 +724,4 @@ export default class TasksControllerV2 extends TasksControllerBase {
value
}));
}
public static async refreshProjectTaskProgressValues(projectId: string): Promise<void> {
try {
// Run the recalculate_all_task_progress function only for tasks in this project
const query = `
DO $$
BEGIN
-- First, reset manual_progress flag for all tasks that have subtasks within this project
UPDATE tasks AS t
SET manual_progress = FALSE
WHERE project_id = '${projectId}'
AND EXISTS (
SELECT 1
FROM tasks
WHERE parent_task_id = t.id
AND archived IS FALSE
);
-- Start recalculation from leaf tasks (no subtasks) and propagate upward
-- This ensures calculations are done in the right order
WITH RECURSIVE task_hierarchy AS (
-- Base case: Start with all leaf tasks (no subtasks) in this project
SELECT
id,
parent_task_id,
0 AS level
FROM tasks
WHERE project_id = '${projectId}'
AND NOT EXISTS (
SELECT 1 FROM tasks AS sub
WHERE sub.parent_task_id = tasks.id
AND sub.archived IS FALSE
)
AND archived IS FALSE
UNION ALL
-- Recursive case: Move up to parent tasks, but only after processing all their children
SELECT
t.id,
t.parent_task_id,
th.level + 1
FROM tasks t
JOIN task_hierarchy th ON t.id = th.parent_task_id
WHERE t.archived IS FALSE
)
-- Sort by level to ensure we calculate in the right order (leaves first, then parents)
UPDATE tasks
SET progress_value = (SELECT (get_task_complete_ratio(tasks.id)->>'ratio')::FLOAT)
FROM (
SELECT id, level
FROM task_hierarchy
ORDER BY level
) AS ordered_tasks
WHERE tasks.id = ordered_tasks.id
AND tasks.project_id = '${projectId}'
AND (manual_progress IS FALSE OR manual_progress IS NULL);
END $$;
`;
await db.query(query);
console.log(`Finished refreshing progress values for project ${projectId}`);
} catch (error) {
log_error("Error refreshing project task progress values", error);
}
}
public static async updateTaskProgress(taskId: string): Promise<void> {
try {
// Calculate the task's progress using get_task_complete_ratio
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
const [data] = result.rows;
if (data && data.info && data.info.ratio !== undefined) {
const progressValue = +((data.info.ratio || 0).toFixed());
// Update the task's progress_value in the database
await db.query(
"UPDATE tasks SET progress_value = $1 WHERE id = $2",
[progressValue, taskId]
);
console.log(`Updated progress for task ${taskId} to ${progressValue}%`);
// If this task has a parent, update the parent's progress as well
const parentResult = await db.query(
"SELECT parent_task_id FROM tasks WHERE id = $1",
[taskId]
);
if (parentResult.rows.length > 0 && parentResult.rows[0].parent_task_id) {
await this.updateTaskProgress(parentResult.rows[0].parent_task_id);
}
}
} catch (error) {
log_error(`Error updating task progress: ${error}`);
}
}
// Add this method to update progress when a task's weight is changed
public static async updateTaskWeight(taskId: string, weight: number): Promise<void> {
try {
// Update the task's weight
await db.query(
"UPDATE tasks SET weight = $1 WHERE id = $2",
[weight, taskId]
);
// Get the parent task ID
const parentResult = await db.query(
"SELECT parent_task_id FROM tasks WHERE id = $1",
[taskId]
);
// If this task has a parent, update the parent's progress
if (parentResult.rows.length > 0 && parentResult.rows[0].parent_task_id) {
await this.updateTaskProgress(parentResult.rows[0].parent_task_id);
}
} catch (error) {
log_error(`Error updating task weight: ${error}`);
}
}
}

View File

@@ -427,24 +427,9 @@ export default class TasksController extends TasksControllerBase {
task.names = WorklenzControllerBase.createTagList(task.assignees);
// Use recursive estimation if task has subtasks, otherwise use own estimation
const recursiveEstimation = task.recursive_estimation || {};
// Check both the recursive estimation count and the actual database count
const hasSubtasks = (task.sub_tasks_count || 0) > 0;
let totalMinutes, hours, minutes;
if (hasSubtasks) {
// For parent tasks, use the sum of all subtasks' estimation (excluding parent's own estimation)
totalMinutes = recursiveEstimation.recursive_total_minutes || 0;
hours = recursiveEstimation.recursive_total_hours || 0;
minutes = recursiveEstimation.recursive_remaining_minutes || 0;
} else {
// For tasks without subtasks, use their own estimation
totalMinutes = task.total_minutes || 0;
hours = Math.floor(totalMinutes / 60);
minutes = totalMinutes % 60;
}
const totalMinutes = task.total_minutes;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
task.total_hours = hours;
task.total_minutes = minutes;
@@ -623,18 +608,6 @@ export default class TasksController extends TasksControllerBase {
return res.status(200).send(new ServerResponse(true, null));
}
@HandleExceptions()
public static async resetParentTaskEstimations(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT reset_all_parent_task_estimations() AS updated_count;`;
const result = await db.query(q);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, {
message: `Reset estimation for ${data.updated_count} parent tasks`,
updated_count: data.updated_count
}));
}
@HandleExceptions()
public static async bulkAssignMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { tasks, members, project_id } = req.body;

View File

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

View File

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

View File

@@ -1,15 +0,0 @@
import {NextFunction} from "express";
import {IWorkLenzRequest} from "../../interfaces/worklenz-request";
import {IWorkLenzResponse} from "../../interfaces/worklenz-response";
import {ServerResponse} from "../../models/server-response";
export default function (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void {
const {name} = req.body;
if (!name || name.trim() === "")
return res.status(200).send(new ServerResponse(false, null, "Name is required"));
req.body.name = req.body.name.trim();
return next();
}

View File

@@ -30,7 +30,6 @@ export async function deserialize(user: { id: string | null }, done: IDeserializ
const excludedSubscriptionTypes = ["TRIAL", "PADDLE"];
const q = `SELECT deserialize_user($1) AS user;`;
const result = await db.query(q, [id]);
if (result.rows.length) {
const [data] = result.rows;
if (data?.user) {

View File

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

View File

@@ -47,55 +47,41 @@ async function handleSignUp(req: Request, email: string, password: string, done:
// team = Invited team_id if req.body.from_invitation is true
const {name, team_name, team_member_id, team_id, timezone} = req.body;
if (!team_name) {
req.flash(ERROR_KEY, "Team name is required");
return done(null, null, {message: "Team name is required"});
}
if (!team_name) return done(null, null, req.flash(ERROR_KEY, "Team name is required"));
const googleAccountFound = await isGoogleAccountFound(email);
if (googleAccountFound) {
req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`);
return done(null, null, {message: `${req.body.email} is already linked with a Google account.`});
}
if (googleAccountFound)
return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`));
try {
const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id);
sendWelcomeEmail(email, name);
req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification.");
return done(null, user, {message: "Registration successful. Please check your email for verification."});
return done(null, user, req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."));
} catch (error: any) {
const message = (error?.message) || "";
if (message === "ERROR_INVALID_JOINING_EMAIL") {
req.flash(ERROR_KEY, `No invitations found for email ${req.body.email}.`);
return done(null, null, {message: `No invitations found for email ${req.body.email}.`});
return done(null, null, req.flash(ERROR_KEY, `No invitations found for email ${req.body.email}.`));
}
// if error.message is "email already exists" then it should have the email address in the error message after ":".
if (message.includes("EMAIL_EXISTS_ERROR") || error.constraint === "users_google_id_uindex") {
const [, value] = error.message.split(":");
const errorMsg = `Worklenz account already exists for email ${value}.`;
req.flash(ERROR_KEY, errorMsg);
return done(null, null, {message: errorMsg});
return done(null, null, req.flash(ERROR_KEY, `Worklenz account already exists for email ${value}.`));
}
if (message.includes("TEAM_NAME_EXISTS_ERROR")) {
const [, value] = error.message.split(":");
const errorMsg = `Team name "${value}" already exists. Please choose a different team name.`;
req.flash(ERROR_KEY, errorMsg);
return done(null, null, {message: errorMsg});
return done(null, null, req.flash(ERROR_KEY, `Team name "${value}" already exists. Please choose a different team name.`));
}
// The Team name is already taken.
if (error.constraint === "teams_url_uindex" || error.constraint === "teams_name_uindex") {
const errorMsg = `Team name "${team_name}" is already taken. Please choose a different team name.`;
req.flash(ERROR_KEY, errorMsg);
return done(null, null, {message: errorMsg});
return done(null, null, req.flash(ERROR_KEY, `Team name "${team_name}" is already taken. Please choose a different team name.`));
}
log_error(error, req.body);
req.flash(ERROR_KEY, DEFAULT_ERROR_MESSAGE);
return done(null, null, {message: DEFAULT_ERROR_MESSAGE});
return done(null, null, req.flash(ERROR_KEY, DEFAULT_ERROR_MESSAGE));
}
}

View File

@@ -1,128 +1,120 @@
import express from "express";
import AccessControlsController from "../../controllers/access-controls-controller";
import AuthController from "../../controllers/auth-controller";
import LogsController from "../../controllers/logs-controller";
import OverviewController from "../../controllers/overview-controller";
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
import attachmentsApiRouter from "./attachments-api-router";
import clientsApiRouter from "./clients-api-router";
import jobTitlesApiRouter from "./job-titles-api-router";
import notificationsApiRouter from "./notifications-api-router";
import personalOverviewApiRouter from "./personal-overview-api-router";
import projectMembersApiRouter from "./project-members-api-router";
import projectsApiRouter from "./projects-api-router";
import settingsApiRouter from "./settings-api-router";
import statusesApiRouter from "./statuses-api-router";
import subTasksApiRouter from "./sub-tasks-api-router";
import taskCommentsApiRouter from "./task-comments-api-router";
import taskWorkLogApiRouter from "./task-work-log-api-router";
import tasksApiRouter from "./tasks-api-router";
import teamMembersApiRouter from "./team-members-api-router";
import teamsApiRouter from "./teams-api-router";
import timezonesApiRouter from "./timezones-api-router";
import todoListApiRouter from "./todo-list-api-router";
import projectStatusesApiRouter from "./project-statuses-api-router";
import labelsApiRouter from "./labels-api-router";
import sharedProjectsApiRouter from "./shared-projects-api-router";
import resourceAllocationApiRouter from "./resource-allocation-api-router";
import taskTemplatesApiRouter from "./task-templates-api-router";
import projectInsightsApiRouter from "./project-insights-api-router";
import passwordValidator from "../../middlewares/validators/password-validator";
import adminCenterApiRouter from "./admin-center-api-router";
import reportingApiRouter from "./reporting-api-router";
import activityLogsApiRouter from "./activity-logs-api-router";
import safeControllerFunction from "../../shared/safe-controller-function";
import projectFoldersApiRouter from "./project-folders-api-router";
import taskPhasesApiRouter from "./task-phases-api-router";
import projectCategoriesApiRouter from "./project-categories-api-router";
import homePageApiRouter from "./home-page-api-router";
import ganttApiRouter from "./gantt-api-router";
import projectCommentsApiRouter from "./project-comments-api-router";
import reportingExportApiRouter from "./reporting-export-api-router";
import projectHealthsApiRouter from "./project-healths-api-router";
import ptTasksApiRouter from "./pt-tasks-api-router";
import projectTemplatesApiRouter from "./project-templates-api";
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
import ptStatusesApiRouter from "./pt-statuses-api-router";
import workloadApiRouter from "./gannt-apis/workload-api-router";
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
import projectManagerApiRouter from "./project-managers-api-router";
import billingApiRouter from "./billing-api-router";
import taskDependenciesApiRouter from "./task-dependencies-api-router";
import taskRecurringApiRouter from "./task-recurring-api-router";
import customColumnsApiRouter from "./custom-columns-api-router";
import ratecardApiRouter from "./ratecard-api-router";
import projectRatecardApiRouter from "./project-ratecard-api-router";
import projectFinanceApiRouter from "./project-finance-api-router";
const api = express.Router();
api.use("/projects", projectsApiRouter);
api.use("/team-members", teamMembersApiRouter);
api.use("/job-titles", jobTitlesApiRouter);
api.use("/clients", clientsApiRouter);
api.use("/rate-cards", ratecardApiRouter);
api.use("/project-rate-cards", projectRatecardApiRouter);
api.use("/teams", teamsApiRouter);
api.use("/tasks", tasksApiRouter);
api.use("/settings", settingsApiRouter);
api.use("/personal-overview", personalOverviewApiRouter);
api.use("/statuses", statusesApiRouter);
api.use("/todo-list", todoListApiRouter);
api.use("/notifications", notificationsApiRouter);
api.use("/attachments", attachmentsApiRouter);
api.use("/sub-tasks", subTasksApiRouter);
api.use("/project-members", projectMembersApiRouter);
api.use("/task-time-log", taskWorkLogApiRouter);
api.use("/task-comments", taskCommentsApiRouter);
api.use("/timezones", timezonesApiRouter);
api.use("/project-statuses", projectStatusesApiRouter);
api.use("/labels", labelsApiRouter);
api.use("/resource-allocation", resourceAllocationApiRouter);
api.use("/shared/projects", sharedProjectsApiRouter);
api.use("/task-templates", taskTemplatesApiRouter);
api.use("/project-insights", projectInsightsApiRouter);
api.use("/admin-center", adminCenterApiRouter);
api.use("/reporting", reportingApiRouter);
api.use("/activity-logs", activityLogsApiRouter);
api.use("/projects-folders", projectFoldersApiRouter);
api.use("/task-phases", taskPhasesApiRouter);
api.use("/project-categories", projectCategoriesApiRouter);
api.use("/home", homePageApiRouter);
api.use("/gantt", ganttApiRouter);
api.use("/project-comments", projectCommentsApiRouter);
api.use("/reporting-export", reportingExportApiRouter);
api.use("/project-healths", projectHealthsApiRouter);
api.use("/project-templates", projectTemplatesApiRouter);
api.use("/pt-tasks", ptTasksApiRouter);
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
api.use("/pt-statuses", ptStatusesApiRouter);
api.use("/workload-gannt", workloadApiRouter);
api.use("/roadmap-gannt", roadmapApiRouter);
api.use("/schedule-gannt", scheduleApiRouter);
api.use("/schedule-gannt-v2", scheduleApiV2Router);
api.use("/project-managers", projectManagerApiRouter);
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
api.use("/billing", billingApiRouter);
api.use("/task-dependencies", taskDependenciesApiRouter);
api.use("/task-recurring", taskRecurringApiRouter);
import express from "express";
import AccessControlsController from "../../controllers/access-controls-controller";
import AuthController from "../../controllers/auth-controller";
import LogsController from "../../controllers/logs-controller";
import OverviewController from "../../controllers/overview-controller";
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
import attachmentsApiRouter from "./attachments-api-router";
import clientsApiRouter from "./clients-api-router";
import jobTitlesApiRouter from "./job-titles-api-router";
import notificationsApiRouter from "./notifications-api-router";
import personalOverviewApiRouter from "./personal-overview-api-router";
import projectMembersApiRouter from "./project-members-api-router";
import projectsApiRouter from "./projects-api-router";
import settingsApiRouter from "./settings-api-router";
import statusesApiRouter from "./statuses-api-router";
import subTasksApiRouter from "./sub-tasks-api-router";
import taskCommentsApiRouter from "./task-comments-api-router";
import taskWorkLogApiRouter from "./task-work-log-api-router";
import tasksApiRouter from "./tasks-api-router";
import teamMembersApiRouter from "./team-members-api-router";
import teamsApiRouter from "./teams-api-router";
import timezonesApiRouter from "./timezones-api-router";
import todoListApiRouter from "./todo-list-api-router";
import projectStatusesApiRouter from "./project-statuses-api-router";
import labelsApiRouter from "./labels-api-router";
import sharedProjectsApiRouter from "./shared-projects-api-router";
import resourceAllocationApiRouter from "./resource-allocation-api-router";
import taskTemplatesApiRouter from "./task-templates-api-router";
import projectInsightsApiRouter from "./project-insights-api-router";
import passwordValidator from "../../middlewares/validators/password-validator";
import adminCenterApiRouter from "./admin-center-api-router";
import reportingApiRouter from "./reporting-api-router";
import activityLogsApiRouter from "./activity-logs-api-router";
import safeControllerFunction from "../../shared/safe-controller-function";
import projectFoldersApiRouter from "./project-folders-api-router";
import taskPhasesApiRouter from "./task-phases-api-router";
import projectCategoriesApiRouter from "./project-categories-api-router";
import homePageApiRouter from "./home-page-api-router";
import ganttApiRouter from "./gantt-api-router";
import projectCommentsApiRouter from "./project-comments-api-router";
import reportingExportApiRouter from "./reporting-export-api-router";
import projectHealthsApiRouter from "./project-healths-api-router";
import ptTasksApiRouter from "./pt-tasks-api-router";
import projectTemplatesApiRouter from "./project-templates-api";
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
import ptStatusesApiRouter from "./pt-statuses-api-router";
import workloadApiRouter from "./gannt-apis/workload-api-router";
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
import projectManagerApiRouter from "./project-managers-api-router";
import billingApiRouter from "./billing-api-router";
import taskDependenciesApiRouter from "./task-dependencies-api-router";
import taskRecurringApiRouter from "./task-recurring-api-router";
import customColumnsApiRouter from "./custom-columns-api-router";
const api = express.Router();
api.use("/projects", projectsApiRouter);
api.use("/team-members", teamMembersApiRouter);
api.use("/job-titles", jobTitlesApiRouter);
api.use("/clients", clientsApiRouter);
api.use("/teams", teamsApiRouter);
api.use("/tasks", tasksApiRouter);
api.use("/settings", settingsApiRouter);
api.use("/personal-overview", personalOverviewApiRouter);
api.use("/statuses", statusesApiRouter);
api.use("/todo-list", todoListApiRouter);
api.use("/notifications", notificationsApiRouter);
api.use("/attachments", attachmentsApiRouter);
api.use("/sub-tasks", subTasksApiRouter);
api.use("/project-members", projectMembersApiRouter);
api.use("/task-time-log", taskWorkLogApiRouter);
api.use("/task-comments", taskCommentsApiRouter);
api.use("/timezones", timezonesApiRouter);
api.use("/project-statuses", projectStatusesApiRouter);
api.use("/labels", labelsApiRouter);
api.use("/resource-allocation", resourceAllocationApiRouter);
api.use("/shared/projects", sharedProjectsApiRouter);
api.use("/task-templates", taskTemplatesApiRouter);
api.use("/project-insights", projectInsightsApiRouter);
api.use("/admin-center", adminCenterApiRouter);
api.use("/reporting", reportingApiRouter);
api.use("/activity-logs", activityLogsApiRouter);
api.use("/projects-folders", projectFoldersApiRouter);
api.use("/task-phases", taskPhasesApiRouter);
api.use("/project-categories", projectCategoriesApiRouter);
api.use("/home", homePageApiRouter);
api.use("/gantt", ganttApiRouter);
api.use("/project-comments", projectCommentsApiRouter);
api.use("/reporting-export", reportingExportApiRouter);
api.use("/project-healths", projectHealthsApiRouter);
api.use("/project-templates", projectTemplatesApiRouter);
api.use("/pt-tasks", ptTasksApiRouter);
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
api.use("/pt-statuses", ptStatusesApiRouter);
api.use("/workload-gannt", workloadApiRouter);
api.use("/roadmap-gannt", roadmapApiRouter);
api.use("/schedule-gannt", scheduleApiRouter);
api.use("/schedule-gannt-v2", scheduleApiV2Router);
api.use("/project-managers", projectManagerApiRouter);
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
api.use("/billing", billingApiRouter);
api.use("/task-dependencies", taskDependenciesApiRouter);
api.use("/task-recurring", taskRecurringApiRouter);
api.use("/custom-columns", customColumnsApiRouter);
api.use("/project-finance", projectFinanceApiRouter);
export default api;

View File

@@ -1,20 +0,0 @@
import express from "express";
import ProjectfinanceController from "../../controllers/project-finance-controller";
import idParamValidator from "../../middlewares/validators/id-param-validator";
import safeControllerFunction from "../../shared/safe-controller-function";
const projectFinanceApiRouter = express.Router();
projectFinanceApiRouter.get("/project/:project_id/tasks", ProjectfinanceController.getTasks);
projectFinanceApiRouter.get("/project/:project_id/tasks/:parent_task_id/subtasks", ProjectfinanceController.getSubTasks);
projectFinanceApiRouter.get(
"/task/:id/breakdown",
idParamValidator,
safeControllerFunction(ProjectfinanceController.getTaskBreakdown)
);
projectFinanceApiRouter.put("/task/:task_id/fixed-cost", ProjectfinanceController.updateTaskFixedCost);
projectFinanceApiRouter.put("/project/:project_id/currency", ProjectfinanceController.updateProjectCurrency);
projectFinanceApiRouter.get("/project/:project_id/export", ProjectfinanceController.exportFinanceData);
export default projectFinanceApiRouter;

View File

@@ -1,69 +0,0 @@
import express from "express";
import ProjectRateCardController from "../../controllers/project-ratecard-controller";
import idParamValidator from "../../middlewares/validators/id-param-validator";
import safeControllerFunction from "../../shared/safe-controller-function";
import projectManagerValidator from "../../middlewares/validators/project-manager-validator";
const projectRatecardApiRouter = express.Router();
// Insert multiple roles for a project
projectRatecardApiRouter.post(
"/",
projectManagerValidator,
safeControllerFunction(ProjectRateCardController.createMany)
);
// Insert a single role for a project
projectRatecardApiRouter.post(
"/create-project-rate-card-role",
projectManagerValidator,
safeControllerFunction(ProjectRateCardController.createOne)
);
// Get all roles for a project
projectRatecardApiRouter.get(
"/project/:project_id",
safeControllerFunction(ProjectRateCardController.getByProjectId)
);
// Get a single role by id
projectRatecardApiRouter.get(
"/:id",
idParamValidator,
safeControllerFunction(ProjectRateCardController.getById)
);
// Update a single role by id
projectRatecardApiRouter.put(
"/:id",
idParamValidator,
safeControllerFunction(ProjectRateCardController.updateById)
);
// Update all roles for a project (delete then insert)
projectRatecardApiRouter.put(
"/project/:project_id",
safeControllerFunction(ProjectRateCardController.updateByProjectId)
);
// Update project member rate card role
projectRatecardApiRouter.put(
"/project/:project_id/members/:id/rate-card-role",
idParamValidator,
projectManagerValidator,
safeControllerFunction(ProjectRateCardController.updateProjectMemberByProjectIdAndMemberId)
);
// Delete a single role by id
projectRatecardApiRouter.delete(
"/:id",
idParamValidator,
safeControllerFunction(ProjectRateCardController.deleteById)
);
// Delete all roles for a project
projectRatecardApiRouter.delete(
"/project/:project_id",
safeControllerFunction(ProjectRateCardController.deleteByProjectId)
);
export default projectRatecardApiRouter;

View File

@@ -1,48 +0,0 @@
import express from "express";
import RateCardController from "../../controllers/ratecard-controller";
import idParamValidator from "../../middlewares/validators/id-param-validator";
import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-or-admin-validator";
import safeControllerFunction from "../../shared/safe-controller-function";
import projectManagerValidator from "../../middlewares/validators/project-manager-validator";
import ratecardBodyValidator from "../../middlewares/validators/ratecard-body-validator";
const ratecardApiRouter = express.Router();
ratecardApiRouter.post(
"/",
projectManagerValidator,
ratecardBodyValidator,
safeControllerFunction(RateCardController.create)
);
ratecardApiRouter.get(
"/",
safeControllerFunction(RateCardController.get)
);
ratecardApiRouter.get(
"/:id",
teamOwnerOrAdminValidator,
idParamValidator,
safeControllerFunction(RateCardController.getById)
);
ratecardApiRouter.put(
"/:id",
teamOwnerOrAdminValidator,
ratecardBodyValidator,
idParamValidator,
safeControllerFunction(RateCardController.update)
);
ratecardApiRouter.delete(
"/:id",
teamOwnerOrAdminValidator,
idParamValidator,
safeControllerFunction(RateCardController.deleteById)
);
export default ratecardApiRouter;

View File

@@ -69,7 +69,4 @@ tasksApiRouter.put("/labels/:id", idParamValidator, safeControllerFunction(Tasks
// Add custom column value update route
tasksApiRouter.put("/:taskId/custom-column", TasksControllerV2.updateCustomColumnValue);
// Add route to reset parent task estimations
tasksApiRouter.post("/reset-parent-estimations", safeControllerFunction(TasksController.resetParentTaskEstimations));
export default tasksApiRouter;

View File

@@ -204,29 +204,3 @@ export async function logPhaseChange(activityLog: IActivityLog) {
insertToActivityLogs(activityLog);
}
}
export async function logProgressChange(activityLog: IActivityLog) {
const { task_id, new_value, old_value } = activityLog;
if (!task_id || !activityLog.socket) return;
if (old_value !== new_value) {
activityLog.user_id = getLoggedInUserIdFromSocket(activityLog.socket);
activityLog.attribute_type = IActivityLogAttributeTypes.PROGRESS;
activityLog.log_type = IActivityLogChangeType.UPDATE;
insertToActivityLogs(activityLog);
}
}
export async function logWeightChange(activityLog: IActivityLog) {
const { task_id, new_value, old_value } = activityLog;
if (!task_id || !activityLog.socket) return;
if (old_value !== new_value) {
activityLog.user_id = getLoggedInUserIdFromSocket(activityLog.socket);
activityLog.attribute_type = IActivityLogAttributeTypes.WEIGHT;
activityLog.log_type = IActivityLogChangeType.UPDATE;
insertToActivityLogs(activityLog);
}
}

View File

@@ -29,8 +29,6 @@ export enum IActivityLogAttributeTypes {
COMMENT = "comment",
ARCHIVE = "archive",
PHASE = "phase",
PROGRESS = "progress",
WEIGHT = "weight",
}
export enum IActivityLogChangeType {

View File

@@ -117,11 +117,11 @@ export const TASK_DUE_NO_DUE_COLOR = "#a9a9a9";
export const DEFAULT_PAGE_SIZE = 20;
// S3 Credentials
export const REGION = process.env.S3_REGION || "us-east-1";
export const BUCKET = process.env.S3_BUCKET || "your-bucket-name";
export const REGION = process.env.AWS_REGION || "us-east-1";
export const BUCKET = process.env.AWS_BUCKET || "your-bucket-name";
export const S3_URL = process.env.S3_URL || "https://your-s3-url";
export const S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID || "";
export const S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY || "";
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "";
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "";
// Azure Blob Storage Credentials
export const STORAGE_PROVIDER = process.env.STORAGE_PROVIDER || "s3";
@@ -166,9 +166,7 @@ export const UNMAPPED = "Unmapped";
export const DATE_RANGES = {
YESTERDAY: "YESTERDAY",
LAST_7_DAYS: "LAST_7_DAYS",
LAST_WEEK: "LAST_WEEK",
LAST_30_DAYS: "LAST_30_DAYS",
LAST_MONTH: "LAST_MONTH",
LAST_QUARTER: "LAST_QUARTER",
ALL_TIME: "ALL_TIME"

View File

@@ -1,49 +0,0 @@
import { Socket } from "socket.io";
import db from "../../config/db";
import { log_error } from "../util";
// Define a type for the callback function
type DoneStatusesCallback = (statuses: Array<{
id: string;
name: string;
sort_order: number;
color_code: string;
}>) => void;
/**
* Socket handler to get task statuses in the "done" category for a project
* Used when prompting users to mark a task as done when progress reaches 100%
*/
export async function on_get_done_statuses(
io: any,
socket: Socket,
projectId: string,
callback: DoneStatusesCallback
) {
try {
if (!projectId) {
return callback([]);
}
// Query to get all statuses in the "done" category for the project
const result = await db.query(`
SELECT ts.id, ts.name, ts.sort_order, stsc.color_code
FROM task_statuses ts
INNER JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE ts.project_id = $1
AND stsc.is_done = TRUE
ORDER BY ts.sort_order ASC
`, [projectId]);
const doneStatuses = result.rows;
console.log(`Found ${doneStatuses.length} "done" statuses for project ${projectId}`);
// Use callback to return the result
callback(doneStatuses);
} catch (error) {
log_error(`Error getting "done" statuses for project ${projectId}: ${error}`);
callback([]);
}
}

View File

@@ -5,8 +5,6 @@ import TasksControllerV2 from "../../controllers/tasks-controller-v2";
export async function on_get_task_progress(_io: Server, socket: Socket, taskId?: string) {
try {
console.log(`GET_TASK_PROGRESS requested for task: ${taskId}`);
const task: any = {};
task.id = taskId;
@@ -15,8 +13,6 @@ export async function on_get_task_progress(_io: Server, socket: Socket, taskId?:
task.complete_ratio = info.ratio;
task.completed_count = info.total_completed;
task.total_tasks_count = info.total_tasks;
console.log(`Sending task progress for task ${taskId}: complete_ratio=${task.complete_ratio}`);
}
return socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task);

View File

@@ -1,89 +0,0 @@
import { Socket } from "socket.io";
import db from "../../config/db";
import { SocketEvents } from "../events";
import { log_error } from "../util";
/**
* Socket handler to retrieve the number of subtasks for a given task
* Used to validate on the client side whether a task should show progress inputs
*/
export async function on_get_task_subtasks_count(io: any, socket: Socket, taskId: string) {
try {
if (!taskId) {
return;
}
// Get the count of subtasks for this task
const result = await db.query(
"SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1 AND archived IS FALSE",
[taskId]
);
const subtaskCount = parseInt(result.rows[0]?.subtask_count || "0");
// Emit the subtask count back to the client
socket.emit(
"TASK_SUBTASKS_COUNT",
{
task_id: taskId,
subtask_count: subtaskCount,
has_subtasks: subtaskCount > 0
}
);
console.log(`Emitted subtask count for task ${taskId}: ${subtaskCount}`);
// If there are subtasks, also get their progress information
if (subtaskCount > 0) {
// Get all subtasks for this parent task with their progress information
const subtasksResult = await db.query(`
SELECT
t.id,
t.progress_value,
t.manual_progress,
t.weight,
CASE
WHEN t.manual_progress = TRUE THEN t.progress_value
ELSE COALESCE(
(SELECT (CASE WHEN tl.total_minutes > 0 THEN
(tl.total_minutes_spent / tl.total_minutes * 100)
ELSE 0 END)
FROM (
SELECT
t2.id,
t2.total_minutes,
COALESCE(SUM(twl.time_spent), 0) as total_minutes_spent
FROM tasks t2
LEFT JOIN task_work_log twl ON t2.id = twl.task_id
WHERE t2.id = t.id
GROUP BY t2.id, t2.total_minutes
) tl
), 0)
END as calculated_progress
FROM tasks t
WHERE t.parent_task_id = $1 AND t.archived IS FALSE
`, [taskId]);
// Emit progress updates for each subtask
for (const subtask of subtasksResult.rows) {
const progressValue = subtask.manual_progress ?
subtask.progress_value :
Math.floor(subtask.calculated_progress);
socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id: subtask.id,
progress_value: progressValue,
weight: subtask.weight
}
);
}
console.log(`Emitted progress updates for ${subtasksResult.rows.length} subtasks of task ${taskId}`);
}
} catch (error) {
log_error(`Error getting subtask count for task ${taskId}: ${error}`);
}
}

View File

@@ -1,11 +1,11 @@
import { Server, Socket } from "socket.io";
import {Server, Socket} from "socket.io";
import db from "../../config/db";
import { getColor, toMinutes } from "../../shared/utils";
import { SocketEvents } from "../events";
import {getColor, toMinutes} from "../../shared/utils";
import {SocketEvents} from "../events";
import { log_error, notifyProjectUpdates } from "../util";
import {log_error, notifyProjectUpdates} from "../util";
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
import { TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants";
import {TASK_STATUS_COLOR_ALPHA, UNMAPPED} from "../../shared/constants";
import moment from "moment";
import momentTime from "moment-timezone";
import { logEndDateChange, logStartDateChange, logStatusChange } from "../../services/activity-logs/activity-logs.service";
@@ -18,9 +18,8 @@ export async function getTaskCompleteInfo(task: any) {
const [d2] = result2.rows;
task.completed_count = d2.res.total_completed || 0;
if (task.sub_tasks_count > 0 && d2.res.total_tasks > 0) {
if (task.sub_tasks_count > 0)
task.sub_tasks_count = d2.res.total_tasks;
}
return task;
}
@@ -98,8 +97,8 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string)
logEndDateChange({
task_id: d.task.id,
socket,
new_value: body.time_zone && d.task.end_date ? momentTime.tz(d.task.end_date, `${body.time_zone}`) : d.task.end_date,
old_value: null
new_value: body.time_zone && d.task.end_date ? momentTime.tz(d.task.end_date, `${body.time_zone}`) : d.task.end_date,
old_value: null
});
}

View File

@@ -4,11 +4,10 @@ import db from "../../config/db";
import {NotificationsService} from "../../services/notifications/notifications.service";
import {TASK_STATUS_COLOR_ALPHA} from "../../shared/constants";
import {SocketEvents} from "../events";
import {getLoggedInUserIdFromSocket, log, log_error, notifyProjectUpdates} from "../util";
import {getLoggedInUserIdFromSocket, log_error, notifyProjectUpdates} from "../util";
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
import {getTaskDetails, logProgressChange, logStatusChange} from "../../services/activity-logs/activity-logs.service";
import {getTaskDetails, logStatusChange} from "../../services/activity-logs/activity-logs.service";
import { assignMemberIfNot } from "./on-quick-assign-or-remove";
import logger from "../../utils/logger";
export async function on_task_status_change(_io: Server, socket: Socket, data?: string) {
try {
@@ -50,46 +49,6 @@ export async function on_task_status_change(_io: Server, socket: Socket, data?:
});
}
// Check if the new status is in a "done" category
if (changeResponse.status_category?.is_done) {
// Get current progress value
const progressResult = await db.query(`
SELECT progress_value, manual_progress
FROM tasks
WHERE id = $1
`, [body.task_id]);
const currentProgress = progressResult.rows[0]?.progress_value;
const isManualProgress = progressResult.rows[0]?.manual_progress;
// Only update if not already 100%
if (currentProgress !== 100) {
// Update progress to 100%
await db.query(`
UPDATE tasks
SET progress_value = 100, manual_progress = TRUE
WHERE id = $1
`, [body.task_id]);
log(`Task ${body.task_id} moved to done status - progress automatically set to 100%`, null);
// Log the progress change to activity logs
await logProgressChange({
task_id: body.task_id,
old_value: currentProgress !== null ? currentProgress.toString() : "0",
new_value: "100",
socket
});
// If this is a subtask, update parent task progress
if (body.parent_task) {
setTimeout(() => {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), body.parent_task);
}, 100);
}
}
}
const info = await TasksControllerV2.getTaskCompleteRatio(body.parent_task || body.task_id);
socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), {

View File

@@ -6,76 +6,10 @@ import { SocketEvents } from "../events";
import { log_error, notifyProjectUpdates } from "../util";
import { getTaskDetails, logTotalMinutes } from "../../services/activity-logs/activity-logs.service";
/**
* Recursively updates all ancestor tasks' progress when a subtask changes
* @param io Socket.io instance
* @param socket Socket instance for emitting events
* @param projectId Project ID for room broadcasting
* @param taskId The task ID to update (starts with the parent task)
*/
async function updateTaskAncestors(io: any, socket: Socket, projectId: string, taskId: string | null) {
if (!taskId) return;
try {
// Get the current task's progress ratio
const progressRatio = await db.query(
"SELECT get_task_complete_ratio($1) as ratio",
[taskId]
);
const ratio = progressRatio?.rows[0]?.ratio?.ratio || 0;
console.log(`Updated task ${taskId} progress after time estimation change: ${ratio}`);
// Check if this task needs a "done" status prompt
let shouldPromptForDone = false;
if (ratio >= 100) {
// Get the task's current status
const taskStatusResult = await db.query(`
SELECT ts.id, stsc.is_done
FROM tasks t
JOIN task_statuses ts ON t.status_id = ts.id
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE t.id = $1
`, [taskId]);
// If the task isn't already in a "done" category, we should prompt the user
if (taskStatusResult.rows.length > 0 && !taskStatusResult.rows[0].is_done) {
shouldPromptForDone = true;
}
}
// Emit the updated progress
socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id: taskId,
progress_value: ratio,
should_prompt_for_done: shouldPromptForDone
}
);
// Find this task's parent to continue the recursive update
const parentResult = await db.query(
"SELECT parent_task_id FROM tasks WHERE id = $1",
[taskId]
);
const parentTaskId = parentResult.rows[0]?.parent_task_id;
// If there's a parent, recursively update it
if (parentTaskId) {
await updateTaskAncestors(io, socket, projectId, parentTaskId);
}
} catch (error) {
log_error(`Error updating ancestor task ${taskId}: ${error}`);
}
}
export async function on_time_estimation_change(io: Server, socket: Socket, data?: string) {
export async function on_time_estimation_change(_io: Server, socket: Socket, data?: string) {
try {
// (SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent,
const q = `UPDATE tasks SET total_minutes = $2 WHERE id = $1 RETURNING total_minutes, project_id, parent_task_id;`;
const q = `UPDATE tasks SET total_minutes = $2 WHERE id = $1 RETURNING total_minutes;`;
const body = JSON.parse(data as string);
const hours = body.total_hours || 0;
@@ -85,10 +19,7 @@ export async function on_time_estimation_change(io: Server, socket: Socket, data
const task_data = await getTaskDetails(body.task_id, "total_minutes");
const result0 = await db.query(q, [body.task_id, totalMinutes]);
const [taskData] = result0.rows;
const projectId = taskData.project_id;
const parentTaskId = taskData.parent_task_id;
const [data0] = result0.rows;
const result = await db.query("SELECT SUM(time_spent) AS total_minutes_spent FROM task_work_log WHERE task_id = $1;", [body.task_id]);
const [dd] = result.rows;
@@ -100,22 +31,6 @@ export async function on_time_estimation_change(io: Server, socket: Socket, data
total_minutes_spent: dd.total_minutes_spent || 0
};
socket.emit(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), TasksController.updateTaskViewModel(d));
// If this is a subtask in time-based mode, update parent task progress
if (parentTaskId) {
const projectSettingsResult = await db.query(
"SELECT use_time_progress FROM projects WHERE id = $1",
[projectId]
);
const useTimeProgress = projectSettingsResult.rows[0]?.use_time_progress;
if (useTimeProgress) {
// Recalculate parent task progress when subtask time estimation changes
await updateTaskAncestors(io, socket, projectId, parentTaskId);
}
}
notifyProjectUpdates(socket, d.id);
logTotalMinutes({

View File

@@ -1,177 +0,0 @@
import { Socket } from "socket.io";
import db from "../../config/db";
import { SocketEvents } from "../events";
import { log, log_error, notifyProjectUpdates } from "../util";
import { logProgressChange } from "../../services/activity-logs/activity-logs.service";
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
interface UpdateTaskProgressData {
task_id: string;
progress_value: number;
parent_task_id: string | null;
}
/**
* Recursively updates all ancestor tasks' progress when a subtask changes
* @param io Socket.io instance
* @param socket Socket instance for emitting events
* @param projectId Project ID for room broadcasting
* @param taskId The task ID to update (starts with the parent task)
*/
async function updateTaskAncestors(io: any, socket: Socket, projectId: string, taskId: string | null) {
if (!taskId) return;
try {
// Use the new controller method to update the task progress
await TasksControllerV2.updateTaskProgress(taskId);
// Get the current task's progress ratio
const progressRatio = await db.query(
"SELECT get_task_complete_ratio($1) as ratio",
[taskId]
);
const ratio = progressRatio?.rows[0]?.ratio?.ratio || 0;
console.log(`Updated task ${taskId} progress: ${ratio}`);
// Check if this task needs a "done" status prompt
let shouldPromptForDone = false;
if (ratio >= 100) {
// Get the task's current status
const taskStatusResult = await db.query(`
SELECT ts.id, stsc.is_done
FROM tasks t
JOIN task_statuses ts ON t.status_id = ts.id
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE t.id = $1
`, [taskId]);
// If the task isn't already in a "done" category, we should prompt the user
if (taskStatusResult.rows.length > 0 && !taskStatusResult.rows[0].is_done) {
shouldPromptForDone = true;
}
}
// Emit the updated progress
socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id: taskId,
progress_value: ratio,
should_prompt_for_done: shouldPromptForDone
}
);
// Find this task's parent to continue the recursive update
const parentResult = await db.query(
"SELECT parent_task_id FROM tasks WHERE id = $1",
[taskId]
);
const parentTaskId = parentResult.rows[0]?.parent_task_id;
// If there's a parent, recursively update it
if (parentTaskId) {
await updateTaskAncestors(io, socket, projectId, parentTaskId);
}
} catch (error) {
log_error(`Error updating ancestor task ${taskId}: ${error}`);
}
}
export async function on_update_task_progress(io: any, socket: Socket, data: string) {
try {
const parsedData = JSON.parse(data) as UpdateTaskProgressData;
const { task_id, progress_value, parent_task_id } = parsedData;
if (!task_id || progress_value === undefined) {
return;
}
// Check if this is a parent task (has subtasks)
const subTasksResult = await db.query(
"SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1",
[task_id]
);
const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || "0");
// If this is a parent task, we shouldn't set manual progress
if (subtaskCount > 0) {
log_error(`Cannot set manual progress on parent task ${task_id} with ${subtaskCount} subtasks`);
return;
}
// Get the current progress value to log the change
const currentProgressResult = await db.query(
"SELECT progress_value, project_id, status_id FROM tasks WHERE id = $1",
[task_id]
);
const currentProgress = currentProgressResult.rows[0]?.progress_value;
const projectId = currentProgressResult.rows[0]?.project_id;
const statusId = currentProgressResult.rows[0]?.status_id;
// Update the task progress in the database
await db.query(
`UPDATE tasks
SET progress_value = $1, manual_progress = true, updated_at = NOW()
WHERE id = $2`,
[progress_value, task_id]
);
// Log the progress change using the activity logs service
await logProgressChange({
task_id,
old_value: currentProgress !== null ? currentProgress.toString() : "0",
new_value: progress_value.toString(),
socket
});
if (projectId) {
// Check if progress is 100% and the task isn't already in a "done" status category
let shouldPromptForDone = false;
if (progress_value >= 100) {
// Check if the task's current status is in a "done" category
const statusCategoryResult = await db.query(`
SELECT stsc.is_done
FROM task_statuses ts
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE ts.id = $1
`, [statusId]);
// If the task isn't already in a "done" category, we should prompt the user
if (statusCategoryResult.rows.length > 0 && !statusCategoryResult.rows[0].is_done) {
shouldPromptForDone = true;
}
}
// Emit the update to all clients in the project room
socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id,
progress_value,
should_prompt_for_done: shouldPromptForDone
}
);
log(`Emitted progress update for task ${task_id} to project room ${projectId}`, null);
// If this task has a parent, use our controller to update all ancestors
if (parent_task_id) {
// Use the controller method to update the parent task's progress
await TasksControllerV2.updateTaskProgress(parent_task_id);
// Also use the existing method for socket notifications
await updateTaskAncestors(io, socket, projectId, parent_task_id);
}
// Notify that project updates are available
notifyProjectUpdates(socket, task_id);
}
} catch (error) {
log_error(error);
}
}

View File

@@ -1,107 +0,0 @@
import { Socket } from "socket.io";
import db from "../../config/db";
import { SocketEvents } from "../events";
import { log, log_error, notifyProjectUpdates } from "../util";
import { logWeightChange } from "../../services/activity-logs/activity-logs.service";
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
interface UpdateTaskWeightData {
task_id: string;
weight: number;
parent_task_id: string | null;
}
export async function on_update_task_weight(io: any, socket: Socket, data: string) {
try {
const parsedData = JSON.parse(data) as UpdateTaskWeightData;
const { task_id, weight, parent_task_id } = parsedData;
if (!task_id || weight === undefined) {
return;
}
// Get the current weight value to log the change
const currentWeightResult = await db.query(
"SELECT weight, project_id FROM tasks WHERE id = $1",
[task_id]
);
const currentWeight = currentWeightResult.rows[0]?.weight;
const projectId = currentWeightResult.rows[0]?.project_id;
// Update the task weight using our controller method
await TasksControllerV2.updateTaskWeight(task_id, weight);
// Log the weight change using the activity logs service
await logWeightChange({
task_id,
old_value: currentWeight !== null ? currentWeight.toString() : "100",
new_value: weight.toString(),
socket
});
if (projectId) {
// Emit the update to all clients in the project room
socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id,
weight
}
);
// If this is a subtask, update the parent task's progress
if (parent_task_id) {
// Use the controller to update the parent task progress
await TasksControllerV2.updateTaskProgress(parent_task_id);
// Get the updated progress to emit to clients
const progressRatio = await db.query(
"SELECT get_task_complete_ratio($1) as ratio",
[parent_task_id]
);
// Emit the parent task's updated progress
socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id: parent_task_id,
progress_value: progressRatio?.rows[0]?.ratio?.ratio || 0
}
);
// We also need to update any grandparent tasks
const grandparentResult = await db.query(
"SELECT parent_task_id FROM tasks WHERE id = $1",
[parent_task_id]
);
const grandparentId = grandparentResult.rows[0]?.parent_task_id;
if (grandparentId) {
await TasksControllerV2.updateTaskProgress(grandparentId);
// Emit the grandparent's updated progress
const grandparentProgressRatio = await db.query(
"SELECT get_task_complete_ratio($1) as ratio",
[grandparentId]
);
socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id: grandparentId,
progress_value: grandparentProgressRatio?.rows[0]?.ratio?.ratio || 0
}
);
}
}
// Notify that project updates are available
notifyProjectUpdates(socket, task_id);
}
} catch (error) {
log_error(error);
}
}

View File

@@ -57,17 +57,4 @@ export enum SocketEvents {
TASK_ASSIGNEES_CHANGE,
TASK_CUSTOM_COLUMN_UPDATE,
CUSTOM_COLUMN_PINNED_CHANGE,
TEAM_MEMBER_ROLE_CHANGE,
// Task progress events
UPDATE_TASK_PROGRESS,
UPDATE_TASK_WEIGHT,
TASK_PROGRESS_UPDATED,
// Task subtasks count events
GET_TASK_SUBTASKS_COUNT,
TASK_SUBTASKS_COUNT,
// Task completion events
GET_DONE_STATUSES,
}

View File

@@ -52,10 +52,6 @@ import { on_task_recurring_change } from "./commands/on-task-recurring-change";
import { on_task_assignees_change } from "./commands/on-task-assignees-change";
import { on_task_custom_column_update } from "./commands/on_custom_column_update";
import { on_custom_column_pinned_change } from "./commands/on_custom_column_pinned_change";
import { on_update_task_progress } from "./commands/on-update-task-progress";
import { on_update_task_weight } from "./commands/on-update-task-weight";
import { on_get_task_subtasks_count } from "./commands/on-get-task-subtasks-count";
import { on_get_done_statuses } from "./commands/on-get-done-statuses";
export function register(io: any, socket: Socket) {
log(socket.id, "client registered");
@@ -73,6 +69,7 @@ export function register(io: any, socket: Socket) {
socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), data => on_time_estimation_change(io, socket, data));
socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), data => on_task_description_change(io, socket, data));
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), data => on_get_task_progress(io, socket, data));
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), data => on_get_task_progress(io, socket, data));
socket.on(SocketEvents.TASK_TIMER_START.toString(), data => on_task_timer_start(io, socket, data));
socket.on(SocketEvents.TASK_TIMER_STOP.toString(), data => on_task_timer_stop(io, socket, data));
socket.on(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), data => on_task_sort_order_change(io, socket, data));
@@ -109,10 +106,6 @@ export function register(io: any, socket: Socket) {
socket.on(SocketEvents.TASK_ASSIGNEES_CHANGE.toString(), data => on_task_assignees_change(io, socket, data));
socket.on(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), data => on_task_custom_column_update(io, socket, data));
socket.on(SocketEvents.CUSTOM_COLUMN_PINNED_CHANGE.toString(), data => on_custom_column_pinned_change(io, socket, data));
socket.on(SocketEvents.UPDATE_TASK_PROGRESS.toString(), data => on_update_task_progress(io, socket, data));
socket.on(SocketEvents.UPDATE_TASK_WEIGHT.toString(), data => on_update_task_weight(io, socket, data));
socket.on(SocketEvents.GET_TASK_SUBTASKS_COUNT.toString(), (taskId) => on_get_task_subtasks_count(io, socket, taskId));
socket.on(SocketEvents.GET_DONE_STATUSES.toString(), (projectId, callback) => on_get_done_statuses(io, socket, projectId, callback));
// socket.io built-in event
socket.on("disconnect", (reason) => on_disconnect(io, socket, reason));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
node_modules
npm-debug.log
.git
.gitignore
.dockerignore
README.md

View File

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

View File

@@ -8,6 +8,7 @@ Worklenz is a project management application built with React, TypeScript, and A
- [Project Structure](#project-structure)
- [Contributing](#contributing)
- [Learn More](#learn-more)
- [License](#license)
## Getting Started
@@ -93,3 +94,7 @@ To learn more about the technologies used in this project:
- [TypeScript Documentation](https://www.typescriptlang.org/docs/)
- [Ant Design Documentation](https://ant.design/docs/react/introduce)
- [Vite Documentation](https://vitejs.dev/guide/)
## License
Worklenz is open source and released under the [GNU Affero General Public License Version 3 (AGPLv3)](LICENSE).

View File

@@ -1,66 +1,99 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="./favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2b2b2b" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
<title>Worklenz</title>
<!-- Environment configuration -->
<script src="/env-config.js"></script>
<!-- Google Analytics -->
<script>
// Function to initialize Google Analytics
function initGoogleAnalytics() {
// Load the Google Analytics script
const script = document.createElement('script');
script.async = true;
// Determine which tracking ID to use based on the environment
const isProduction = window.location.hostname === 'worklenz.com' ||
window.location.hostname === 'app.worklenz.com';
const trackingId = isProduction
? 'G-XXXXXXXXXX'
: 'G-3LM2HGWEXG'; // Open source tracking ID
script.src = `https://www.googletagmanager.com/gtag/js?id=${trackingId}`;
document.head.appendChild(script);
<head>
<meta charset="utf-8" />
<link rel="icon" href="./favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2b2b2b" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet" />
<!-- SVAR Gantt Icons -->
<link rel="stylesheet" href="https://cdn.svar.dev/fonts/wxi/wx-icons.css" />
<title>Worklenz</title>
<!-- Environment configuration -->
<script src="/env-config.js"></script>
<!-- Unregister service worker -->
<script src="/unregister-sw.js"></script>
<!-- Microsoft Clarity -->
<script type="text/javascript">
if (window.location.hostname === 'app.worklenz.com') {
(function (c, l, a, r, i, t, y) {
c[a] = c[a] || function () { (c[a].q = c[a].q || []).push(arguments) };
t = l.createElement(r); t.async = 1; t.src = "https://www.clarity.ms/tag/dx77073klh";
y = l.getElementsByTagName(r)[0]; y.parentNode.insertBefore(t, y);
})(window, document, "clarity", "script", "dx77073klh");
}
</script>
<!-- Google Analytics (only on production) -->
<script type="text/javascript">
if (window.location.hostname === 'app.worklenz.com') {
var gaScript = document.createElement('script');
gaScript.async = true;
gaScript.src = 'https://www.googletagmanager.com/gtag/js?id=G-7KSRKQ1397';
document.head.appendChild(gaScript);
gaScript.onload = function() {
// Initialize Google Analytics
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-7KSRKQ1397');
};
}
</script>
</head>
gtag('config', trackingId);
}
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
<script type="text/javascript">
if (window.location.hostname === 'app.worklenz.com') {
var hs = document.createElement('script');
hs.type = 'text/javascript';
hs.id = 'hs-script-loader';
hs.async = true;
hs.defer = true;
hs.src = '//js.hs-scripts.com/22348300.js';
document.body.appendChild(hs);
}
</script>
</body>
// Initialize analytics
initGoogleAnalytics();
</html>
// Function to show privacy notice
function showPrivacyNotice() {
const notice = document.createElement('div');
notice.style.cssText = `
position: fixed;
bottom: 16px;
right: 16px;
background: #222;
color: #f5f5f5;
padding: 12px 16px 10px 16px;
border-radius: 7px;
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
z-index: 1000;
max-width: 320px;
font-family: Inter, sans-serif;
border: 1px solid #333;
font-size: 0.95rem;
`;
notice.innerHTML = `
<div style="margin-bottom: 6px; font-weight: 600; color: #fff; font-size: 1rem;">Analytics Notice</div>
<div style="margin-bottom: 8px; color: #f5f5f5;">This app uses Google Analytics for anonymous usage stats. No personal data is tracked.</div>
<button id="analytics-notice-btn" style="padding: 5px 14px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.95rem;">Got it</button>
`;
document.body.appendChild(notice);
// Add event listener to button
const btn = notice.querySelector('#analytics-notice-btn');
btn.addEventListener('click', function(e) {
e.preventDefault();
localStorage.setItem('privacyNoticeShown', 'true');
notice.remove();
});
}
// Wait for DOM to be ready
document.addEventListener('DOMContentLoaded', function() {
// Check if we should show the notice
const isProduction = window.location.hostname === 'worklenz.com' ||
window.location.hostname === 'app.worklenz.com';
const noticeShown = localStorage.getItem('privacyNoticeShown') === 'true';
// Show notice if not in production and not shown before
if (!isProduction && !noticeShown) {
showPrivacyNotice();
}
});
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
</body>
</html>

View File

@@ -1,4 +1,4 @@
export default {
module.exports = {
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@
"name": "worklenz",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "vite",
"prebuild": "node scripts/copy-tinymce.js",
@@ -17,8 +16,8 @@
"@ant-design/icons": "^5.4.0",
"@ant-design/pro-components": "^2.7.19",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@paddle/paddle-js": "^1.3.3",
@@ -26,12 +25,12 @@
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.2",
"@tinymce/tinymce-react": "^5.1.1",
"antd": "^5.24.9",
"axios": "^1.9.0",
"antd": "^5.24.1",
"axios": "^1.7.9",
"chart.js": "^4.4.7",
"chartjs-plugin-datalabels": "^2.2.0",
"date-fns": "^4.1.0",
"dompurify": "^3.2.5",
"dompurify": "^3.2.4",
"gantt-task-react": "^0.3.9",
"html2canvas": "^1.4.1",
"i18next": "^23.16.8",
@@ -53,8 +52,7 @@
"react-window": "^1.8.11",
"socket.io-client": "^4.8.1",
"tinymce": "^7.7.2",
"web-vitals": "^4.2.4",
"wx-react-gantt": "^1.3.1"
"web-vitals": "^4.2.4"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
@@ -75,16 +73,10 @@
"tailwindcss": "^3.4.17",
"terser": "^5.39.0",
"typescript": "^5.7.3",
"vite": "^6.3.5",
"vite": "^6.2.5",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.5"
},
"resolutions": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/modifiers": "^6.0.1"
},
"eslintConfig": {
"extends": [
"react-app",

View File

@@ -0,0 +1,49 @@
import MembersReportsTimeLogsTab from './members-reports-time-logs-tab';
type MembersReportsDrawerProps = {
memberId: string | null;
exportTimeLogs: () => void;
};
const MembersReportsDrawer = ({ memberId, exportTimeLogs }: MembersReportsDrawerProps) => {
return (
<Drawer
open={isDrawerOpen}
onClose={handleClose}
width={900}
destroyOnClose
title={
selectedMember && (
<Flex align="center" justify="space-between">
<Flex gap={8} align="center" style={{ fontWeight: 500 }}>
<Typography.Text>{selectedMember.name}</Typography.Text>
</Flex>
<Space>
<TimeWiseFilter />
<Dropdown
menu={{
items: [
{ key: '1', label: t('timeLogsButton'), onClick: exportTimeLogs },
{ key: '2', label: t('activityLogsButton') },
{ key: '3', label: t('tasksButton') },
],
}}
>
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
{t('exportButton')}
</Button>
</Dropdown>
</Space>
</Flex>
)
}
>
{selectedMember && <MembersReportsDrawerTabs memberId={selectedMember.id} />}
{selectedMember && <MembersOverviewTasksStatsDrawer memberId={selectedMember.id} />}
{selectedMember && <MembersOverviewProjectsStatsDrawer memberId={selectedMember.id} />}
</Drawer>
);
};
export default MembersReportsDrawer;

View File

@@ -0,0 +1,41 @@
import React, { useState } from 'react';
import { Flex, Skeleton } from 'antd';
import { useTranslation } from 'react-i18next';
import { useTimeLogs } from '../contexts/TimeLogsContext';
import { BillableFilter } from './BillableFilter';
import { TimeLogCard } from './TimeLogCard';
import { EmptyListPlaceholder } from './EmptyListPlaceholder';
import { TaskDrawer } from './TaskDrawer';
import MembersReportsDrawer from './members-reports-drawer';
const MembersReportsTimeLogsTab: React.FC = () => {
const { t } = useTranslation();
const { timeLogsData, billable, setBillable, exportTimeLogs, exporting } = useTimeLogs();
return (
<Flex vertical gap={24}>
<BillableFilter billable={billable} onBillableChange={setBillable} />
<button onClick={exportTimeLogs} disabled={exporting}>
{exporting ? t('exporting') : t('exportTimeLogs')}
</button>
<Skeleton active loading={exporting} paragraph={{ rows: 10 }}>
{timeLogsData.length > 0 ? (
<Flex vertical gap={24}>
{timeLogsData.map((logs, index) => (
<TimeLogCard key={index} data={logs} />
))}
</Flex>
) : (
<EmptyListPlaceholder text={t('timeLogsEmptyPlaceholder')} />
)}
</Skeleton>
{createPortal(<TaskDrawer />, document.body)}
<MembersReportsDrawer memberId={/* pass the memberId here */} exportTimeLogs={exportTimeLogs} />
</Flex>
);
};
export default MembersReportsTimeLogsTab;

View File

@@ -1,4 +1,4 @@
export default {
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},

View File

@@ -1,163 +0,0 @@
[
{
"id": "c2669c5f-a019-445b-b703-b941bbefdab7",
"type": "low",
"name": "Low",
"color_code": "#c2e4d0",
"color_code_dark": "#46d980",
"tasks": [
{
"id": "4be5ef5c-1234-4247-b159-6d8df2b37d04",
"task": "Testing and QA",
"isBillable": false,
"hours": 180,
"cost": 18000,
"fixedCost": 2500,
"totalBudget": 20000,
"totalActual": 21000,
"variance": -1000,
"members": [
{
"memberId": "6",
"name": "Eve Adams",
"jobId": "J006",
"jobRole": "QA Engineer",
"hourlyRate": 100
}
]
},
{
"id": "6be5ef5c-1234-4247-b159-6d8df2b37d06",
"task": "Project Documentation",
"isBillable": false,
"hours": 100,
"cost": 10000,
"fixedCost": 1000,
"totalBudget": 12000,
"totalActual": 12500,
"variance": -500,
"members": [
{
"memberId": "8",
"name": "Grace Lee",
"jobId": "J008",
"jobRole": "Technical Writer",
"hourlyRate": 100
}
]
}
]
},
{
"id": "d3f9c5f1-b019-445b-b703-b941bbefdab8",
"type": "medium",
"name": "Medium",
"color_code": "#f9e3b1",
"color_code_dark": "#ffc227",
"tasks": [
{
"id": "1be5ef5c-1234-4247-b159-6d8df2b37d01",
"task": "UI Design",
"isBillable": true,
"hours": 120,
"cost": 12000,
"fixedCost": 1500,
"totalBudget": 14000,
"totalActual": 13500,
"variance": 500,
"members": [
{
"memberId": "1",
"name": "John Doe",
"jobId": "J001",
"jobRole": "UI/UX Designer",
"hourlyRate": 100
},
{
"memberId": "2",
"name": "Jane Smith",
"jobId": "J002",
"jobRole": "Frontend Developer",
"hourlyRate": 120
}
]
},
{
"id": "2be5ef5c-1234-4247-b159-6d8df2b37d02",
"task": "API Integration",
"isBillable": true,
"hours": 200,
"cost": 20000,
"fixedCost": 3000,
"totalBudget": 25000,
"totalActual": 26000,
"variance": -1000,
"members": [
{
"memberId": "3",
"name": "Alice Johnson",
"jobId": "J003",
"jobRole": "Backend Developer",
"hourlyRate": 100
}
]
}
]
},
{
"id": "e3f9c5f1-b019-445b-b703-b941bbefdab9",
"type": "high",
"name": "High",
"color_code": "#f6bfc0",
"color_code_dark": "#ff4141",
"tasks": [
{
"id": "5be5ef5c-1234-4247-b159-6d8df2b37d05",
"task": "Database Migration",
"isBillable": true,
"hours": 250,
"cost": 37500,
"fixedCost": 4000,
"totalBudget": 42000,
"totalActual": 41000,
"variance": 1000,
"members": [
{
"memberId": "7",
"name": "Frank Harris",
"jobId": "J007",
"jobRole": "Database Administrator",
"hourlyRate": 150
}
]
},
{
"id": "3be5ef5c-1234-4247-b159-6d8df2b37d03",
"task": "Performance Optimization",
"isBillable": true,
"hours": 300,
"cost": 45000,
"fixedCost": 5000,
"totalBudget": 50000,
"totalActual": 47000,
"variance": 3000,
"members": [
{
"memberId": "4",
"name": "Bob Brown",
"jobId": "J004",
"jobRole": "Performance Engineer",
"hourlyRate": 150
},
{
"memberId": "5",
"name": "Charlie Davis",
"jobId": "J005",
"jobRole": "Full Stack Developer",
"hourlyRate": 130
}
]
}
]
}
]

View File

@@ -1,163 +0,0 @@
[
{
"id": "c2669c5f-a019-445b-b703-b941bbefdab7",
"type": "todo",
"name": "To Do",
"color_code": "#d8d7d8",
"color_code_dark": "#989898",
"tasks": [
{
"id": "1be5ef5c-1234-4247-b159-6d8df2b37d01",
"task": "UI Design",
"isBillable": true,
"hours": 120,
"cost": 12000,
"fixedCost": 1500,
"totalBudget": 14000,
"totalActual": 13500,
"variance": 500,
"members": [
{
"memberId": "1",
"name": "John Doe",
"jobId": "J001",
"jobRole": "UI/UX Designer",
"hourlyRate": 100
},
{
"memberId": "2",
"name": "Jane Smith",
"jobId": "J002",
"jobRole": "Frontend Developer",
"hourlyRate": 120
}
]
},
{
"id": "2be5ef5c-1234-4247-b159-6d8df2b37d02",
"task": "API Integration",
"isBillable": true,
"hours": 200,
"cost": 20000,
"fixedCost": 3000,
"totalBudget": 25000,
"totalActual": 26000,
"variance": -1000,
"members": [
{
"memberId": "3",
"name": "Alice Johnson",
"jobId": "J003",
"jobRole": "Backend Developer",
"hourlyRate": 100
}
]
}
]
},
{
"id": "d3f9c5f1-b019-445b-b703-b941bbefdab8",
"type": "doing",
"name": "In Progress",
"color_code": "#c0d5f6",
"color_code_dark": "#4190ff",
"tasks": [
{
"id": "3be5ef5c-1234-4247-b159-6d8df2b37d03",
"task": "Performance Optimization",
"isBillable": true,
"hours": 300,
"cost": 45000,
"fixedCost": 5000,
"totalBudget": 50000,
"totalActual": 47000,
"variance": 3000,
"members": [
{
"memberId": "4",
"name": "Bob Brown",
"jobId": "J004",
"jobRole": "Performance Engineer",
"hourlyRate": 150
},
{
"memberId": "5",
"name": "Charlie Davis",
"jobId": "J005",
"jobRole": "Full Stack Developer",
"hourlyRate": 130
}
]
},
{
"id": "4be5ef5c-1234-4247-b159-6d8df2b37d04",
"task": "Testing and QA",
"isBillable": false,
"hours": 180,
"cost": 18000,
"fixedCost": 2500,
"totalBudget": 20000,
"totalActual": 21000,
"variance": -1000,
"members": [
{
"memberId": "6",
"name": "Eve Adams",
"jobId": "J006",
"jobRole": "QA Engineer",
"hourlyRate": 100
}
]
}
]
},
{
"id": "e3f9c5f1-b019-445b-b703-b941bbefdab9",
"type": "done",
"name": "Done",
"color_code": "#c2e4d0",
"color_code_dark": "#46d980",
"tasks": [
{
"id": "5be5ef5c-1234-4247-b159-6d8df2b37d05",
"task": "Database Migration",
"isBillable": true,
"hours": 250,
"cost": 37500,
"fixedCost": 4000,
"totalBudget": 42000,
"totalActual": 41000,
"variance": 1000,
"members": [
{
"memberId": "7",
"name": "Frank Harris",
"jobId": "J007",
"jobRole": "Database Administrator",
"hourlyRate": 150
}
]
},
{
"id": "6be5ef5c-1234-4247-b159-6d8df2b37d06",
"task": "Project Documentation",
"isBillable": false,
"hours": 100,
"cost": 10000,
"fixedCost": 1000,
"totalBudget": 12000,
"totalActual": 12500,
"variance": -500,
"members": [
{
"memberId": "8",
"name": "Grace Lee",
"jobId": "J008",
"jobRole": "Technical Writer",
"hourlyRate": 100
}
]
}
]
}
]

View File

@@ -1,51 +0,0 @@
[
{
"ratecardId": "RC001",
"ratecardName": "Rate Card 1",
"jobRolesList": [
{
"jobId": "J001",
"jobTitle": "Project Manager",
"ratePerHour": 100
},
{
"jobId": "J002",
"jobTitle": "Senior Software Engineer",
"ratePerHour": 120
},
{
"jobId": "J003",
"jobTitle": "Junior Software Engineer",
"ratePerHour": 80
},
{
"jobId": "J004",
"jobTitle": "UI/UX Designer",
"ratePerHour": 50
}
],
"createdDate": "2024-12-01T00:00:00.000Z"
},
{
"ratecardId": "RC002",
"ratecardName": "Rate Card 2",
"jobRolesList": [
{
"jobId": "J001",
"jobTitle": "Project Manager",
"ratePerHour": 80
},
{
"jobId": "J002",
"jobTitle": "Senior Software Engineer",
"ratePerHour": 100
},
{
"jobId": "J003",
"jobTitle": "Junior Software Engineer",
"ratePerHour": 60
}
],
"createdDate": "2024-12-15T00:00:00.000Z"
}
]

View File

@@ -0,0 +1,4 @@
{
"doesNotExistText": "Na vjen keq, faqja që kërkoni nuk ekziston.",
"backHomeButton": "Kthehu në Faqen Kryesore"
}

View File

@@ -0,0 +1,31 @@
{
"continue": "Vazhdo",
"setupYourAccount": "Konfiguro Llogarinë Tënde në Worklenz.",
"organizationStepTitle": "Emërtoni Organizatën Tuaj",
"organizationStepLabel": "Zgjidhni një emër për llogarinë tuaj në Worklenz.",
"projectStepTitle": "Krijoni projektin tuaj të parë",
"projectStepLabel": "Në cilin projekt po punoni aktualisht?",
"projectStepPlaceholder": "p.sh. Plani i Marketingut",
"tasksStepTitle": "Krijoni detyrat tuaja të para",
"tasksStepLabel": "Shkruani disa detyra që do të kryeni në",
"tasksStepAddAnother": "Shto një tjetër",
"emailPlaceholder": "Adresa email",
"invalidEmail": "Ju lutemi vendosni një adresë email të vlefshme",
"or": "ose",
"templateButton": "Importo nga shablloni",
"goBack": "Kthehu Mbrapa",
"cancel": "Anulo",
"create": "Krijo",
"templateDrawerTitle": "Zgjidh nga shabllonet",
"step3InputLabel": "Fto me email",
"addAnother": "Shto një tjetër",
"skipForNow": "Kalo tani për tani",
"formTitle": "Krijoni detyrën tuaj të parë.",
"step3Title": "Fto ekipin tënd të punojë me",
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
"maxTasks": " (Mund të krijoni deri në 5 detyra)"
}

View File

@@ -0,0 +1,113 @@
{
"title": "Faturimet",
"currentBill": "Fatura Aktuale",
"configuration": "Konfigurimi",
"currentPlanDetails": "Detajet e Planit Aktual",
"upgradePlan": "Përmirëso Planin",
"cardBodyText01": "Provë falas",
"cardBodyText02": "(Plani juaj i provës skadon në 1 muaj 19 ditë)",
"redeemCode": "Kodi i Zbritjes",
"accountStorage": "Depozita e Llogarisë",
"used": "Përdorur:",
"remaining": "E mbetur:",
"charges": "Tarifat",
"tooltip": "Tarifat për ciklin aktual të faturimit",
"description": "Përshkrimi",
"billingPeriod": "Periudha e Faturimit",
"billStatus": "Statusi i Faturës",
"perUserValue": "Vlera për Përdorues",
"users": "Përdoruesit",
"amount": "Shuma",
"invoices": "Faturat",
"transactionId": "ID e Transaksionit",
"transactionDate": "Data e Transaksionit",
"paymentMethod": "Metoda e Pagesës",
"status": "Statusi",
"ltdUsers": "Mund të shtoni deri në {{ltd_users}} përdorues.",
"totalSeats": "Vende totale",
"availableSeats": "Vende të disponueshme",
"addMoreSeats": "Shto më shumë vende",
"drawerTitle": "Kodi i Zbritjes",
"label": "Kodi i Zbritjes",
"drawerPlaceholder": "Vendosni kodin tuaj të zbritjes",
"redeemSubmit": "Paraqit",
"modalTitle": "Zgjidhni planin më të mirë për ekipin tuaj",
"seatLabel": "Numri i vendeve",
"freePlan": "Plan Falas",
"startup": "Startup",
"business": "Biznes",
"tag": "Më i Popullarizuar",
"enterprise": "Ndërmarrje",
"freeSubtitle": "falas përgjithmonë",
"freeUsers": "Më e mira për përdorim personal",
"freeText01": "100MB depozitë",
"freeText02": "3 projekte",
"freeText03": "5 anëtarë të ekipit",
"startupSubtitle": "ÇMIM I RASTËSISHËM / muaj",
"startupUsers": "Deri në 15 përdorues",
"startupText01": "25GB depozitë",
"startupText02": "Projekte të pakufizuara aktive",
"startupText03": "Orar",
"startupText04": "Raportim",
"startupText05": "Abonohu në projekte",
"businessSubtitle": "përdorues / muaj",
"businessUsers": "16 - 200 përdorues",
"enterpriseUsers": "200 - 500+ përdorues",
"footerTitle": "Ju lutemi na jepni një numër kontakti që mund të përdorim për t'ju kontaktuar.",
"footerLabel": "Numri i Kontaktit",
"footerButton": "Na kontaktoni",
"redeemCodePlaceHolder": "Vendosni kodin tuaj të zbritjes",
"submit": "Paraqit",
"trialPlan": "Provë Falas",
"trialExpireDate": "E vlefshme deri më {{trial_expire_date}}",
"trialExpired": "Provat tuaja falas skaduan {{trial_expire_string}}",
"trialInProgress": "Provat tuaja falas skadojnë {{trial_expire_string}}",
"required": "Kjo fushë është e detyrueshme",
"invalidCode": "Kod i pavlefshëm",
"selectPlan": "Zgjidhni planin më të mirë për ekipin tuaj",
"changeSubscriptionPlan": "Ndryshoni planin tuaj të abonimit",
"noOfSeats": "Numri i vendeve",
"annualPlan": "Pro - Vjetor",
"monthlyPlan": "Pro - Mujor",
"freeForever": "Falas Përgjithmonë",
"bestForPersonalUse": "Më e mira për përdorim personal",
"storage": "Depozitë",
"projects": "Projekte",
"teamMembers": "Anëtarët e Ekipit",
"unlimitedTeamMembers": "Anëtarë të pakufizuar të ekipit",
"unlimitedActiveProjects": "Projekte të pakufizuara aktive",
"schedule": "Orar",
"reporting": "Raportim",
"subscribeToProjects": "Abonohu në projekte",
"billedAnnually": "Faturuar çdo vit",
"billedMonthly": "Faturuar çdo muaj",
"pausePlan": "Pauzë Planin",
"resumePlan": "Rifillo Planin",
"changePlan": "Ndrysho Planin",
"cancelPlan": "Anulo Planin",
"perMonthPerUser": "për përdorues/muaj",
"viewInvoice": "Shiko Faturën",
"switchToFreePlan": "Kalo në Planin Falas",
"expirestoday": "sot",
"expirestomorrow": "nesër",
"expiredDaysAgo": "{{days}} ditë më parë",
"continueWith": "Vazhdo me {{plan}}",
"changeToPlan": "Ndrysho në {{plan}}"
}

View File

@@ -0,0 +1,8 @@
{
"overview": "Përmbledhje",
"name": "Emri i Organizatës",
"owner": "Pronari i Organizatës",
"admins": "Administruesit e Organizatës",
"contactNumber": "Shto Numrin e Kontaktit",
"edit": "Redakto"
}

View File

@@ -0,0 +1,12 @@
{
"membersCount": "Numri i Anëtarëve",
"createdAt": "Krijuar më",
"projectName": "Emri i Projektit",
"teamName": "Emri i Ekipit",
"refreshProjects": "Rifresko Projektet",
"searchPlaceholder": "Kërkoni sipas emrit të projektit",
"deleteProject": "Jeni i sigurt që dëshironi të fshini këtë projekt?",
"confirm": "Konfirmo",
"cancel": "Anulo",
"delete": "Fshi Projektin"
}

View File

@@ -0,0 +1,8 @@
{
"overview": "Përmbledhje",
"users": "Përdoruesit",
"teams": "Ekipet",
"billing": "Faturimi",
"projects": "Projektet",
"adminCenter": "Qendra Administrative"
}

View File

@@ -0,0 +1,33 @@
{
"title": "Ekipet",
"subtitle": "ekipet",
"tooltip": "Rifresko ekipet",
"placeholder": "Kërko sipas emrit",
"addTeam": "Shto Ekip",
"team": "Ekipi",
"membersCount": "Numri i Anëtarëve",
"members": "Anëtarët",
"drawerTitle": "Krijo Ekip të Ri",
"label": "Emri i Ekipit",
"drawerPlaceholder": "Emri",
"create": "Krijo",
"delete": "Fshi",
"settings": "Cilësimet",
"popTitle": "Jeni i sigurt?",
"message": "Ju lutemi shkruani një Emër",
"teamSettings": "Cilësimet e Ekipit",
"teamName": "Emri i Ekipit",
"teamDescription": "Përshkrimi i Ekipit",
"teamMembers": "Anëtarët e Ekipit",
"teamMembersCount": "Numri i Anëtarëve të Ekipit",
"teamMembersPlaceholder": "Kërko sipas emrit",
"addMember": "Shto Anëtar",
"add": "Shto",
"update": "Përditëso",
"teamNamePlaceholder": "Emri i ekipit",
"user": "Përdoruesi",
"role": "Roli",
"owner": "Pronari",
"admin": "Administruesi",
"member": "Anëtari"
}

View File

@@ -0,0 +1,9 @@
{
"title": "Përdoruesit",
"subTitle": "përdoruesit",
"placeholder": "Kërko sipas emrit",
"user": "Përdoruesi",
"email": "Email",
"lastActivity": "Aktiviteti i Fundit",
"refresh": "Rifresko përdoruesit"
}

Some files were not shown because too many files have changed in this diff Show More