Compare commits
85 Commits
v2.1.1
...
feature/me
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1067d87fe | ||
|
|
97feef5982 | ||
|
|
76c92b1cc6 | ||
|
|
67c62fc69b | ||
|
|
14d8f43001 | ||
|
|
3b59a8560b | ||
|
|
819252cedd | ||
|
|
1dade05f54 | ||
|
|
34613e5e0c | ||
|
|
a8b20680e5 | ||
|
|
fc30c1854e | ||
|
|
c19e06d902 | ||
|
|
82155cab8d | ||
|
|
f9858fbd4b | ||
|
|
f3a7fd8be5 | ||
|
|
49bdd00dac | ||
|
|
2e985bd051 | ||
|
|
8e74f1ddb5 | ||
|
|
2a3f87cac1 | ||
|
|
217a6941a1 | ||
|
|
753e3be83f | ||
|
|
ebd0f66768 | ||
|
|
a07584b3af | ||
|
|
0d08634c78 | ||
|
|
d333104f43 | ||
|
|
a724247aec | ||
|
|
2e36a477ce | ||
|
|
6892de487f | ||
|
|
7b04821ef1 | ||
|
|
f8a216fb6e | ||
|
|
86b5d94ff8 | ||
|
|
fb3a505c22 | ||
|
|
72d372b685 | ||
|
|
536c1c37b1 | ||
|
|
40caea7d79 | ||
|
|
33c15ac138 | ||
|
|
05ab135ed2 | ||
|
|
19deef9298 | ||
|
|
c4837e7e5c | ||
|
|
b73ef12eac | ||
|
|
c52b223c59 | ||
|
|
ffc9101030 | ||
|
|
b5c5225867 | ||
|
|
407b3c5ba7 | ||
|
|
528db06cd8 | ||
|
|
0e1314d183 | ||
|
|
7ac35bfdbc | ||
|
|
cc6d647f5a | ||
|
|
fba1adda35 | ||
|
|
fe2518d53c | ||
|
|
62548e5c37 | ||
|
|
faa5d26601 | ||
|
|
ba90fa1274 | ||
|
|
1676fc1314 | ||
|
|
aaaaec6f36 | ||
|
|
e0b2fa2d6f | ||
|
|
4a2393881b | ||
|
|
583fec04d7 | ||
|
|
e7ff9b645b | ||
|
|
2b82ff699e | ||
|
|
d1136a549a | ||
|
|
ec4d3e738a | ||
|
|
c8380e1c30 | ||
|
|
cabc97afc0 | ||
|
|
349f0ecfec | ||
|
|
890ad5e969 | ||
|
|
0fc79d9ae5 | ||
|
|
d60ac2246d | ||
|
|
5d04718394 | ||
|
|
4bece298c1 | ||
|
|
469901ab88 | ||
|
|
13c7015b1c | ||
|
|
21ab2f8a82 | ||
|
|
a368b979d5 | ||
|
|
a5b881c609 | ||
|
|
9dbab2c5d3 | ||
|
|
8f913b0f4e | ||
|
|
31ac184107 | ||
|
|
23558b8efc | ||
|
|
4bb3b42c76 | ||
|
|
0c5eff7121 | ||
|
|
136530adf1 | ||
|
|
6128c64c31 | ||
|
|
a2bfdb682b | ||
|
|
f7582173ed |
35
README.md
35
README.md
@@ -1,6 +1,6 @@
|
|||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
|
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
|
||||||
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/icon-144x144.png" alt="Worklenz Logo" width="75">
|
<img src="https://app.worklenz.com/assets/icons/icon-144x144.png" alt="Worklenz Logo" width="75">
|
||||||
</a>
|
</a>
|
||||||
<br>
|
<br>
|
||||||
Worklenz
|
Worklenz
|
||||||
@@ -69,7 +69,8 @@ cd worklenz
|
|||||||
2. Set up environment variables
|
2. Set up environment variables
|
||||||
- Copy the example environment files
|
- Copy the example environment files
|
||||||
```bash
|
```bash
|
||||||
cp worklenz-backend/.env.template worklenz-backend/.env
|
cp .env.example .env
|
||||||
|
cp worklenz-backend/.env.example worklenz-backend/.env
|
||||||
```
|
```
|
||||||
- Update the environment variables with your configuration
|
- Update the environment variables with your configuration
|
||||||
|
|
||||||
@@ -191,27 +192,6 @@ Email [info@worklenz.com](mailto:info@worklenz.com) to disclose any security vul
|
|||||||
|
|
||||||
This project is licensed under the [MIT License](LICENSE).
|
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
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -335,7 +315,6 @@ docker-compose up -d
|
|||||||
docker-compose down
|
docker-compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## MinIO Integration
|
## MinIO Integration
|
||||||
|
|
||||||
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
|
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
|
||||||
@@ -424,10 +403,6 @@ This script generates properly configured environment files for both development
|
|||||||
- Frontend: http://localhost:5000
|
- Frontend: http://localhost:5000
|
||||||
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
|
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
|
||||||
|
|
||||||
4. Video Guide
|
|
||||||
|
|
||||||
For a visual walkthrough of the local Docker deployment process, check out our [step-by-step video guide](https://www.youtube.com/watch?v=AfwAKxJbqLg).
|
|
||||||
|
|
||||||
### Remote Server Deployment
|
### Remote Server Deployment
|
||||||
|
|
||||||
When deploying to a remote server:
|
When deploying to a remote server:
|
||||||
@@ -453,10 +428,6 @@ When deploying to a remote server:
|
|||||||
- Frontend: http://your-server-hostname:5000
|
- Frontend: http://your-server-hostname:5000
|
||||||
- Backend API: http://your-server-hostname:3000
|
- Backend API: http://your-server-hostname:3000
|
||||||
|
|
||||||
4. Video Guide
|
|
||||||
|
|
||||||
For a complete walkthrough of deploying Worklenz to a remote server, check out our [deployment video guide](https://www.youtube.com/watch?v=CAZGu2iOXQs&t=10s).
|
|
||||||
|
|
||||||
### Environment Configuration
|
### Environment Configuration
|
||||||
|
|
||||||
The Docker setup uses environment variables to configure the services:
|
The Docker setup uses environment variables to configure the services:
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
restart: unless-stopped
|
condition: service_started
|
||||||
env_file:
|
env_file:
|
||||||
- ./worklenz-frontend/.env.production
|
- ./worklenz-frontend/.env.production
|
||||||
networks:
|
networks:
|
||||||
@@ -26,7 +26,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
minio:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
env_file:
|
||||||
- ./worklenz-backend/.env
|
- ./worklenz-backend/.env
|
||||||
networks:
|
networks:
|
||||||
@@ -38,7 +37,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "9000:9000"
|
- "9000:9000"
|
||||||
- "9001:9001"
|
- "9001:9001"
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin}
|
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin}
|
||||||
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin}
|
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin}
|
||||||
@@ -54,14 +52,13 @@ services:
|
|||||||
container_name: worklenz_createbuckets
|
container_name: worklenz_createbuckets
|
||||||
depends_on:
|
depends_on:
|
||||||
- minio
|
- minio
|
||||||
restart: on-failure
|
|
||||||
entrypoint: >
|
entrypoint: >
|
||||||
/bin/sh -c '
|
/bin/sh -c '
|
||||||
echo "Waiting for MinIO to start...";
|
echo "Waiting for MinIO to start...";
|
||||||
sleep 15;
|
sleep 15;
|
||||||
for i in 1 2 3 4 5; do
|
for i in 1 2 3 4 5; do
|
||||||
echo "Attempt $i to connect to MinIO...";
|
echo "Attempt $i to connect to MinIO...";
|
||||||
if /usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin; then
|
if /usr/bin/mc config host add myminio http://minio:9000 minioadmin minioadmin; then
|
||||||
echo "Successfully connected to MinIO!";
|
echo "Successfully connected to MinIO!";
|
||||||
/usr/bin/mc mb --ignore-existing myminio/worklenz-bucket;
|
/usr/bin/mc mb --ignore-existing myminio/worklenz-bucket;
|
||||||
/usr/bin/mc policy set public myminio/worklenz-bucket;
|
/usr/bin/mc policy set public myminio/worklenz-bucket;
|
||||||
@@ -87,7 +84,6 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
networks:
|
||||||
- worklenz
|
- worklenz
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
39
docs/recurring-tasks-user-guide.md
Normal file
39
docs/recurring-tasks-user-guide.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 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 most common options:
|
||||||
|
|
||||||
|
- **Daily:** The task is created every day.
|
||||||
|
- **Weekly:** The task is created once a week. You can pick the day (e.g., every Monday).
|
||||||
|
- **Monthly:** The task is created once a month. You can pick the date (e.g., the 1st of every month).
|
||||||
|
- **Weekdays:** The task is created every Monday to Friday.
|
||||||
|
- **Custom:** Set your own schedule, such as every 2 days, every 3 weeks, or only on certain days.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
- "Send team update" every Friday (weekly)
|
||||||
|
- "Submit expense report" on the 1st of each month (monthly)
|
||||||
|
- "Check backups" every day (daily)
|
||||||
|
- "Review project status" every Monday and Thursday (custom)
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Need Help?
|
||||||
|
If you have questions or need help setting up recurring tasks, contact your workspace admin or support team.
|
||||||
56
docs/recurring-tasks.md
Normal file
56
docs/recurring-tasks.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
- Errors are logged using the `log_error` utility.
|
||||||
|
- The job continues processing other templates even if one fails.
|
||||||
|
|
||||||
|
## 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.
|
||||||
223
docs/task-progress-guide-for-users.md
Normal file
223
docs/task-progress-guide-for-users.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
# 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.
|
||||||
550
docs/task-progress-methods.md
Normal file
550
docs/task-progress-methods.md
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
# 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.
|
||||||
244
task-progress-methods.md
Normal file
244
task-progress-methods.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# 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.
|
||||||
@@ -73,8 +73,8 @@ cat > worklenz-backend/.env << EOL
|
|||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
SESSION_NAME=worklenz.sid
|
SESSION_NAME=worklenz.sid
|
||||||
SESSION_SECRET=$(openssl rand -base64 48)
|
SESSION_SECRET=change_me_in_production
|
||||||
COOKIE_SECRET=$(openssl rand -base64 48)
|
COOKIE_SECRET=change_me_in_production
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
SOCKET_IO_CORS=${FRONTEND_URL}
|
SOCKET_IO_CORS=${FRONTEND_URL}
|
||||||
@@ -123,7 +123,7 @@ SLACK_WEBHOOK=
|
|||||||
COMMIT_BUILD_IMMEDIATELY=true
|
COMMIT_BUILD_IMMEDIATELY=true
|
||||||
|
|
||||||
# JWT Secret
|
# JWT Secret
|
||||||
JWT_SECRET=$(openssl rand -base64 48)
|
JWT_SECRET=change_me_in_production
|
||||||
EOL
|
EOL
|
||||||
|
|
||||||
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")
|
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")
|
||||||
@@ -138,4 +138,4 @@ echo "Frontend URL: ${FRONTEND_URL}"
|
|||||||
echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000"
|
echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000"
|
||||||
echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000"
|
echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000"
|
||||||
echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}"
|
echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}"
|
||||||
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"
|
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
build
|
||||||
.scannerwork
|
.scannerwork
|
||||||
coverage
|
coverage
|
||||||
.dockerignore
|
|
||||||
.git
|
|
||||||
*.md
|
|
||||||
tests
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,12 +47,17 @@ FRONTEND_URL=http://localhost:5000
|
|||||||
# STORAGE
|
# STORAGE
|
||||||
STORAGE_PROVIDER=s3 # values s3 or azure
|
STORAGE_PROVIDER=s3 # values s3 or azure
|
||||||
|
|
||||||
# AWS
|
# AWS - SES
|
||||||
AWS_REGION="your_aws_region"
|
AWS_REGION="your_aws_region"
|
||||||
AWS_ACCESS_KEY_ID="your_aws_access_key_id"
|
AWS_ACCESS_KEY_ID="your_aws_access_key_id"
|
||||||
AWS_SECRET_ACCESS_KEY="your_aws_secret_access_key"
|
AWS_SECRET_ACCESS_KEY="your_aws_secret_access_key"
|
||||||
AWS_BUCKET="your_s3_bucket"
|
|
||||||
|
# S3
|
||||||
|
S3_REGION="S3_REGION"
|
||||||
|
S3_BUCKET="your_s3_bucket"
|
||||||
S3_URL="your_s3_url"
|
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
|
||||||
AZURE_STORAGE_ACCOUNT_NAME="your_storage_account_name"
|
AZURE_STORAGE_ACCOUNT_NAME="your_storage_account_name"
|
||||||
|
|||||||
@@ -1,39 +1,26 @@
|
|||||||
# --- Stage 1: Build ---
|
# Use the official Node.js 20 image as a base
|
||||||
FROM node:20-slim AS builder
|
FROM node:20
|
||||||
|
|
||||||
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
|
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 ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install app dependencies
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy the rest of the application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Run the build script to compile TypeScript to JavaScript
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
RUN echo "$RELEASE_VERSION" > release
|
# Expose the port the app runs on
|
||||||
|
|
||||||
# --- 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
|
EXPOSE 3000
|
||||||
CMD ["node", "build/bin/www"]
|
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
-- 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
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- 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;
|
||||||
|
|
||||||
|
-- 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();
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
-- Migration: Fix progress_mode_type ENUM and casting issues
|
||||||
|
-- Date: 2025-04-27
|
||||||
|
-- Version: 1.0.0
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- First, let's ensure the ENUM type exists with the correct values
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Check if the type exists
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'progress_mode_type') THEN
|
||||||
|
CREATE TYPE progress_mode_type AS ENUM ('manual', 'weighted', 'time', 'default');
|
||||||
|
ELSE
|
||||||
|
-- Add any missing values to the existing ENUM
|
||||||
|
BEGIN
|
||||||
|
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'manual';
|
||||||
|
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'weighted';
|
||||||
|
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'time';
|
||||||
|
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'default';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN
|
||||||
|
-- Ignore if values already exist
|
||||||
|
NULL;
|
||||||
|
END;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Update functions to use proper type casting
|
||||||
|
CREATE OR REPLACE FUNCTION on_update_task_progress(_body json) RETURNS json
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS
|
||||||
|
$$
|
||||||
|
DECLARE
|
||||||
|
_task_id UUID;
|
||||||
|
_progress_value INTEGER;
|
||||||
|
_parent_task_id UUID;
|
||||||
|
_project_id UUID;
|
||||||
|
_current_mode progress_mode_type;
|
||||||
|
BEGIN
|
||||||
|
_task_id = (_body ->> 'task_id')::UUID;
|
||||||
|
_progress_value = (_body ->> 'progress_value')::INTEGER;
|
||||||
|
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
|
||||||
|
|
||||||
|
-- Get the project ID and determine the current progress mode
|
||||||
|
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
|
||||||
|
|
||||||
|
IF _project_id IS NOT NULL THEN
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
|
||||||
|
WHEN use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
|
||||||
|
WHEN use_time_progress IS TRUE THEN 'time'::progress_mode_type
|
||||||
|
ELSE 'default'::progress_mode_type
|
||||||
|
END
|
||||||
|
INTO _current_mode
|
||||||
|
FROM projects
|
||||||
|
WHERE id = _project_id;
|
||||||
|
ELSE
|
||||||
|
_current_mode := 'default'::progress_mode_type;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Update the task with progress value and set the progress mode
|
||||||
|
UPDATE tasks
|
||||||
|
SET progress_value = _progress_value,
|
||||||
|
manual_progress = TRUE,
|
||||||
|
progress_mode = _current_mode,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = _task_id;
|
||||||
|
|
||||||
|
-- Return the updated task info
|
||||||
|
RETURN JSON_BUILD_OBJECT(
|
||||||
|
'task_id', _task_id,
|
||||||
|
'progress_value', _progress_value,
|
||||||
|
'progress_mode', _current_mode
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Update the on_update_task_weight function to use proper type casting
|
||||||
|
CREATE OR REPLACE FUNCTION on_update_task_weight(_body json) RETURNS json
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS
|
||||||
|
$$
|
||||||
|
DECLARE
|
||||||
|
_task_id UUID;
|
||||||
|
_weight INTEGER;
|
||||||
|
_parent_task_id UUID;
|
||||||
|
_project_id UUID;
|
||||||
|
BEGIN
|
||||||
|
_task_id = (_body ->> 'task_id')::UUID;
|
||||||
|
_weight = (_body ->> 'weight')::INTEGER;
|
||||||
|
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
|
||||||
|
|
||||||
|
-- Get the project ID
|
||||||
|
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
|
||||||
|
|
||||||
|
-- Update the task with weight value and set progress_mode to 'weighted'
|
||||||
|
UPDATE tasks
|
||||||
|
SET weight = _weight,
|
||||||
|
progress_mode = 'weighted'::progress_mode_type,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = _task_id;
|
||||||
|
|
||||||
|
-- Return the updated task info
|
||||||
|
RETURN JSON_BUILD_OBJECT(
|
||||||
|
'task_id', _task_id,
|
||||||
|
'weight', _weight
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Update the reset_project_progress_values function to use proper type casting
|
||||||
|
CREATE OR REPLACE FUNCTION reset_project_progress_values() RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS
|
||||||
|
$$
|
||||||
|
DECLARE
|
||||||
|
_old_mode progress_mode_type;
|
||||||
|
_new_mode progress_mode_type;
|
||||||
|
_project_id UUID;
|
||||||
|
BEGIN
|
||||||
|
_project_id := NEW.id;
|
||||||
|
|
||||||
|
-- Determine old and new modes with proper type casting
|
||||||
|
_old_mode :=
|
||||||
|
CASE
|
||||||
|
WHEN OLD.use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
|
||||||
|
WHEN OLD.use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
|
||||||
|
WHEN OLD.use_time_progress IS TRUE THEN 'time'::progress_mode_type
|
||||||
|
ELSE 'default'::progress_mode_type
|
||||||
|
END;
|
||||||
|
|
||||||
|
_new_mode :=
|
||||||
|
CASE
|
||||||
|
WHEN NEW.use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
|
||||||
|
WHEN NEW.use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
|
||||||
|
WHEN NEW.use_time_progress IS TRUE THEN 'time'::progress_mode_type
|
||||||
|
ELSE 'default'::progress_mode_type
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- If mode has changed, reset progress values for tasks with the old mode
|
||||||
|
IF _old_mode <> _new_mode THEN
|
||||||
|
-- Reset progress values for tasks that were set in the old mode
|
||||||
|
UPDATE tasks
|
||||||
|
SET progress_value = NULL,
|
||||||
|
progress_mode = NULL
|
||||||
|
WHERE project_id = _project_id
|
||||||
|
AND progress_mode = _old_mode;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Update the tasks table to ensure proper type casting for existing values
|
||||||
|
UPDATE tasks
|
||||||
|
SET progress_mode = progress_mode::text::progress_mode_type
|
||||||
|
WHERE progress_mode IS NOT NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
-- 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;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +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 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', 'alb', 'de');
|
CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt');
|
||||||
|
|
||||||
-- START: Users
|
-- START: Users
|
||||||
CREATE SEQUENCE IF NOT EXISTS users_user_no_seq START 1;
|
CREATE SEQUENCE IF NOT EXISTS users_user_no_seq START 1;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import db from "../config/db";
|
|||||||
import {ServerResponse} from "../models/server-response";
|
import {ServerResponse} from "../models/server-response";
|
||||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||||
import HandleExceptions from "../decorators/handle-exceptions";
|
import HandleExceptions from "../decorators/handle-exceptions";
|
||||||
import {calculateMonthDays, getColor, megabytesToBytes} from "../shared/utils";
|
import {calculateMonthDays, getColor, log_error, megabytesToBytes} from "../shared/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {calculateStorage} from "../shared/s3";
|
import {calculateStorage} from "../shared/s3";
|
||||||
import {checkTeamSubscriptionStatus, getActiveTeamMemberCount, getCurrentProjectsCount, getFreePlanSettings, getOwnerIdByTeam, getTeamMemberCount, getUsedStorage} from "../shared/paddle-utils";
|
import {checkTeamSubscriptionStatus, getActiveTeamMemberCount, getCurrentProjectsCount, getFreePlanSettings, getOwnerIdByTeam, getTeamMemberCount, getUsedStorage} from "../shared/paddle-utils";
|
||||||
@@ -232,7 +232,11 @@ export default class AdminCenterController extends WorklenzControllerBase {
|
|||||||
FROM team_member_info_view
|
FROM team_member_info_view
|
||||||
WHERE team_member_info_view.team_member_id = tm.id),
|
WHERE team_member_info_view.team_member_id = tm.id),
|
||||||
role_id,
|
role_id,
|
||||||
r.name AS role_name
|
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
|
||||||
FROM team_members tm
|
FROM team_members tm
|
||||||
LEFT JOIN users u on tm.user_id = u.id
|
LEFT JOIN users u on tm.user_id = u.id
|
||||||
LEFT JOIN roles r on tm.role_id = r.id
|
LEFT JOIN roles r on tm.role_id = r.id
|
||||||
@@ -255,22 +259,33 @@ export default class AdminCenterController extends WorklenzControllerBase {
|
|||||||
const {id} = req.params;
|
const {id} = req.params;
|
||||||
const {name, teamMembers} = req.body;
|
const {name, teamMembers} = req.body;
|
||||||
|
|
||||||
const updateNameQuery = `UPDATE teams
|
try {
|
||||||
SET name = $1
|
// Update team name
|
||||||
WHERE id = $2;`;
|
const updateNameQuery = `UPDATE teams SET name = $1 WHERE id = $2 RETURNING id;`;
|
||||||
await db.query(updateNameQuery, [name, 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"));
|
||||||
|
}
|
||||||
|
|
||||||
if (teamMembers.length) {
|
// Update team member roles if provided
|
||||||
teamMembers.forEach(async (element: { role_name: string; user_id: string; }) => {
|
if (teamMembers?.length) {
|
||||||
const q = `UPDATE team_members
|
// Use Promise.all to handle all role updates concurrently
|
||||||
SET role_id = (SELECT id FROM roles WHERE roles.team_id = $1 AND name = $2)
|
await Promise.all(teamMembers.map(async (member: { role_name: string; user_id: string; }) => {
|
||||||
WHERE user_id = $3
|
const roleQuery = `
|
||||||
AND team_id = $1;`;
|
UPDATE team_members
|
||||||
await db.query(q, [id, element.role_name, element.user_id]);
|
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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, [], "Team updated successfully"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ export default class ProjectInsightsController extends WorklenzControllerBase {
|
|||||||
(SELECT get_task_assignees(tasks.id)) AS assignees
|
(SELECT get_task_assignees(tasks.id)) AS assignees
|
||||||
FROM tasks
|
FROM tasks
|
||||||
JOIN work_log ON work_log.task_id = tasks.id
|
JOIN work_log ON work_log.task_id = tasks.id
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1 AND total_minutes <> 0 AND (total_minutes * 60) <> work_log.total_time_spent
|
||||||
AND CASE
|
AND CASE
|
||||||
WHEN ($2 IS TRUE) THEN project_id IS NOT NULL
|
WHEN ($2 IS TRUE) THEN project_id IS NOT NULL
|
||||||
ELSE archived IS FALSE END
|
ELSE archived IS FALSE END
|
||||||
|
|||||||
@@ -408,6 +408,9 @@ export default class ProjectsController extends WorklenzControllerBase {
|
|||||||
sps.color_code AS status_color,
|
sps.color_code AS status_color,
|
||||||
sps.icon AS status_icon,
|
sps.icon AS status_icon,
|
||||||
(SELECT name FROM clients WHERE id = projects.client_id) AS client_name,
|
(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)
|
(SELECT COALESCE(ROW_TO_JSON(pm), '{}'::JSON)
|
||||||
FROM (SELECT team_member_id AS id,
|
FROM (SELECT team_member_id AS id,
|
||||||
|
|||||||
@@ -415,15 +415,20 @@ export default class ReportingController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async getMyTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async getMyTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const selectedTeamId = req.user?.team_id;
|
||||||
|
if (!selectedTeamId) {
|
||||||
|
return res.status(400).send(new ServerResponse(false, "No selected team"));
|
||||||
|
}
|
||||||
const q = `SELECT team_id AS id, name
|
const q = `SELECT team_id AS id, name
|
||||||
FROM team_members tm
|
FROM team_members tm
|
||||||
LEFT JOIN teams ON teams.id = tm.team_id
|
LEFT JOIN teams ON teams.id = tm.team_id
|
||||||
WHERE tm.user_id = $1
|
WHERE tm.user_id = $1
|
||||||
|
AND tm.team_id = $2
|
||||||
AND role_id IN (SELECT id
|
AND role_id IN (SELECT id
|
||||||
FROM roles
|
FROM roles
|
||||||
WHERE (admin_role IS TRUE OR owner IS TRUE))
|
WHERE (admin_role IS TRUE OR owner IS TRUE))
|
||||||
ORDER BY name;`;
|
ORDER BY name;`;
|
||||||
const result = await db.query(q, [req.user?.id]);
|
const result = await db.query(q, [req.user?.id, selectedTeamId]);
|
||||||
result.rows.forEach((team: any) => team.selected = true);
|
result.rows.forEach((team: any) => team.selected = true);
|
||||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -408,6 +408,90 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
|||||||
|
|
||||||
const { duration, date_range } = req.body;
|
const { duration, date_range } = req.body;
|
||||||
|
|
||||||
|
// Calculate the date range (start and end)
|
||||||
|
let startDate: moment.Moment;
|
||||||
|
let endDate: moment.Moment;
|
||||||
|
if (date_range && date_range.length === 2) {
|
||||||
|
startDate = moment(date_range[0]);
|
||||||
|
endDate = moment(date_range[1]);
|
||||||
|
} else if (duration === DATE_RANGES.ALL_TIME) {
|
||||||
|
// Fetch the earliest start_date (or created_at if null) from selected projects
|
||||||
|
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
|
||||||
|
const minDateResult = await db.query(minDateQuery, []);
|
||||||
|
const minDate = minDateResult.rows[0]?.min_date;
|
||||||
|
startDate = minDate ? moment(minDate) : moment('2000-01-01');
|
||||||
|
endDate = moment();
|
||||||
|
} else {
|
||||||
|
switch (duration) {
|
||||||
|
case DATE_RANGES.YESTERDAY:
|
||||||
|
startDate = moment().subtract(1, "day");
|
||||||
|
endDate = moment().subtract(1, "day");
|
||||||
|
break;
|
||||||
|
case DATE_RANGES.LAST_WEEK:
|
||||||
|
startDate = moment().subtract(1, "week").startOf("isoWeek");
|
||||||
|
endDate = moment().subtract(1, "week").endOf("isoWeek");
|
||||||
|
break;
|
||||||
|
case DATE_RANGES.LAST_MONTH:
|
||||||
|
startDate = moment().subtract(1, "month").startOf("month");
|
||||||
|
endDate = moment().subtract(1, "month").endOf("month");
|
||||||
|
break;
|
||||||
|
case DATE_RANGES.LAST_QUARTER:
|
||||||
|
startDate = moment().subtract(3, "months").startOf("quarter");
|
||||||
|
endDate = moment().subtract(1, "quarter").endOf("quarter");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
startDate = moment().startOf("day");
|
||||||
|
endDate = moment().endOf("day");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
let totalWorkingHours = workingDays * orgWorkingHours;
|
||||||
|
|
||||||
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||||
const archivedClause = archived
|
const archivedClause = archived
|
||||||
? ""
|
? ""
|
||||||
@@ -430,6 +514,19 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
|||||||
for (const member of result.rows) {
|
for (const member of result.rows) {
|
||||||
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
|
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
|
||||||
member.color_code = getColor(member.name);
|
member.color_code = getColor(member.name);
|
||||||
|
member.total_working_hours = totalWorkingHours;
|
||||||
|
if (totalWorkingHours === 0) {
|
||||||
|
member.utilization_percent = member.logged_time && parseFloat(member.logged_time) > 0 ? 'N/A' : '0.00';
|
||||||
|
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
|
||||||
|
// Over/under utilized hours: all logged time is over-utilized
|
||||||
|
member.over_under_utilized_hours = member.utilized_hours;
|
||||||
|
} else {
|
||||||
|
member.utilization_percent = (member.logged_time && totalWorkingHours > 0) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00';
|
||||||
|
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
|
||||||
|
// Over/under utilized hours: utilized_hours - total_working_hours
|
||||||
|
const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0;
|
||||||
|
member.over_under_utilized_hours = overUnder.toFixed(2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
|
|||||||
@@ -74,14 +74,9 @@ export default class ScheduleControllerV2 extends WorklenzControllerBase {
|
|||||||
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
|
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
const updateQuery = `
|
const updateQuery = `UPDATE public.organization_working_days
|
||||||
UPDATE public.organization_working_days
|
|
||||||
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
|
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE organization_id IN (
|
WHERE organization_id IN (SELECT id FROM organizations WHERE user_id = $1);`;
|
||||||
SELECT organization_id FROM organizations
|
|
||||||
WHERE user_id = $1
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
await db.query(updateQuery, [req.user?.owner_id]);
|
await db.query(updateQuery, [req.user?.owner_id]);
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,51 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static updateTaskViewModel(task: any) {
|
public static updateTaskViewModel(task: any) {
|
||||||
task.progress = ~~(task.total_minutes_spent / task.total_minutes * 100);
|
console.log(`Processing task ${task.id} (${task.name})`);
|
||||||
|
console.log(` manual_progress: ${task.manual_progress}, progress_value: ${task.progress_value}`);
|
||||||
|
console.log(` project_use_manual_progress: ${task.project_use_manual_progress}, project_use_weighted_progress: ${task.project_use_weighted_progress}`);
|
||||||
|
console.log(` has subtasks: ${task.sub_tasks_count > 0}`);
|
||||||
|
|
||||||
|
// For parent tasks (with subtasks), always use calculated progress from subtasks
|
||||||
|
if (task.sub_tasks_count > 0) {
|
||||||
|
// For parent tasks without manual progress, calculate from subtasks (already done via db function)
|
||||||
|
console.log(` Parent task with subtasks: complete_ratio=${task.complete_ratio}`);
|
||||||
|
|
||||||
|
// Ensure progress matches complete_ratio for consistency
|
||||||
|
task.progress = task.complete_ratio || 0;
|
||||||
|
|
||||||
|
// Important: Parent tasks should not have manual progress
|
||||||
|
// If they somehow do, reset it
|
||||||
|
if (task.manual_progress) {
|
||||||
|
console.log(` WARNING: Parent task ${task.id} had manual_progress set to true, resetting`);
|
||||||
|
task.manual_progress = false;
|
||||||
|
task.progress_value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
console.log(` Using manual progress: progress=${task.progress}, complete_ratio=${task.complete_ratio}`);
|
||||||
|
}
|
||||||
|
// For tasks with no subtasks and no manual progress, calculate based on time
|
||||||
|
else {
|
||||||
|
task.progress = task.total_minutes_spent && task.total_minutes
|
||||||
|
? ~~(task.total_minutes_spent / task.total_minutes * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Set complete_ratio to match progress
|
||||||
|
task.complete_ratio = task.progress;
|
||||||
|
|
||||||
|
console.log(` Calculated time-based progress: progress=${task.progress}, complete_ratio=${task.complete_ratio}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure numeric values
|
||||||
|
task.progress = parseInt(task.progress) || 0;
|
||||||
|
task.complete_ratio = parseInt(task.complete_ratio) || 0;
|
||||||
|
|
||||||
task.overdue = task.total_minutes < task.total_minutes_spent;
|
task.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};
|
||||||
@@ -73,9 +117,9 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
|||||||
if (task.timer_start_time)
|
if (task.timer_start_time)
|
||||||
task.timer_start_time = moment(task.timer_start_time).valueOf();
|
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 totalCompleted = (+task.completed_sub_tasks + +task.parent_task_completed) || 0;
|
||||||
const totalTasks = +task.sub_tasks_count || 0; // if needed add +1 for parent
|
const totalTasks = +task.sub_tasks_count || 0;
|
||||||
task.complete_ratio = TasksControllerBase.calculateTaskCompleteRatio(totalCompleted, totalTasks);
|
|
||||||
task.completed_count = totalCompleted;
|
task.completed_count = totalCompleted;
|
||||||
task.total_tasks_count = totalTasks;
|
task.total_tasks_count = totalTasks;
|
||||||
|
|
||||||
|
|||||||
@@ -97,9 +97,14 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
try {
|
try {
|
||||||
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
|
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
|
||||||
const [data] = result.rows;
|
const [data] = result.rows;
|
||||||
data.info.ratio = +data.info.ratio.toFixed();
|
console.log("data", data);
|
||||||
return data.info;
|
if (data && data.info && data.info.ratio !== undefined) {
|
||||||
|
data.info.ratio = +((data.info.ratio || 0).toFixed());
|
||||||
|
return data.info;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
log_error(`Error in getTaskCompleteRatio: ${error}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,6 +197,13 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
t.archived,
|
t.archived,
|
||||||
t.description,
|
t.description,
|
||||||
t.sort_order,
|
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 phase_id FROM task_phase WHERE task_id = t.id) AS phase_id,
|
||||||
(SELECT name
|
(SELECT name
|
||||||
@@ -315,6 +327,11 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
|
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async getList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
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 isSubTasks = !!req.query.parent_task;
|
||||||
const groupBy = (req.query.group || GroupBy.STATUS) as string;
|
const groupBy = (req.query.group || GroupBy.STATUS) as string;
|
||||||
|
|
||||||
@@ -334,7 +351,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
return g;
|
return g;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
this.updateMapByGroup(tasks, groupBy, map);
|
await this.updateMapByGroup(tasks, groupBy, map);
|
||||||
|
|
||||||
const updatedGroups = Object.keys(map).map(key => {
|
const updatedGroups = Object.keys(map).map(key => {
|
||||||
const group = map[key];
|
const group = map[key];
|
||||||
@@ -353,12 +370,28 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
return res.status(200).send(new ServerResponse(true, updatedGroups));
|
return res.status(200).send(new ServerResponse(true, updatedGroups));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) {
|
public static async updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
const unmapped = [];
|
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) {
|
for (const task of tasks) {
|
||||||
task.index = index++;
|
task.index = index++;
|
||||||
TasksControllerV2.updateTaskViewModel(task);
|
TasksControllerV2.updateTaskViewModel(task);
|
||||||
|
|
||||||
if (groupBy === GroupBy.STATUS) {
|
if (groupBy === GroupBy.STATUS) {
|
||||||
map[task.status]?.tasks.push(task);
|
map[task.status]?.tasks.push(task);
|
||||||
} else if (groupBy === GroupBy.PRIORITY) {
|
} else if (groupBy === GroupBy.PRIORITY) {
|
||||||
@@ -394,8 +427,13 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
|
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
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;
|
const isSubTasks = !!req.query.parent_task;
|
||||||
|
|
||||||
// Add customColumns flag to query params
|
// Add customColumns flag to query params
|
||||||
req.query.customColumns = "true";
|
req.query.customColumns = "true";
|
||||||
|
|
||||||
@@ -410,7 +448,24 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
[data] = result.rows;
|
[data] = result.rows;
|
||||||
} else { // else we return a flat list of tasks
|
} else { // else we return a flat list of tasks
|
||||||
data = [...result.rows];
|
data = [...result.rows];
|
||||||
|
|
||||||
for (const task of data) {
|
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);
|
TasksControllerV2.updateTaskViewModel(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,6 +498,53 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
return res.status(200).send(new ServerResponse(true, task));
|
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()
|
@HandleExceptions()
|
||||||
public static async convertToSubtask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async convertToSubtask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
|
||||||
@@ -482,6 +584,11 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
? [req.body.id, req.body.to_group_id]
|
? [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];
|
: [req.body.id, req.body.project_id, req.body.parent_task_id, req.body.to_group_id];
|
||||||
await db.query(q, params);
|
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 result = await db.query("SELECT get_single_task($1) AS task;", [req.body.id]);
|
||||||
const [data] = result.rows;
|
const [data] = result.rows;
|
||||||
@@ -724,4 +831,126 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
value
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {startDailyDigestJob} from "./daily-digest-job";
|
import {startDailyDigestJob} from "./daily-digest-job";
|
||||||
import {startNotificationsJob} from "./notifications-job";
|
import {startNotificationsJob} from "./notifications-job";
|
||||||
import {startProjectDigestJob} from "./project-digest-job";
|
import {startProjectDigestJob} from "./project-digest-job";
|
||||||
import { startRecurringTasksJob } from "./recurring-tasks";
|
import {startRecurringTasksJob} from "./recurring-tasks";
|
||||||
|
|
||||||
export function startCronJobs() {
|
export function startCronJobs() {
|
||||||
startNotificationsJob();
|
startNotificationsJob();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import TasksController from "../controllers/tasks-controller";
|
|||||||
|
|
||||||
// At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday.
|
// At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday.
|
||||||
// const TIME = "0 11 */1 * 1-5";
|
// const TIME = "0 11 */1 * 1-5";
|
||||||
const TIME = "*/2 * * * *";
|
const TIME = "*/2 * * * *"; // runs every 2 minutes - for testing purposes
|
||||||
const TIME_FORMAT = "YYYY-MM-DD";
|
const TIME_FORMAT = "YYYY-MM-DD";
|
||||||
// const TIME = "0 0 * * *"; // Runs at midnight every day
|
// const TIME = "0 0 * * *"; // Runs at midnight every day
|
||||||
|
|
||||||
|
|||||||
@@ -204,3 +204,29 @@ export async function logPhaseChange(activityLog: IActivityLog) {
|
|||||||
insertToActivityLogs(activityLog);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export enum IActivityLogAttributeTypes {
|
|||||||
COMMENT = "comment",
|
COMMENT = "comment",
|
||||||
ARCHIVE = "archive",
|
ARCHIVE = "archive",
|
||||||
PHASE = "phase",
|
PHASE = "phase",
|
||||||
|
PROGRESS = "progress",
|
||||||
|
WEIGHT = "weight",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum IActivityLogChangeType {
|
export enum IActivityLogChangeType {
|
||||||
|
|||||||
@@ -117,11 +117,11 @@ export const TASK_DUE_NO_DUE_COLOR = "#a9a9a9";
|
|||||||
export const DEFAULT_PAGE_SIZE = 20;
|
export const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
// S3 Credentials
|
// S3 Credentials
|
||||||
export const REGION = process.env.AWS_REGION || "us-east-1";
|
export const REGION = process.env.S3_REGION || "us-east-1";
|
||||||
export const BUCKET = process.env.AWS_BUCKET || "your-bucket-name";
|
export const BUCKET = process.env.S3_BUCKET || "your-bucket-name";
|
||||||
export const S3_URL = process.env.S3_URL || "https://your-s3-url";
|
export const S3_URL = process.env.S3_URL || "https://your-s3-url";
|
||||||
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "";
|
export const S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID || "";
|
||||||
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "";
|
export const S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY || "";
|
||||||
|
|
||||||
// Azure Blob Storage Credentials
|
// Azure Blob Storage Credentials
|
||||||
export const STORAGE_PROVIDER = process.env.STORAGE_PROVIDER || "s3";
|
export const STORAGE_PROVIDER = process.env.STORAGE_PROVIDER || "s3";
|
||||||
@@ -166,7 +166,9 @@ export const UNMAPPED = "Unmapped";
|
|||||||
|
|
||||||
export const DATE_RANGES = {
|
export const DATE_RANGES = {
|
||||||
YESTERDAY: "YESTERDAY",
|
YESTERDAY: "YESTERDAY",
|
||||||
|
LAST_7_DAYS: "LAST_7_DAYS",
|
||||||
LAST_WEEK: "LAST_WEEK",
|
LAST_WEEK: "LAST_WEEK",
|
||||||
|
LAST_30_DAYS: "LAST_30_DAYS",
|
||||||
LAST_MONTH: "LAST_MONTH",
|
LAST_MONTH: "LAST_MONTH",
|
||||||
LAST_QUARTER: "LAST_QUARTER",
|
LAST_QUARTER: "LAST_QUARTER",
|
||||||
ALL_TIME: "ALL_TIME"
|
ALL_TIME: "ALL_TIME"
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import TasksControllerV2 from "../../controllers/tasks-controller-v2";
|
|||||||
|
|
||||||
export async function on_get_task_progress(_io: Server, socket: Socket, taskId?: string) {
|
export async function on_get_task_progress(_io: Server, socket: Socket, taskId?: string) {
|
||||||
try {
|
try {
|
||||||
|
console.log(`GET_TASK_PROGRESS requested for task: ${taskId}`);
|
||||||
|
|
||||||
const task: any = {};
|
const task: any = {};
|
||||||
task.id = taskId;
|
task.id = taskId;
|
||||||
|
|
||||||
@@ -13,6 +15,8 @@ export async function on_get_task_progress(_io: Server, socket: Socket, taskId?:
|
|||||||
task.complete_ratio = info.ratio;
|
task.complete_ratio = info.ratio;
|
||||||
task.completed_count = info.total_completed;
|
task.completed_count = info.total_completed;
|
||||||
task.total_tasks_count = info.total_tasks;
|
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);
|
return socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task);
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@ import db from "../../config/db";
|
|||||||
import {NotificationsService} from "../../services/notifications/notifications.service";
|
import {NotificationsService} from "../../services/notifications/notifications.service";
|
||||||
import {TASK_STATUS_COLOR_ALPHA} from "../../shared/constants";
|
import {TASK_STATUS_COLOR_ALPHA} from "../../shared/constants";
|
||||||
import {SocketEvents} from "../events";
|
import {SocketEvents} from "../events";
|
||||||
import {getLoggedInUserIdFromSocket, log_error, notifyProjectUpdates} from "../util";
|
import {getLoggedInUserIdFromSocket, log, log_error, notifyProjectUpdates} from "../util";
|
||||||
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
|
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
|
||||||
import {getTaskDetails, logStatusChange} from "../../services/activity-logs/activity-logs.service";
|
import {getTaskDetails, logProgressChange, logStatusChange} from "../../services/activity-logs/activity-logs.service";
|
||||||
import { assignMemberIfNot } from "./on-quick-assign-or-remove";
|
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) {
|
export async function on_task_status_change(_io: Server, socket: Socket, data?: string) {
|
||||||
try {
|
try {
|
||||||
@@ -49,6 +50,46 @@ 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);
|
const info = await TasksControllerV2.getTaskCompleteRatio(body.parent_task || body.task_id);
|
||||||
|
|
||||||
socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), {
|
socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), {
|
||||||
|
|||||||
@@ -6,10 +6,76 @@ import { SocketEvents } from "../events";
|
|||||||
import { log_error, notifyProjectUpdates } from "../util";
|
import { log_error, notifyProjectUpdates } from "../util";
|
||||||
import { getTaskDetails, logTotalMinutes } from "../../services/activity-logs/activity-logs.service";
|
import { getTaskDetails, logTotalMinutes } from "../../services/activity-logs/activity-logs.service";
|
||||||
|
|
||||||
export async function on_time_estimation_change(_io: Server, socket: Socket, data?: string) {
|
/**
|
||||||
|
* 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) {
|
||||||
try {
|
try {
|
||||||
// (SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent,
|
// (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;`;
|
const q = `UPDATE tasks SET total_minutes = $2 WHERE id = $1 RETURNING total_minutes, project_id, parent_task_id;`;
|
||||||
const body = JSON.parse(data as string);
|
const body = JSON.parse(data as string);
|
||||||
|
|
||||||
const hours = body.total_hours || 0;
|
const hours = body.total_hours || 0;
|
||||||
@@ -19,7 +85,10 @@ export async function on_time_estimation_change(_io: Server, socket: Socket, dat
|
|||||||
const task_data = await getTaskDetails(body.task_id, "total_minutes");
|
const task_data = await getTaskDetails(body.task_id, "total_minutes");
|
||||||
|
|
||||||
const result0 = await db.query(q, [body.task_id, totalMinutes]);
|
const result0 = await db.query(q, [body.task_id, totalMinutes]);
|
||||||
const [data0] = result0.rows;
|
const [taskData] = result0.rows;
|
||||||
|
|
||||||
|
const projectId = taskData.project_id;
|
||||||
|
const parentTaskId = taskData.parent_task_id;
|
||||||
|
|
||||||
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 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;
|
const [dd] = result.rows;
|
||||||
@@ -31,6 +100,22 @@ export async function on_time_estimation_change(_io: Server, socket: Socket, dat
|
|||||||
total_minutes_spent: dd.total_minutes_spent || 0
|
total_minutes_spent: dd.total_minutes_spent || 0
|
||||||
};
|
};
|
||||||
socket.emit(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), TasksController.updateTaskViewModel(d));
|
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);
|
notifyProjectUpdates(socket, d.id);
|
||||||
|
|
||||||
logTotalMinutes({
|
logTotalMinutes({
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
worklenz-backend/src/socket.io/commands/on-update-task-weight.ts
Normal file
107
worklenz-backend/src/socket.io/commands/on-update-task-weight.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,4 +57,17 @@ export enum SocketEvents {
|
|||||||
TASK_ASSIGNEES_CHANGE,
|
TASK_ASSIGNEES_CHANGE,
|
||||||
TASK_CUSTOM_COLUMN_UPDATE,
|
TASK_CUSTOM_COLUMN_UPDATE,
|
||||||
CUSTOM_COLUMN_PINNED_CHANGE,
|
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ 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_assignees_change } from "./commands/on-task-assignees-change";
|
||||||
import { on_task_custom_column_update } from "./commands/on_custom_column_update";
|
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_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) {
|
export function register(io: any, socket: Socket) {
|
||||||
log(socket.id, "client registered");
|
log(socket.id, "client registered");
|
||||||
@@ -69,7 +73,6 @@ 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_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.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.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_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_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));
|
socket.on(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), data => on_task_sort_order_change(io, socket, data));
|
||||||
@@ -106,6 +109,10 @@ 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_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.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.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.io built-in event
|
||||||
socket.on("disconnect", (reason) => on_disconnect(io, socket, reason));
|
socket.on("disconnect", (reason) => on_disconnect(io, socket, reason));
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
node_modules
|
|
||||||
npm-debug.log
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.dockerignore
|
|
||||||
README.md
|
|
||||||
@@ -12,7 +12,7 @@ COPY . .
|
|||||||
RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \
|
RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \
|
||||||
echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js
|
echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js
|
||||||
|
|
||||||
RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS production
|
FROM node:22-alpine AS production
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ Worklenz is a project management application built with React, TypeScript, and A
|
|||||||
- [Project Structure](#project-structure)
|
- [Project Structure](#project-structure)
|
||||||
- [Contributing](#contributing)
|
- [Contributing](#contributing)
|
||||||
- [Learn More](#learn-more)
|
- [Learn More](#learn-more)
|
||||||
- [License](#license)
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -94,7 +93,3 @@ To learn more about the technologies used in this project:
|
|||||||
- [TypeScript Documentation](https://www.typescriptlang.org/docs/)
|
- [TypeScript Documentation](https://www.typescriptlang.org/docs/)
|
||||||
- [Ant Design Documentation](https://ant.design/docs/react/introduce)
|
- [Ant Design Documentation](https://ant.design/docs/react/introduce)
|
||||||
- [Vite Documentation](https://vitejs.dev/guide/)
|
- [Vite Documentation](https://vitejs.dev/guide/)
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Worklenz is open source and released under the [GNU Affero General Public License Version 3 (AGPLv3)](LICENSE).
|
|
||||||
|
|||||||
@@ -1,99 +1,53 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<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);
|
|
||||||
|
|
||||||
// Initialize Google Analytics
|
<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>
|
||||||
|
<!-- 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() {
|
||||||
window.dataLayer = window.dataLayer || [];
|
window.dataLayer = window.dataLayer || [];
|
||||||
function gtag(){dataLayer.push(arguments);}
|
function gtag(){dataLayer.push(arguments);}
|
||||||
gtag('js', new Date());
|
gtag('js', new Date());
|
||||||
gtag('config', trackingId);
|
gtag('config', 'G-7KSRKQ1397');
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
|
||||||
// Initialize analytics
|
<body>
|
||||||
initGoogleAnalytics();
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./src/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
// Function to show privacy notice
|
</html>
|
||||||
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>
|
|
||||||
168
worklenz-frontend/package-lock.json
generated
168
worklenz-frontend/package-lock.json
generated
@@ -22,12 +22,12 @@
|
|||||||
"@tanstack/react-table": "^8.20.6",
|
"@tanstack/react-table": "^8.20.6",
|
||||||
"@tanstack/react-virtual": "^3.11.2",
|
"@tanstack/react-virtual": "^3.11.2",
|
||||||
"@tinymce/tinymce-react": "^5.1.1",
|
"@tinymce/tinymce-react": "^5.1.1",
|
||||||
"antd": "^5.24.1",
|
"antd": "^5.24.9",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.9.0",
|
||||||
"chart.js": "^4.4.7",
|
"chart.js": "^4.4.7",
|
||||||
"chartjs-plugin-datalabels": "^2.2.0",
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.5",
|
||||||
"gantt-task-react": "^0.3.9",
|
"gantt-task-react": "^0.3.9",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"i18next": "^23.16.8",
|
"i18next": "^23.16.8",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"terser": "^5.39.0",
|
"terser": "^5.39.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.2.5",
|
"vite": "^6.3.5",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^3.0.5"
|
"vitest": "^3.0.5"
|
||||||
}
|
}
|
||||||
@@ -2665,9 +2665,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/antd": {
|
"node_modules/antd": {
|
||||||
"version": "5.24.6",
|
"version": "5.24.9",
|
||||||
"resolved": "https://registry.npmjs.org/antd/-/antd-5.24.6.tgz",
|
"resolved": "https://registry.npmjs.org/antd/-/antd-5.24.9.tgz",
|
||||||
"integrity": "sha512-xIlTa/1CTbgkZsdU/dOXkYvJXb9VoiMwsaCzpKFH2zAEY3xqOfwQ57/DdG7lAdrWP7QORtSld4UA6suxzuTHXw==",
|
"integrity": "sha512-liB+Y/JwD5/KSKbK1Z1EVAbWcoWYvWJ1s97AbbT+mOdigpJQuWwH7kG8IXNEljI7onvj0DdD43TXhSRLUu9AMA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/colors": "^7.2.0",
|
"@ant-design/colors": "^7.2.0",
|
||||||
@@ -2692,13 +2692,13 @@
|
|||||||
"rc-drawer": "~7.2.0",
|
"rc-drawer": "~7.2.0",
|
||||||
"rc-dropdown": "~4.2.1",
|
"rc-dropdown": "~4.2.1",
|
||||||
"rc-field-form": "~2.7.0",
|
"rc-field-form": "~2.7.0",
|
||||||
"rc-image": "~7.11.1",
|
"rc-image": "~7.12.0",
|
||||||
"rc-input": "~1.7.3",
|
"rc-input": "~1.8.0",
|
||||||
"rc-input-number": "~9.4.0",
|
"rc-input-number": "~9.5.0",
|
||||||
"rc-mentions": "~2.19.1",
|
"rc-mentions": "~2.20.0",
|
||||||
"rc-menu": "~9.16.1",
|
"rc-menu": "~9.16.1",
|
||||||
"rc-motion": "^2.9.5",
|
"rc-motion": "^2.9.5",
|
||||||
"rc-notification": "~5.6.3",
|
"rc-notification": "~5.6.4",
|
||||||
"rc-pagination": "~5.1.0",
|
"rc-pagination": "~5.1.0",
|
||||||
"rc-picker": "~4.11.3",
|
"rc-picker": "~4.11.3",
|
||||||
"rc-progress": "~4.0.0",
|
"rc-progress": "~4.0.0",
|
||||||
@@ -2710,8 +2710,8 @@
|
|||||||
"rc-steps": "~6.0.1",
|
"rc-steps": "~6.0.1",
|
||||||
"rc-switch": "~4.1.0",
|
"rc-switch": "~4.1.0",
|
||||||
"rc-table": "~7.50.4",
|
"rc-table": "~7.50.4",
|
||||||
"rc-tabs": "~15.5.1",
|
"rc-tabs": "~15.6.1",
|
||||||
"rc-textarea": "~1.9.0",
|
"rc-textarea": "~1.10.0",
|
||||||
"rc-tooltip": "~6.4.0",
|
"rc-tooltip": "~6.4.0",
|
||||||
"rc-tree": "~5.13.1",
|
"rc-tree": "~5.13.1",
|
||||||
"rc-tree-select": "~5.27.0",
|
"rc-tree-select": "~5.27.0",
|
||||||
@@ -2839,9 +2839,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.8.4",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
|
||||||
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
|
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
@@ -5657,9 +5657,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rc-image": {
|
"node_modules/rc-image": {
|
||||||
"version": "7.11.1",
|
"version": "7.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz",
|
||||||
"integrity": "sha512-XuoWx4KUXg7hNy5mRTy1i8c8p3K8boWg6UajbHpDXS5AlRVucNfTi5YxTtPBTBzegxAZpvuLfh3emXFt6ybUdA==",
|
"integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.11.2",
|
"@babel/runtime": "^7.11.2",
|
||||||
@@ -5675,9 +5675,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rc-input": {
|
"node_modules/rc-input": {
|
||||||
"version": "1.7.3",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz",
|
||||||
"integrity": "sha512-A5w4egJq8+4JzlQ55FfQjDnPvOaAbzwC3VLOAdOytyek3TboSOP9qxN+Gifup+shVXfvecBLBbWBpWxmk02SWQ==",
|
"integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.11.1",
|
"@babel/runtime": "^7.11.1",
|
||||||
@@ -5690,15 +5690,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rc-input-number": {
|
"node_modules/rc-input-number": {
|
||||||
"version": "9.4.0",
|
"version": "9.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz",
|
||||||
"integrity": "sha512-Tiy4DcXcFXAf9wDhN8aUAyMeCLHJUHA/VA/t7Hj8ZEx5ETvxG7MArDOSE6psbiSCo+vJPm4E3fGN710ITVn6GA==",
|
"integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.10.1",
|
"@babel/runtime": "^7.10.1",
|
||||||
"@rc-component/mini-decimal": "^1.0.1",
|
"@rc-component/mini-decimal": "^1.0.1",
|
||||||
"classnames": "^2.2.5",
|
"classnames": "^2.2.5",
|
||||||
"rc-input": "~1.7.1",
|
"rc-input": "~1.8.0",
|
||||||
"rc-util": "^5.40.1"
|
"rc-util": "^5.40.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -5707,17 +5707,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rc-mentions": {
|
"node_modules/rc-mentions": {
|
||||||
"version": "2.19.1",
|
"version": "2.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz",
|
||||||
"integrity": "sha512-KK3bAc/bPFI993J3necmaMXD2reZTzytZdlTvkeBbp50IGH1BDPDvxLdHDUrpQx2b2TGaVJsn+86BvYa03kGqA==",
|
"integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.22.5",
|
"@babel/runtime": "^7.22.5",
|
||||||
"@rc-component/trigger": "^2.0.0",
|
"@rc-component/trigger": "^2.0.0",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"rc-input": "~1.7.1",
|
"rc-input": "~1.8.0",
|
||||||
"rc-menu": "~9.16.0",
|
"rc-menu": "~9.16.0",
|
||||||
"rc-textarea": "~1.9.0",
|
"rc-textarea": "~1.10.0",
|
||||||
"rc-util": "^5.34.1"
|
"rc-util": "^5.34.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -5759,9 +5759,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rc-notification": {
|
"node_modules/rc-notification": {
|
||||||
"version": "5.6.3",
|
"version": "5.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz",
|
||||||
"integrity": "sha512-42szwnn8VYQoT6GnjO00i1iwqV9D1TTMvxObWsuLwgl0TsOokzhkYiufdtQBsJMFjJravS1hfDKVMHLKLcPE4g==",
|
"integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.10.1",
|
"@babel/runtime": "^7.10.1",
|
||||||
@@ -6007,9 +6007,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rc-tabs": {
|
"node_modules/rc-tabs": {
|
||||||
"version": "15.5.2",
|
"version": "15.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.6.1.tgz",
|
||||||
"integrity": "sha512-Hbqf2IV6k/jPgfMjPtIDmPV0D0C9c/fN4B/fYcoh9qqaUzUZQoK0PYzsV3UaV+3UsmyoYt48p74m/HkLhGTw+w==",
|
"integrity": "sha512-/HzDV1VqOsUWyuC0c6AkxVYFjvx9+rFPKZ32ejxX0Uc7QCzcEjTA9/xMgv4HemPKwzBNX8KhGVbbumDjnj92aA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.11.2",
|
"@babel/runtime": "^7.11.2",
|
||||||
@@ -6029,14 +6029,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rc-textarea": {
|
"node_modules/rc-textarea": {
|
||||||
"version": "1.9.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.0.tgz",
|
||||||
"integrity": "sha512-dQW/Bc/MriPBTugj2Kx9PMS5eXCCGn2cxoIaichjbNvOiARlaHdI99j4DTxLl/V8+PIfW06uFy7kjfUIDDKyxQ==",
|
"integrity": "sha512-ai9IkanNuyBS4x6sOL8qu/Ld40e6cEs6pgk93R+XLYg0mDSjNBGey6/ZpDs5+gNLD7urQ14po3V6Ck2dJLt9SA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.10.1",
|
"@babel/runtime": "^7.10.1",
|
||||||
"classnames": "^2.2.1",
|
"classnames": "^2.2.1",
|
||||||
"rc-input": "~1.7.1",
|
"rc-input": "~1.8.0",
|
||||||
"rc-resize-observer": "^1.0.0",
|
"rc-resize-observer": "^1.0.0",
|
||||||
"rc-util": "^5.27.0"
|
"rc-util": "^5.27.0"
|
||||||
},
|
},
|
||||||
@@ -7126,6 +7126,51 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyglobby": {
|
||||||
|
"version": "0.2.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
||||||
|
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fdir": "^6.4.4",
|
||||||
|
"picomatch": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyglobby/node_modules/fdir": {
|
||||||
|
"version": "6.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
|
||||||
|
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"picomatch": "^3 || ^4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"picomatch": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinymce": {
|
"node_modules/tinymce": {
|
||||||
"version": "7.7.2",
|
"version": "7.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-7.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-7.7.2.tgz",
|
||||||
@@ -7299,15 +7344,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.2.5",
|
"version": "6.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||||
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
|
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
|
"fdir": "^6.4.4",
|
||||||
|
"picomatch": "^4.0.2",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"rollup": "^4.30.1"
|
"rollup": "^4.34.9",
|
||||||
|
"tinyglobby": "^0.2.13"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
@@ -7413,6 +7461,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite/node_modules/fdir": {
|
||||||
|
"version": "6.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
|
||||||
|
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"picomatch": "^3 || ^4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"picomatch": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite/node_modules/picomatch": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz",
|
||||||
|
|||||||
@@ -25,12 +25,12 @@
|
|||||||
"@tanstack/react-table": "^8.20.6",
|
"@tanstack/react-table": "^8.20.6",
|
||||||
"@tanstack/react-virtual": "^3.11.2",
|
"@tanstack/react-virtual": "^3.11.2",
|
||||||
"@tinymce/tinymce-react": "^5.1.1",
|
"@tinymce/tinymce-react": "^5.1.1",
|
||||||
"antd": "^5.24.1",
|
"antd": "^5.24.9",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.9.0",
|
||||||
"chart.js": "^4.4.7",
|
"chart.js": "^4.4.7",
|
||||||
"chartjs-plugin-datalabels": "^2.2.0",
|
"chartjs-plugin-datalabels": "^2.2.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.5",
|
||||||
"gantt-task-react": "^0.3.9",
|
"gantt-task-react": "^0.3.9",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"i18next": "^23.16.8",
|
"i18next": "^23.16.8",
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"terser": "^5.39.0",
|
"terser": "^5.39.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.2.5",
|
"vite": "^6.3.5",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^3.0.5"
|
"vitest": "^3.0.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"doesNotExistText": "Na vjen keq, faqja që kërkoni nuk ekziston.",
|
|
||||||
"backHomeButton": "Kthehu në Faqen Kryesore"
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"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)"
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
{
|
|
||||||
"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}}"
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"overview": "Përmbledhje",
|
|
||||||
"users": "Përdoruesit",
|
|
||||||
"teams": "Ekipet",
|
|
||||||
"billing": "Faturimi",
|
|
||||||
"projects": "Projektet",
|
|
||||||
"adminCenter": "Qendra Administrative"
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Përdoruesit",
|
|
||||||
"subTitle": "përdoruesit",
|
|
||||||
"placeholder": "Kërko sipas emrit",
|
|
||||||
"user": "Përdoruesi",
|
|
||||||
"email": "Email",
|
|
||||||
"lastActivity": "Aktiviteti i Fundit",
|
|
||||||
"refresh": "Rifresko përdoruesit"
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Emri",
|
|
||||||
"client": "Klienti",
|
|
||||||
"category": "Kategoria",
|
|
||||||
"status": "Statusi",
|
|
||||||
"tasksProgress": "Progresi i Detyrave",
|
|
||||||
"updated_at": "Përditësuar Së Fundi",
|
|
||||||
"members": "Anëtarët",
|
|
||||||
"setting": "Cilësimet",
|
|
||||||
"projects": "Projektet",
|
|
||||||
"refreshProjects": "Rifresko projektet",
|
|
||||||
"all": "Të Gjitha",
|
|
||||||
"favorites": "Të Preferuarat",
|
|
||||||
"archived": "Të Arkivuara",
|
|
||||||
"placeholder": "Kërko sipas emrit",
|
|
||||||
"archive": "Arkivo",
|
|
||||||
"unarchive": "Ç'arkivo",
|
|
||||||
"archiveConfirm": "Jeni i sigurt që doni ta arkivoni këtë projekt?",
|
|
||||||
"unarchiveConfirm": "Jeni i sigurt që doni ta çarkivoni këtë projekt?",
|
|
||||||
"clickToFilter": "Klikoni për të filtruar sipas",
|
|
||||||
"noProjects": "Nuk u gjetën projekte",
|
|
||||||
"addToFavourites": "Shto në të preferuarat"
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"loggingOut": "Po dilni...",
|
|
||||||
"authenticating": "Po autentikoheni...",
|
|
||||||
"gettingThingsReady": "Po përgatiten gjërat për ju..."
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"headerDescription": "Rivendosni fjalëkalimin tuaj",
|
|
||||||
"emailLabel": "Email",
|
|
||||||
"emailPlaceholder": "Vendosni email-in tuaj",
|
|
||||||
"emailRequired": "Ju lutemi vendosni Email-in tuaj!",
|
|
||||||
"resetPasswordButton": "Rivendos Fjalëkalimin",
|
|
||||||
"returnToLoginButton": "Kthehu te Hyrja",
|
|
||||||
"passwordResetSuccessMessage": "Një lidhje për rivendosjen e fjalëkalimit është dërguar në email-in tuaj.",
|
|
||||||
"orText": "OSE",
|
|
||||||
"successTitle": "U dërguan udhëzimet për rivendosje!",
|
|
||||||
"successMessage": "Informacioni për rivendosje është dërguar në email-in tuaj. Ju lutemi kontrolloni email-in."
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"headerDescription": "Hyni në llogarinë tuaj",
|
|
||||||
"emailLabel": "Email",
|
|
||||||
"emailPlaceholder": "Vendosni email-in tuaj",
|
|
||||||
"emailRequired": "Ju lutemi vendosni Email-in tuaj!",
|
|
||||||
"passwordLabel": "Fjalëkalimi",
|
|
||||||
"passwordPlaceholder": "Vendosni fjalëkalimin",
|
|
||||||
"passwordRequired": "Ju lutemi vendosni Fjalëkalimin!",
|
|
||||||
"rememberMe": "Më mbaj mend",
|
|
||||||
"loginButton": "Hyr",
|
|
||||||
"signupButton": "Regjistrohu",
|
|
||||||
"forgotPasswordButton": "Keni harruar fjalëkalimin?",
|
|
||||||
"signInWithGoogleButton": "Hyr me Google",
|
|
||||||
"dontHaveAccountText": "Nuk keni llogari?",
|
|
||||||
"orText": "OSE",
|
|
||||||
"successMessage": "Jeni futur me sukses!",
|
|
||||||
"loginError": "Hyrja dështoi",
|
|
||||||
"googleLoginError": "Hyrja përmes Google dështoi",
|
|
||||||
"validationMessages": {
|
|
||||||
"email": "Ju lutemi vendosni një adresë email të vlefshme",
|
|
||||||
"password": "Fjalëkalimi duhet të jetë së paku 8 karaktere"
|
|
||||||
},
|
|
||||||
"errorMessages": {
|
|
||||||
"loginErrorTitle": "Hyrja dështoi",
|
|
||||||
"loginErrorMessage": "Ju lutemi kontrolloni email-in dhe fjalëkalimin dhe provoni përsëri"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"headerDescription": "Regjistrohuni për të filluar",
|
|
||||||
"nameLabel": "Emri i Plotë",
|
|
||||||
"namePlaceholder": "Shkruani emrin tuaj të plotë",
|
|
||||||
"nameRequired": "Ju lutemi shkruani emrin tuaj të plotë!",
|
|
||||||
"nameMinCharacterRequired": "Emri duhet të jetë së paku 4 karaktere!",
|
|
||||||
"emailLabel": "Email",
|
|
||||||
"emailPlaceholder": "Shkruani email-in tuaj",
|
|
||||||
"emailRequired": "Ju lutemi shkruani Email-in tuaj!",
|
|
||||||
"passwordLabel": "Fjalëkalimi",
|
|
||||||
"passwordPlaceholder": "Krijoni një fjalëkalim",
|
|
||||||
"passwordRequired": "Ju lutemi krijoni një Fjalëkalim!",
|
|
||||||
"passwordMinCharacterRequired": "Fjalëkalimi duhet të jetë së paku 8 karaktere!",
|
|
||||||
"passwordPatternRequired": "Fjalëkalimi nuk plotëson kërkesat!",
|
|
||||||
"strongPasswordPlaceholder": "Vendosni një fjalëkalim më të fortë",
|
|
||||||
"passwordValidationAltText": "Fjalëkalimi duhet të përmbajë së paku 8 karaktere me shkronja të mëdha dhe të vogla, një numër dhe një simbol.",
|
|
||||||
"signupSuccessMessage": "Jeni regjistruar me sukses!",
|
|
||||||
"privacyPolicyLink": "Politika e Privatësisë",
|
|
||||||
"termsOfUseLink": "Kushtet e Përdorimit",
|
|
||||||
"bySigningUpText": "Duke u regjistruar, ju pranoni",
|
|
||||||
"andText": "dhe",
|
|
||||||
"signupButton": "Regjistrohu",
|
|
||||||
"signInWithGoogleButton": "Hyr me Google",
|
|
||||||
"alreadyHaveAccountText": "Keni tashmë një llogari?",
|
|
||||||
"loginButton": "Hyr",
|
|
||||||
"orText": "OSE",
|
|
||||||
"reCAPTCHAVerificationError": "Gabim në Verifikimin e reCAPTCHA",
|
|
||||||
"reCAPTCHAVerificationErrorMessage": "Nuk mundëm të verifikojmë reCAPTCHA-n tuaj. Ju lutemi provoni përsëri."
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Verifikoni Email-in për Rivendosje",
|
|
||||||
"description": "Vendosni fjalëkalimin tuaj të ri",
|
|
||||||
"placeholder": "Vendosni fjalëkalimin tuaj të ri",
|
|
||||||
"confirmPasswordPlaceholder": "Konfirmoni fjalëkalimin e ri",
|
|
||||||
"passwordHint": "Të paktën 8 karaktere, me shkronja të mëdha dhe të vogla, një numër dhe një simbol.",
|
|
||||||
"resetPasswordButton": "Rivendos fjalëkalimin",
|
|
||||||
"orText": "Ose",
|
|
||||||
"resendResetEmail": "Dërgo përsëri email-in e rivendosjes",
|
|
||||||
"passwordRequired": "Ju lutemi vendosni fjalëkalimin e ri",
|
|
||||||
"returnToLoginButton": "Kthehu te Hyrja",
|
|
||||||
"confirmPasswordRequired": "Ju lutemi konfirmoni fjalëkalimin e ri",
|
|
||||||
"passwordMismatch": "Fjalëkalimet nuk përputhen"
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"login-success": "Hyrja u krye me sukses!",
|
|
||||||
"login-failed": "Hyrja dështoi. Ju lutemi kontrolloni kredencialet dhe provoni përsëri.",
|
|
||||||
"signup-success": "Regjistrimi u krye me sukses! Mirë se erdhët.",
|
|
||||||
"signup-failed": "Regjistrimi dështoi. Ju lutemi sigurohuni që të gjitha fushat e nevojshme janë plotësuar dhe provoni përsëri.",
|
|
||||||
"reconnecting": "Jeni shkëputur nga serveri.",
|
|
||||||
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
|
|
||||||
"connection-restored": "U lidhët me serverin me sukses"
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"formTitle": "Krijoni projektin tuaj të parë",
|
|
||||||
"inputLabel": "Në cilin projekt po punoni aktualisht?",
|
|
||||||
"or": "ose",
|
|
||||||
"templateButton": "Importo nga shablloni",
|
|
||||||
"createFromTemplate": "Krijo nga shablloni",
|
|
||||||
"goBack": "Kthehu Mbrapa",
|
|
||||||
"continue": "Vazhdo",
|
|
||||||
"cancel": "Anulo",
|
|
||||||
"create": "Krijo",
|
|
||||||
"templateDrawerTitle": "Zgjidh nga shabllonet",
|
|
||||||
"createProject": "Krijo Projekt"
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"formTitle": "Krijo detyrën tënde të parë.",
|
|
||||||
"inputLabel": "Shkruaj disa detyra që do të kryesh në",
|
|
||||||
"addAnother": "Shto një tjetër",
|
|
||||||
"goBack": "Kthehu mbrapa",
|
|
||||||
"continue": "Vazhdo"
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"todoList": {
|
|
||||||
"title": "Lista e Detyrave",
|
|
||||||
"refreshTasks": "Rifresko detyrat",
|
|
||||||
"addTask": "+ Shto Detyrë",
|
|
||||||
"noTasks": "Asnjë detyrë",
|
|
||||||
"pressEnter": "Shtyp",
|
|
||||||
"toCreate": "për të krijuar.",
|
|
||||||
"markAsDone": "Shëno si të përfunduar"
|
|
||||||
},
|
|
||||||
"projects": {
|
|
||||||
"title": "Projektet",
|
|
||||||
"refreshProjects": "Rifresko projektet",
|
|
||||||
"noRecentProjects": "Aktualisht nuk jeni caktuar në asnjë projekt.",
|
|
||||||
"noFavouriteProjects": "Asnjë projekt i shënuar si i preferuar.",
|
|
||||||
"recent": "Të Fundit",
|
|
||||||
"favourites": "Të Preferuarat"
|
|
||||||
},
|
|
||||||
"tasks": {
|
|
||||||
"assignedToMe": "Më janë caktuar",
|
|
||||||
"assignedByMe": "I kam caktuar",
|
|
||||||
"all": "Të Gjitha",
|
|
||||||
"today": "Sot",
|
|
||||||
"upcoming": "Ardhj",
|
|
||||||
"overdue": "Të vonuara",
|
|
||||||
"noDueDate": "Pa afat",
|
|
||||||
"noTasks": "Asnjë detyrë për të shfaqur.",
|
|
||||||
"addTask": "+ Shto detyrë",
|
|
||||||
"name": "Emri",
|
|
||||||
"project": "Projekti",
|
|
||||||
"status": "Statusi",
|
|
||||||
"dueDate": "Afati",
|
|
||||||
"dueDatePlaceholder": "Cakto Afatin",
|
|
||||||
"tomorrow": "Nesër",
|
|
||||||
"nextWeek": "Javën e Ardhshme",
|
|
||||||
"nextMonth": "Muajin e Ardhshëm",
|
|
||||||
"projectRequired": "Ju lutemi zgjidhni një projekt",
|
|
||||||
"pressTabToSelectDueDateAndProject": "Shtyp Tab për të zgjedhur afatin dhe projektin",
|
|
||||||
"dueOn": "Detyrat me afat më",
|
|
||||||
"taskRequired": "Ju lutemi shtoni një detyrë",
|
|
||||||
"list": "Listë",
|
|
||||||
"calendar": "Kalendar",
|
|
||||||
"tasks": "Detyrat",
|
|
||||||
"refresh": "Rifresko"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"formTitle": "Fto ekipin tënd të punojë me",
|
|
||||||
"inputLabel": "Fto me email",
|
|
||||||
"addAnother": "Shto një tjetër",
|
|
||||||
"goBack": "Kthehu mbrapa",
|
|
||||||
"continue": "Vazhdo",
|
|
||||||
"skipForNow": "Anashkalo tani për tani"
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"rename": "Riemërto",
|
|
||||||
"delete": "Fshi",
|
|
||||||
"addTask": "Shto Detyrë",
|
|
||||||
"addSectionButton": "Shto Seksion",
|
|
||||||
"changeCategory": "Ndrysho kategorinë",
|
|
||||||
|
|
||||||
"deleteTooltip": "Fshi",
|
|
||||||
"deleteConfirmationTitle": "Jeni i sigurt?",
|
|
||||||
"deleteConfirmationOk": "Po",
|
|
||||||
"deleteConfirmationCancel": "Anulo",
|
|
||||||
|
|
||||||
"dueDate": "Data e përfundimit",
|
|
||||||
"cancel": "Anulo",
|
|
||||||
|
|
||||||
"today": "Sot",
|
|
||||||
"tomorrow": "Nesër",
|
|
||||||
"assignToMe": "Cakto mua",
|
|
||||||
"archive": "Arkivo",
|
|
||||||
|
|
||||||
"newTaskNamePlaceholder": "Shkruaj emrin e detyrës",
|
|
||||||
"newSubtaskNamePlaceholder": "Shkruaj emrin e nëndetyrës"
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Prova juaj e Worklenz ka skaduar!",
|
|
||||||
"subtitle": "Ju lutemi përmirësoni tani.",
|
|
||||||
"button": "Përmirëso tani",
|
|
||||||
"checking": "Po kontrollohet statusi i abonimit..."
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"logoAlt": "Logoja e Worklenz",
|
|
||||||
"home": "Kryefaqja",
|
|
||||||
"projects": "Projektet",
|
|
||||||
"schedule": "Orari",
|
|
||||||
"reporting": "Raportimi",
|
|
||||||
"clients": "Klientët",
|
|
||||||
"teams": "Ekipet",
|
|
||||||
"labels": "Etiketa",
|
|
||||||
"jobTitles": "Tituj Pune",
|
|
||||||
"upgradePlan": "Përmirëso Abonimin",
|
|
||||||
"upgradePlanTooltip": "Përmirëso abonimin",
|
|
||||||
"invite": "Fto",
|
|
||||||
"inviteTooltip": "Fto anëtarë të ekipit të bashkohen",
|
|
||||||
"switchTeamTooltip": "Ndrysho ekipin",
|
|
||||||
"help": "Ndihmë",
|
|
||||||
"notificationTooltip": "Shiko njoftimet",
|
|
||||||
"profileTooltip": "Shiko profilin",
|
|
||||||
"adminCenter": "Qendra Administrative",
|
|
||||||
"settings": "Cilësimet",
|
|
||||||
"logOut": "Dil",
|
|
||||||
"notificationsDrawer": {
|
|
||||||
"read": "Lexuara e njoftimet ",
|
|
||||||
"unread": "Njoftimet e palexuara",
|
|
||||||
"markAsRead": "Shëno si të lexuara",
|
|
||||||
"readAndJoin": "Lexo & Bashkohu",
|
|
||||||
"accept": "Prano",
|
|
||||||
"acceptAndJoin": "Prano & Bashkohu",
|
|
||||||
"noNotifications": "Asnjë njoftim"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"nameYourOrganization": "Emërtoni organizatën tuaj.",
|
|
||||||
"worklenzAccountTitle": "Zgjidhni një emër për llogarinë tuaj në Worklenz.",
|
|
||||||
"continue": "Vazhdo"
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"configurePhases": "Konfiguro Fazat",
|
|
||||||
"phaseLabel": "Etiketa e Fazës",
|
|
||||||
"enterPhaseName": "Vendosni një emër për etiketën e fazës",
|
|
||||||
"addOption": "Shto Opsion",
|
|
||||||
"phaseOptions": "Opsionet e Fazës:"
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"createProject": "Krijo Projekt",
|
|
||||||
"editProject": "Modifiko Projektin",
|
|
||||||
"enterCategoryName": "Vendosni emër për kategorinë",
|
|
||||||
"hitEnterToCreate": "Shtyp Enter për të krijuar!",
|
|
||||||
"enterNotes": "Shënime",
|
|
||||||
"youCanManageClientsUnderSettings": "Mund të menaxhoni klientët nën Cilësimet",
|
|
||||||
"addCategory": "Shto kategori projektit",
|
|
||||||
"newCategory": "Kategori e Re",
|
|
||||||
"notes": "Shënime",
|
|
||||||
"startDate": "Data e Fillimit",
|
|
||||||
"endDate": "Data e Përfundimit",
|
|
||||||
"estimateWorkingDays": "Vlerëso ditët e punës",
|
|
||||||
"estimateManDays": "Vlerëso ditët e punëtorëve",
|
|
||||||
"hoursPerDay": "Orë në ditë",
|
|
||||||
"create": "Krijo",
|
|
||||||
"update": "Përditëso",
|
|
||||||
"delete": "Fshi",
|
|
||||||
"typeToSearchClients": "Shkruani për të kërkuar klientë",
|
|
||||||
"projectColor": "Ngjyra e Projektit",
|
|
||||||
"pleaseEnterAName": "Ju lutemi vendosni një emër",
|
|
||||||
"enterProjectName": "Vendosni emrin e projektit",
|
|
||||||
"name": "Emri",
|
|
||||||
"status": "Statusi",
|
|
||||||
"health": "Gjendja",
|
|
||||||
"category": "Kategoria",
|
|
||||||
"projectManager": "Menaxheri i Projektit",
|
|
||||||
"client": "Klienti",
|
|
||||||
"deleteConfirmation": "Jeni i sigurt që doni të fshini?",
|
|
||||||
"deleteConfirmationDescription": "Kjo do të fshijë të gjitha të dhënat e lidhura dhe nuk mund të zhbëhet.",
|
|
||||||
"yes": "Po",
|
|
||||||
"no": "Jo",
|
|
||||||
"createdAt": "Krijuar më",
|
|
||||||
"updatedAt": "Përditësuar më",
|
|
||||||
"by": "nga",
|
|
||||||
"add": "Shto",
|
|
||||||
"asClient": "si klient",
|
|
||||||
"createClient": "Krijo klient",
|
|
||||||
"searchInputPlaceholder": "Kërko sipas emrit ose emailit",
|
|
||||||
"hoursPerDayValidationMessage": "Orët në ditë duhet të jenë një numër midis 1 dhe 24",
|
|
||||||
"noPermission": "Nuk ka leje"
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"nameColumn": "Emri",
|
|
||||||
"attachedTaskColumn": "Detyra e Bashkangjitur",
|
|
||||||
"sizeColumn": "Madhësia",
|
|
||||||
"uploadedByColumn": "Ngarkuar Nga",
|
|
||||||
"uploadedAtColumn": "Ngarkuar Më",
|
|
||||||
"fileIconAlt": "Ikona e skedarit",
|
|
||||||
"titleDescriptionText": "Të gjitha bashkëngjitjet e detyrave në këtë projekt do të shfahen këtu.",
|
|
||||||
"deleteConfirmationTitle": "Jeni i sigurt?",
|
|
||||||
"deleteConfirmationOk": "Po",
|
|
||||||
"deleteConfirmationCancel": "Anulo",
|
|
||||||
"segmentedTooltip": "Së shpejti! Kaloni midis pamjes listë dhe pamjes miniaturash.",
|
|
||||||
"emptyText": "Nuk ka bashkëngjitje në projekt."
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"overview": {
|
|
||||||
"title": "Përmbledhje",
|
|
||||||
"statusOverview": "Përmbledhje Statusi",
|
|
||||||
"priorityOverview": "Përmbledhje Prioriteti",
|
|
||||||
"lastUpdatedTasks": "Detyrat e Përditësuara Së Fundi"
|
|
||||||
},
|
|
||||||
"members": {
|
|
||||||
"title": "Anëtarët",
|
|
||||||
"tooltip": "Anëtarët",
|
|
||||||
"tasksByMembers": "Detyrat sipas anëtarëve",
|
|
||||||
"tasksByMembersTooltip": "Detyrat sipas anëtarëve",
|
|
||||||
"name": "Emri",
|
|
||||||
"taskCount": "Numri i Detyrave",
|
|
||||||
"contribution": "Kontributi",
|
|
||||||
"completed": "Të Përfunduara",
|
|
||||||
"incomplete": "Të Papërfunduara",
|
|
||||||
"overdue": "Të Vonuara",
|
|
||||||
"progress": "Progresi"
|
|
||||||
},
|
|
||||||
"tasks": {
|
|
||||||
"overdueTasks": "Detyrat e Vonuara",
|
|
||||||
"overLoggedTasks": "Detyrat me regjistrim të tepërt",
|
|
||||||
"tasksCompletedEarly": "Detyrat e përfunduara para afatit",
|
|
||||||
"tasksCompletedLate": "Detyrat e përfunduara pas afatit",
|
|
||||||
"overLoggedTasksTooltip": "Detyrat me kohë të regjistruar mbi kohën e vlerësuar",
|
|
||||||
"overdueTasksTooltip": "Detyrat që kanë kaluar afatin e tyre"
|
|
||||||
},
|
|
||||||
"common": {
|
|
||||||
"seeAll": "Shiko të gjitha",
|
|
||||||
"totalLoggedHours": "Orët totale të regjistruara",
|
|
||||||
"totalEstimation": "Vlerësimi total",
|
|
||||||
"completedTasks": "Detyrat e përfunduara",
|
|
||||||
"incompleteTasks": "Detyrat e papërfunduara",
|
|
||||||
"overdueTasks": "Detyrat e vonuara",
|
|
||||||
"overdueTasksTooltip": "Detyrat që kanë kaluar afatin e tyre",
|
|
||||||
"totalLoggedHoursTooltip": "Vlerësimi dhe koha e regjistruar për detyrat.",
|
|
||||||
"includeArchivedTasks": "Përfshi Detyrat e Arkivuara",
|
|
||||||
"export": "Eksporto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"nameColumn": "Emri",
|
|
||||||
"jobTitleColumn": "Titulli i Punës",
|
|
||||||
"emailColumn": "Email",
|
|
||||||
"tasksColumn": "Detyrat",
|
|
||||||
"taskProgressColumn": "Progresi i Detyrave",
|
|
||||||
"accessColumn": "Qasja",
|
|
||||||
"fileIconAlt": "Ikona e skedarit",
|
|
||||||
"deleteConfirmationTitle": "Jeni i sigurt?",
|
|
||||||
"deleteConfirmationOk": "Po",
|
|
||||||
"deleteConfirmationCancel": "Anulo",
|
|
||||||
"refreshButtonTooltip": "Rifresko anëtarët",
|
|
||||||
"deleteButtonTooltip": "Hiq nga projekti",
|
|
||||||
"memberCount": "Anëtar",
|
|
||||||
"membersCountPlural": "Anëtarë",
|
|
||||||
"emptyText": "Nuk ka bashkëngjitje në projekt."
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"inputPlaceholder": "Shto një koment..",
|
|
||||||
"addButton": "Shto",
|
|
||||||
"cancelButton": "Anulo",
|
|
||||||
"deleteButton": "Fshi"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"importTaskTemplate": "Importo Shabllon Detyrash",
|
|
||||||
"templateName": "Emri i Shabllonit",
|
|
||||||
"templateDescription": "Përshkrimi i Shabllonit",
|
|
||||||
"selectedTasks": "Detyrat e Përzgjedhura",
|
|
||||||
"tasks": "Detyrat",
|
|
||||||
"templates": "Shabllonet",
|
|
||||||
"remove": "Hiq",
|
|
||||||
"cancel": "Anulo",
|
|
||||||
"import": "Importo"
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Anëtarët e Projektit",
|
|
||||||
"searchLabel": "Shtoni anëtarë duke shkruar emrin ose email-in e tyre",
|
|
||||||
"searchPlaceholder": "Shkruani emrin ose email-in",
|
|
||||||
"inviteAsAMember": "Fto si anëtar",
|
|
||||||
"inviteNewMemberByEmail": "Fto anëtar të ri me email"
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"importTasks": "Importo detyra",
|
|
||||||
"createTask": "Krijo detyrë",
|
|
||||||
"settings": "Cilësimet",
|
|
||||||
"subscribe": "Abonohu",
|
|
||||||
"unsubscribe": "Ç'abonohu",
|
|
||||||
"deleteProject": "Fshi projektin",
|
|
||||||
"startDate": "Data e fillimit",
|
|
||||||
"endDate": "Data e përfundimit",
|
|
||||||
"projectSettings": "Cilësimet e projektit",
|
|
||||||
"projectSummary": "Përmbledhja e projektit",
|
|
||||||
"receiveProjectSummary": "Merrni një përmbledhje të projektit çdo mbrëmje."
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Ruaj si Shabllon",
|
|
||||||
"templateName": "Emri i Shabllonit",
|
|
||||||
"includes": "Çfarë duhet të përfshihet në shabllon nga projekti?",
|
|
||||||
"includesOptions": {
|
|
||||||
"statuses": "Statuset",
|
|
||||||
"phases": "Fazat",
|
|
||||||
"labels": "Etiketat"
|
|
||||||
},
|
|
||||||
"taskIncludes": "Çfarë duhet të përfshihet në shabllon nga detyrat?",
|
|
||||||
"taskIncludesOptions": {
|
|
||||||
"statuses": "Statuset",
|
|
||||||
"phases": "Fazat",
|
|
||||||
"labels": "Etiketat",
|
|
||||||
"name": "Emri",
|
|
||||||
"priority": "Prioriteti",
|
|
||||||
"status": "Statusi",
|
|
||||||
"phase": "Faza",
|
|
||||||
"label": "Etiketa",
|
|
||||||
"timeEstimate": "Vlerësimi i Kohës",
|
|
||||||
"description": "Përshkrimi",
|
|
||||||
"subTasks": "Nëndetyrat"
|
|
||||||
},
|
|
||||||
"cancel": "Anulo",
|
|
||||||
"save": "Ruaj",
|
|
||||||
"templateNamePlaceholder": "Shkruani emrin e shabllonit"
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
{
|
|
||||||
"exportButton": "Eksporto",
|
|
||||||
"timeLogsButton": "Regjistrimet e Kohës",
|
|
||||||
"activityLogsButton": "Regjistrimet e Aktivitetit",
|
|
||||||
"tasksButton": "Detyrat",
|
|
||||||
"searchByNameInputPlaceholder": "Kërko sipas emrit",
|
|
||||||
|
|
||||||
"overviewTab": "Përmbledhje",
|
|
||||||
"timeLogsTab": "Regjistrimet e Kohës",
|
|
||||||
"activityLogsTab": "Regjistrimet e Aktivitetit",
|
|
||||||
"tasksTab": "Detyrat",
|
|
||||||
|
|
||||||
"projectsText": "Projektet",
|
|
||||||
"totalTasksText": "Detyrat Gjithsej",
|
|
||||||
"assignedTasksText": "Detyrat e Caktuara",
|
|
||||||
"completedTasksText": "Detyrat e Përfunduara",
|
|
||||||
"ongoingTasksText": "Detyrat në Vazhdim",
|
|
||||||
"overdueTasksText": "Detyrat e Vonuara",
|
|
||||||
"loggedHoursText": "Orët e Regjistruara",
|
|
||||||
|
|
||||||
"tasksText": "Detyrat",
|
|
||||||
"allText": "Të Gjitha",
|
|
||||||
|
|
||||||
"tasksByProjectsText": "Detyrat Sipas Projekteve",
|
|
||||||
"tasksByStatusText": "Detyrat Sipas Statusit",
|
|
||||||
"tasksByPriorityText": "Detyrat Sipas Prioritetit",
|
|
||||||
|
|
||||||
"todoText": "Për Të Bërë",
|
|
||||||
"doingText": "Duke bërë",
|
|
||||||
"doneText": "E Përfunduar",
|
|
||||||
"lowText": "I Ulët",
|
|
||||||
"mediumText": "I Mesëm",
|
|
||||||
"highText": "I Lartë",
|
|
||||||
|
|
||||||
"billableButton": "Fakturueshme",
|
|
||||||
"billableText": "Fakturueshme",
|
|
||||||
"nonBillableText": "Jo Fakturueshme",
|
|
||||||
|
|
||||||
"timeLogsEmptyPlaceholder": "Asnjë regjistrim kohe për të shfaqur",
|
|
||||||
"loggedText": "Regjistruar",
|
|
||||||
"forText": "për",
|
|
||||||
"inText": "në",
|
|
||||||
"updatedText": "Përditësuar",
|
|
||||||
"fromText": "Nga",
|
|
||||||
"toText": "në",
|
|
||||||
"withinText": "brenda",
|
|
||||||
|
|
||||||
"activityLogsEmptyPlaceholder": "Asnjë regjistrim aktiviteti për të shfaqur",
|
|
||||||
|
|
||||||
"filterByText": "Filtro sipas:",
|
|
||||||
"selectProjectPlaceholder": "Zgjidh Projektin",
|
|
||||||
|
|
||||||
"taskColumn": "Detyra",
|
|
||||||
"nameColumn": "Emri",
|
|
||||||
"projectColumn": "Projekti",
|
|
||||||
"statusColumn": "Statusi",
|
|
||||||
"priorityColumn": "Prioriteti",
|
|
||||||
"dueDateColumn": "Afati",
|
|
||||||
"completedDateColumn": "Data e Përfundimit",
|
|
||||||
"estimatedTimeColumn": "Koha e Vlerësuar",
|
|
||||||
"loggedTimeColumn": "Koha e Regjistruar",
|
|
||||||
"overloggedTimeColumn": "Koha e Tepërt",
|
|
||||||
"daysLeftColumn": "Ditë të Mbetura/Vonuar",
|
|
||||||
"startDateColumn": "Data e Fillimit",
|
|
||||||
"endDateColumn": "Data e Përfundimit",
|
|
||||||
"actualTimeColumn": "Koha Aktuale",
|
|
||||||
"projectHealthColumn": "Gjendja e Projektit",
|
|
||||||
"categoryColumn": "Kategoria",
|
|
||||||
"projectManagerColumn": "Menaxheri i Projektit",
|
|
||||||
|
|
||||||
"tasksStatsOverviewDrawerTitle": "Detyrat e ",
|
|
||||||
"projectsStatsOverviewDrawerTitle": "Projektet e ",
|
|
||||||
|
|
||||||
"cancelledText": "Anuluar",
|
|
||||||
"blockedText": "E Bllokuar",
|
|
||||||
"onHoldText": "Në Pritje",
|
|
||||||
"proposedText": "E Propozuar",
|
|
||||||
"inPlanningText": "Në Planifikim",
|
|
||||||
"inProgressText": "Në Progres",
|
|
||||||
"completedText": "E Përfunduar",
|
|
||||||
"continuousText": "E Vazhdueshme",
|
|
||||||
|
|
||||||
"daysLeftText": "ditë të mbetura",
|
|
||||||
"daysOverdueText": "ditë vonuar",
|
|
||||||
|
|
||||||
"notSetText": "Pa Caktuar",
|
|
||||||
"needsAttentionText": "Kërkon Vëmendje",
|
|
||||||
"atRiskText": "Në Rrezik",
|
|
||||||
"goodText": "Në Rregull"
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"yesterdayText": "Dje",
|
|
||||||
"lastSevenDaysText": "7 Ditët e Fundit",
|
|
||||||
"lastWeekText": "Javën e Kaluar",
|
|
||||||
"lastThirtyDaysText": "30 Ditët e Fundit",
|
|
||||||
"lastMonthText": "Muajin e Kaluar",
|
|
||||||
"lastThreeMonthsText": "3 Muajt e Fundit",
|
|
||||||
"allTimeText": "Të Gjitha",
|
|
||||||
"customRangeText": "Interval i Përshtatur",
|
|
||||||
"startDateInputPlaceholder": "Data e fillimit",
|
|
||||||
"EndDateInputPlaceholder": "Data e përfundimit",
|
|
||||||
"filterButton": "Filtro",
|
|
||||||
|
|
||||||
"membersTitle": "Anëtarët",
|
|
||||||
"includeArchivedButton": "Përfshij Projektet e Arkivuara",
|
|
||||||
"exportButton": "Eksporto",
|
|
||||||
"excelButton": "Excel",
|
|
||||||
"searchByNameInputPlaceholder": "Kërko sipas emrit",
|
|
||||||
|
|
||||||
"memberColumn": "Anëtari",
|
|
||||||
"tasksProgressColumn": "Progresi i Detyrave",
|
|
||||||
"tasksAssignedColumn": "Detyrat e Caktuara",
|
|
||||||
"completedTasksColumn": "Detyrat e Përfunduara",
|
|
||||||
"overdueTasksColumn": "Detyrat e Vonuara",
|
|
||||||
"ongoingTasksColumn": "Detyrat në Vazhdim",
|
|
||||||
|
|
||||||
"tasksAssignedColumnTooltip": "Detyrat e caktuara në intervalin e zgjedhur",
|
|
||||||
"overdueTasksColumnTooltip": "Detyrat e vonuara deri në fund të intervalit të zgjedhur",
|
|
||||||
"completedTasksColumnTooltip": "Detyrat e përfunduara në intervalin e zgjedhur",
|
|
||||||
"ongoingTasksColumnTooltip": "Detyrat e filluara por jo të përfunduara ende",
|
|
||||||
|
|
||||||
"todoText": "Për Të Bërë",
|
|
||||||
"doingText": "Duke bërë",
|
|
||||||
"doneText": "E Përfunduar"
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"exportButton": "Eksporto",
|
|
||||||
"projectsButton": "Projektet",
|
|
||||||
"membersButton": "Anëtarët",
|
|
||||||
"searchByNameInputPlaceholder": "Kërko sipas emrit",
|
|
||||||
|
|
||||||
"overviewTab": "Përmbledhje",
|
|
||||||
"projectsTab": "Projektet",
|
|
||||||
"membersTab": "Anëtarët",
|
|
||||||
|
|
||||||
"projectsByStatusText": "Projektet Sipas Statusit",
|
|
||||||
"projectsByCategoryText": "Projektet Sipas Kategorisë",
|
|
||||||
"projectsByHealthText": "Projektet Sipas Gjendjes",
|
|
||||||
|
|
||||||
"projectsText": "Projektet",
|
|
||||||
"allText": "Të Gjitha",
|
|
||||||
|
|
||||||
"cancelledText": "Anuluar",
|
|
||||||
"blockedText": "E Bllokuar",
|
|
||||||
"onHoldText": "Në Pritje",
|
|
||||||
"proposedText": "E Propozuar",
|
|
||||||
"inPlanningText": "Në Planifikim",
|
|
||||||
"inProgressText": "Në Progres",
|
|
||||||
"completedText": "E Përfunduar",
|
|
||||||
"continuousText": "E Vazhdueshme",
|
|
||||||
|
|
||||||
"notSetText": "Pa Caktuar",
|
|
||||||
"needsAttentionText": "Kërkon Vëmendje",
|
|
||||||
"atRiskText": "Në Rrezik",
|
|
||||||
"goodText": "Në Rregull",
|
|
||||||
|
|
||||||
"nameColumn": "Emri",
|
|
||||||
"emailColumn": "Email",
|
|
||||||
"projectsColumn": "Projektet",
|
|
||||||
"tasksColumn": "Detyrat",
|
|
||||||
"overdueTasksColumn": "Detyrat e Vonuara",
|
|
||||||
"completedTasksColumn": "Detyrat e Përfunduara",
|
|
||||||
"ongoingTasksColumn": "Detyrat në Vazhdim"
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"overviewTitle": "Përmbledhje",
|
|
||||||
"includeArchivedButton": "Përfshij Projektet e Arkivuara",
|
|
||||||
|
|
||||||
"teamCount": "Ekip",
|
|
||||||
"teamCountPlural": "Ekipe",
|
|
||||||
"projectCount": "Projekt",
|
|
||||||
"projectCountPlural": "Projekte",
|
|
||||||
"memberCount": "Anëtar",
|
|
||||||
"memberCountPlural": "Anëtarë",
|
|
||||||
"activeProjectCount": "Projekt Aktiv",
|
|
||||||
"activeProjectCountPlural": "Projekte Aktive",
|
|
||||||
"overdueProjectCount": "Projekt i Vonuar",
|
|
||||||
"overdueProjectCountPlural": "Projekte të Vonuara",
|
|
||||||
"unassignedMemberCount": "Anëtar i Pacaktuar",
|
|
||||||
"unassignedMemberCountPlural": "Anëtarë të Pacaktuar",
|
|
||||||
"memberWithOverdueTaskCount": "Anëtar me Detyrë të Vonuar",
|
|
||||||
"memberWithOverdueTaskCountPlural": "Anëtarë me Detyra të Vonuara",
|
|
||||||
|
|
||||||
"teamsText": "Ekipet",
|
|
||||||
|
|
||||||
"nameColumn": "Emri",
|
|
||||||
"projectsColumn": "Projektet",
|
|
||||||
"membersColumn": "Anëtarët"
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"exportButton": "Eksporto",
|
|
||||||
"membersButton": "Anëtarët",
|
|
||||||
"tasksButton": "Detyrat",
|
|
||||||
"searchByNameInputPlaceholder": "Kërko sipas emrit",
|
|
||||||
|
|
||||||
"overviewTab": "Përmbledhje",
|
|
||||||
"membersTab": "Anëtarët",
|
|
||||||
"tasksTab": "Detyrat",
|
|
||||||
|
|
||||||
"completedTasksText": "Detyrat e Përfunduara",
|
|
||||||
"incompleteTasksText": "Detyrat e Papërfunduara",
|
|
||||||
"overdueTasksText": "Detyrat e Vonuara",
|
|
||||||
"allocatedHoursText": "Orët e Alokuara",
|
|
||||||
"loggedHoursText": "Orët e Regjistruara",
|
|
||||||
|
|
||||||
"tasksText": "Detyrat",
|
|
||||||
"allText": "Të Gjitha",
|
|
||||||
|
|
||||||
"tasksByStatusText": "Detyrat Sipas Statusit",
|
|
||||||
"tasksByPriorityText": "Detyrat Sipas Prioritetit",
|
|
||||||
"tasksByDueDateText": "Detyrat Sipas Afatit",
|
|
||||||
|
|
||||||
"todoText": "Për Të Bërë",
|
|
||||||
"doingText": "Duke bërë",
|
|
||||||
"doneText": "E Përfunduar",
|
|
||||||
"lowText": "I Ulët",
|
|
||||||
"mediumText": "I Mesëm",
|
|
||||||
"highText": "I Lartë",
|
|
||||||
"completedText": "E Përfunduar",
|
|
||||||
"upcomingText": "Në Ardhje",
|
|
||||||
"overdueText": "E Vonuar",
|
|
||||||
"noDueDateText": "Pa Afat",
|
|
||||||
|
|
||||||
"nameColumn": "Emri",
|
|
||||||
"tasksCountColumn": "Numri i Detyrave",
|
|
||||||
"completedTasksColumn": "Detyrat e Përfunduara",
|
|
||||||
"incompleteTasksColumn": "Detyrat e Papërfunduara",
|
|
||||||
"overdueTasksColumn": "Detyrat e Vonuara",
|
|
||||||
"contributionColumn": "Kontributi",
|
|
||||||
"progressColumn": "Progresi",
|
|
||||||
"loggedTimeColumn": "Koha e Regjistruar",
|
|
||||||
"taskColumn": "Detyra",
|
|
||||||
"projectColumn": "Projekti",
|
|
||||||
"statusColumn": "Statusi",
|
|
||||||
"priorityColumn": "Prioriteti",
|
|
||||||
"phaseColumn": "Faza",
|
|
||||||
"dueDateColumn": "Afati",
|
|
||||||
"completedDateColumn": "Data e Përfundimit",
|
|
||||||
"estimatedTimeColumn": "Koha e Vlerësuar",
|
|
||||||
"overloggedTimeColumn": "Koha e Tepërt",
|
|
||||||
"completedOnColumn": "Përfunduar Më",
|
|
||||||
"daysOverdueColumn": "Ditë vonim",
|
|
||||||
|
|
||||||
"groupByText": "Grupo Sipas:",
|
|
||||||
"statusText": "Statusi",
|
|
||||||
"priorityText": "Prioriteti",
|
|
||||||
"phaseText": "Faza"
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"searchByNamePlaceholder": "Kërko sipas emrit",
|
|
||||||
"searchByCategoryPlaceholder": "Kërko sipas kategorisë",
|
|
||||||
|
|
||||||
"statusText": "Statusi",
|
|
||||||
"healthText": "Gjendja",
|
|
||||||
"categoryText": "Kategoria",
|
|
||||||
"projectManagerText": "Menaxheri i Projektit",
|
|
||||||
"showFieldsText": "Shfaq fushat",
|
|
||||||
|
|
||||||
"cancelledText": "Anuluar",
|
|
||||||
"blockedText": "E bllokuar",
|
|
||||||
"onHoldText": "Në pritje",
|
|
||||||
"proposedText": "E propozuar",
|
|
||||||
"inPlanningText": "Në planifikim",
|
|
||||||
"inProgressText": "Në progres",
|
|
||||||
"completedText": "E përfunduar",
|
|
||||||
"continuousText": "E vazhdueshme",
|
|
||||||
|
|
||||||
"notSetText": "Pa caktuar",
|
|
||||||
"needsAttentionText": "Kërkon vëmendje",
|
|
||||||
"atRiskText": "Në rrezik",
|
|
||||||
"goodText": "Në rregull",
|
|
||||||
|
|
||||||
"nameText": "Projekti",
|
|
||||||
"estimatedVsActualText": "Vlerësuar vs Aktual",
|
|
||||||
"tasksProgressText": "Progresi i detyrave",
|
|
||||||
"lastActivityText": "Aktiviteti i fundit",
|
|
||||||
"datesText": "Datat e Fillimit/Përfundimit",
|
|
||||||
"daysLeftText": "Ditë të mbetura/vonuar",
|
|
||||||
"projectHealthText": "Gjendja e projektit",
|
|
||||||
"projectUpdateText": "Përditësimi i projektit",
|
|
||||||
"clientText": "Klienti",
|
|
||||||
"teamText": "Ekipi"
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
{
|
|
||||||
"projectCount": "Projekt",
|
|
||||||
"projectCountPlural": "Projekte",
|
|
||||||
"includeArchivedButton": "Përfshij Projektet e Arkivuara",
|
|
||||||
"exportButton": "Eksporto",
|
|
||||||
"excelButton": "Excel",
|
|
||||||
|
|
||||||
"projectColumn": "Projekti",
|
|
||||||
"estimatedVsActualColumn": "Vlerësuar vs Aktual",
|
|
||||||
"tasksProgressColumn": "Progresi i Detyrave",
|
|
||||||
"lastActivityColumn": "Aktiviteti i Fundit",
|
|
||||||
"statusColumn": "Statusi",
|
|
||||||
"datesColumn": "Data e Fillimit/Përfundimit",
|
|
||||||
"daysLeftColumn": "Ditë të Mbetura/Vonuar",
|
|
||||||
"projectHealthColumn": "Gjendja e Projektit",
|
|
||||||
"categoryColumn": "Kategoria",
|
|
||||||
"projectUpdateColumn": "Përditësimi i Projektit",
|
|
||||||
"clientColumn": "Klienti",
|
|
||||||
"teamColumn": "Ekipi",
|
|
||||||
"projectManagerColumn": "Menaxheri i Projektit",
|
|
||||||
|
|
||||||
"openButton": "Hap",
|
|
||||||
|
|
||||||
"estimatedText": "Vlerësuar",
|
|
||||||
"actualText": "Aktual",
|
|
||||||
|
|
||||||
"todoText": "Për të Bërë",
|
|
||||||
"doingText": "duke bërë",
|
|
||||||
"doneText": "E Përfunduar",
|
|
||||||
|
|
||||||
"cancelledText": "Anuluar",
|
|
||||||
"blockedText": "E Bllokuar",
|
|
||||||
"onHoldText": "Në Pritje",
|
|
||||||
"proposedText": "E Propozuar",
|
|
||||||
"inPlanningText": "Në Planifikim",
|
|
||||||
"inProgressText": "Në Progres",
|
|
||||||
"completedText": "E Përfunduar",
|
|
||||||
"continuousText": "E Vazhdueshme",
|
|
||||||
|
|
||||||
"daysLeftText": "ditë të mbetura",
|
|
||||||
"dayLeftText": "ditë e mbetur",
|
|
||||||
"daysOverdueText": "ditë vonuar",
|
|
||||||
|
|
||||||
"notSetText": "Pa Caktuar",
|
|
||||||
"needsAttentionText": "Kërkon Vëmendje",
|
|
||||||
"atRiskText": "Në Rrezik",
|
|
||||||
"goodText": "Në Rregull",
|
|
||||||
|
|
||||||
"setCategoryText": "Cakto Kategorinë",
|
|
||||||
"searchByNameInputPlaceholder": "Kërko sipas emrit",
|
|
||||||
"todayText": "Sot"
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"overview": "Përmbledhje",
|
|
||||||
"projects": "Projektet",
|
|
||||||
"members": "Anëtarët",
|
|
||||||
"timeReports": "Raportet e Kohës",
|
|
||||||
"estimateVsActual": "Vlerësimi vs Aktual",
|
|
||||||
"currentOrganizationTooltip": "Organizata aktuale"
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"today": "Sot",
|
|
||||||
"week": "Javë",
|
|
||||||
"month": "Muaj",
|
|
||||||
|
|
||||||
"settings": "Cilësimet",
|
|
||||||
"workingDays": "Ditët e punës",
|
|
||||||
"monday": "E hënë",
|
|
||||||
"tuesday": "E martë",
|
|
||||||
"wednesday": "E mërkurë",
|
|
||||||
"thursday": "E enjte",
|
|
||||||
"friday": "E premte",
|
|
||||||
"saturday": "E shtunë",
|
|
||||||
"sunday": "E diel",
|
|
||||||
"workingHours": "Orët e punës",
|
|
||||||
"hours": "Orë",
|
|
||||||
"saveButton": "Ruaj",
|
|
||||||
|
|
||||||
"totalAllocation": "Alokimi Total",
|
|
||||||
"timeLogged": "Koha e Regjistruar",
|
|
||||||
"remainingTime": "Koha e Mbetur",
|
|
||||||
"total": "Total",
|
|
||||||
"perDay": "Në Ditë",
|
|
||||||
"tasks": "detyra",
|
|
||||||
"startDate": "Data e Fillimit",
|
|
||||||
"endDate": "Data e Përfundimit",
|
|
||||||
|
|
||||||
"hoursPerDay": "Orë Në Ditë",
|
|
||||||
"totalHours": "Orë Totale",
|
|
||||||
"deleteButton": "Fshi",
|
|
||||||
"cancelButton": "Anulo",
|
|
||||||
|
|
||||||
"tabTitle": "Detyra pa Data Fillimi & Përfundimi",
|
|
||||||
|
|
||||||
"allocatedTime": "Koha e alokuar",
|
|
||||||
"totalLogged": "Total i Regjistruar",
|
|
||||||
"loggedBillable": "Regjistruar Fakturueshme",
|
|
||||||
"loggedNonBillable": "Regjistruar Jo Fakturueshme"
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"categoryColumn": "Kategoria",
|
|
||||||
"deleteConfirmationTitle": "Jeni të sigurt?",
|
|
||||||
"deleteConfirmationOk": "Po",
|
|
||||||
"deleteConfirmationCancel": "Anulo",
|
|
||||||
"associatedTaskColumn": "Projektet e Lidhura",
|
|
||||||
"searchPlaceholder": "Kërko sipas emrit",
|
|
||||||
"emptyText": "Kategoritë mund të krijohen gjatë përditësimit ose krijimit të projekteve.",
|
|
||||||
"colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën"
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Ndrysho Fjalëkalimin",
|
|
||||||
"currentPassword": "Fjalëkalimi Aktual",
|
|
||||||
"newPassword": "Fjalëkalimi i Ri",
|
|
||||||
"confirmPassword": "Konfirmo Fjalëkalimin",
|
|
||||||
"currentPasswordPlaceholder": "Vendosni fjalëkalimin aktual",
|
|
||||||
"newPasswordPlaceholder": "Fjalëkalimi i Ri",
|
|
||||||
"confirmPasswordPlaceholder": "Konfirmo Fjalëkalimin",
|
|
||||||
"currentPasswordRequired": "Ju lutemi vendosni fjalëkalimin aktual!",
|
|
||||||
"newPasswordRequired": "Ju lutemi vendosni fjalëkalimin e ri!",
|
|
||||||
"passwordValidationError": "Fjalëkalimi duhet të përmbajë të paktën 8 karaktere, me një shkronjë të madhe, një numër dhe një simbol.",
|
|
||||||
"passwordMismatch": "Fjalëkalimet nuk përputhen!",
|
|
||||||
"passwordRequirements": "Fjalëkalimi i ri duhet të jetë së paku 8 karaktere, me një shkronjë të madhe, një numër dhe një simbol.",
|
|
||||||
"updateButton": "Përditëso Fjalëkalimin"
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"nameColumn": "Emri",
|
|
||||||
"projectColumn": "Projekti",
|
|
||||||
"noProjectsAvailable": "Nuk ka projekte të disponueshme",
|
|
||||||
"deleteConfirmationTitle": "Jeni i sigurt?",
|
|
||||||
"deleteConfirmationOk": "Po",
|
|
||||||
"deleteConfirmationCancel": "Anulo",
|
|
||||||
"searchPlaceholder": "Kërko sipas emrit",
|
|
||||||
"createClient": "Krijo Klient",
|
|
||||||
"pinTooltip": "Klikoni për ta fiksuar në menynë kryesore",
|
|
||||||
"createClientDrawerTitle": "Krijo Klient",
|
|
||||||
"updateClientDrawerTitle": "Përditëso Klientin",
|
|
||||||
"nameLabel": "Emri",
|
|
||||||
"namePlaceholder": "Emri",
|
|
||||||
"nameRequiredError": "Ju lutemi shkruani një Emër",
|
|
||||||
"createButton": "Krijo",
|
|
||||||
"updateButton": "Përditëso",
|
|
||||||
"createClientSuccessMessage": "Klienti u krijua me sukses!",
|
|
||||||
"createClientErrorMessage": "Krijimi i klientit dështoi!",
|
|
||||||
"updateClientSuccessMessage": "Klienti u përditësua me sukses!",
|
|
||||||
"updateClientErrorMessage": "Përditësimi i klientit dështoi!"
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"nameColumn": "Emri",
|
|
||||||
"deleteConfirmationTitle": "Jeni i sigurt?",
|
|
||||||
"deleteConfirmationOk": "Po",
|
|
||||||
"deleteConfirmationCancel": "Anulo",
|
|
||||||
"searchPlaceholder": "Kërko sipas emrit",
|
|
||||||
"createJobTitleButton": "Krijo Titull Pune",
|
|
||||||
"pinTooltip": "Klikoni për ta fiksuar në menynë kryesore",
|
|
||||||
"createJobTitleDrawerTitle": "Krijo Titull Pune",
|
|
||||||
"updateJobTitleDrawerTitle": "Përditëso Titullin e Punës",
|
|
||||||
"nameLabel": "Emri",
|
|
||||||
"namePlaceholder": "Emri",
|
|
||||||
"nameRequiredError": "Ju lutemi shkruani një Emër",
|
|
||||||
"createButton": "Krijo",
|
|
||||||
"updateButton": "Përditëso",
|
|
||||||
"createJobTitleSuccessMessage": "Titulli i punës u krijua me sukses!",
|
|
||||||
"createJobTitleErrorMessage": "Krijimi i titullit të punës dështoi!",
|
|
||||||
"updateJobTitleSuccessMessage": "Titulli i punës u përditësua me sukses!",
|
|
||||||
"updateJobTitleErrorMessage": "Përditësimi i titullit të punës dështoi!"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"labelColumn": "Etiketa",
|
|
||||||
"deleteConfirmationTitle": "Jeni i sigurt?",
|
|
||||||
"deleteConfirmationOk": "Po",
|
|
||||||
"deleteConfirmationCancel": "Anulo",
|
|
||||||
"associatedTaskColumn": "Numri i Detyrave të Lidhura",
|
|
||||||
"searchPlaceholder": "Kërko sipas emrit",
|
|
||||||
"emptyText": "Etiketat mund të krijohen gjatë përditësimit ose krijimit të detyrave.",
|
|
||||||
"pinTooltip": "Klikoni për ta fiksuar në menynë kryesore",
|
|
||||||
"colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën"
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"language": "Gjuha",
|
|
||||||
"language_required": "Gjuha është e detyrueshme",
|
|
||||||
"time_zone": "Zona kohore",
|
|
||||||
"time_zone_required": "Zona kohore është e detyrueshme",
|
|
||||||
"save_changes": "Ruaj Ndryshimet"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Cilësimet e Njoftimeve",
|
|
||||||
"emailTitle": "Më dërgo njoftime me email",
|
|
||||||
"emailDescription": "Kjo përfshin caktimet e reja të detyrave",
|
|
||||||
"dailyDigestTitle": "Më dërgo një përmbledhje ditore",
|
|
||||||
"dailyDigestDescription": "Çdo mbrëmje, do të merrni një përmbledhje të aktivitetit të fundit në detyra.",
|
|
||||||
"popupTitle": "Shfaq njoftimet në kompjuterin tim kur Worklenz është i hapur",
|
|
||||||
"popupDescription": "Njoftimet e shfaqura mund të çaktivizohen nga shfletuesi juaj. Ndryshoni cilësimet e shfletuesit për t'i lejuar ato.",
|
|
||||||
"unreadItemsTitle": "Shfaq numrin e artikujve të palexuar",
|
|
||||||
"unreadItemsDescription": "Do të shihni numërimin për çdo njoftim."
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user