Add task progress tracking methods and enhance UI components
- Introduced a comprehensive guide for users on task progress tracking methods, including manual, weighted, and time-based progress. - Implemented backend support for progress calculations, including SQL functions and migrations to accommodate new progress features. - Enhanced frontend components to support progress input and display, including updates to task and project drawers. - Added localization for new progress-related terms and validation messages. - Integrated real-time updates for task progress and weight changes through socket events.
This commit is contained in:
173
docs/task-progress-guide-for-users.md
Normal file
173
docs/task-progress-guide-for-users.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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 200 (critical task)
|
||||||
|
- Subtask B: 75% complete, Weight 100 (standard task)
|
||||||
|
- Subtask C: 25% complete, Weight 300 (major task)
|
||||||
|
|
||||||
|
The parent task will be approximately 42% complete, with Subtask C having the greatest impact due to its higher weight.
|
||||||
|
|
||||||
|
### Important Notes About Weights
|
||||||
|
|
||||||
|
- Default weight is 100 if not specified
|
||||||
|
- You can set weights from 0 to any reasonable number (typically 1-1000)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## Default Progress Method
|
||||||
|
|
||||||
|
If none of the special progress methods are enabled, WorkLenz uses a simple completion-based approach:
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
**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 (default 100 if not specified)
|
||||||
|
- Higher weight values give tasks more influence in parent task progress calculations
|
||||||
|
- Weight values are typically whole numbers between 0 and 1000
|
||||||
|
- 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 200
|
||||||
|
- Subtask B: Progress 75%, Weight 100
|
||||||
|
- Subtask C: Progress 25%, Weight 300
|
||||||
|
|
||||||
|
Calculation:
|
||||||
|
```
|
||||||
|
ParentProgress = ((50 * 200) + (75 * 100) + (25 * 300)) / (200 + 100 + 300)
|
||||||
|
ParentProgress = (10000 + 7500 + 7500) / 600
|
||||||
|
ParentProgress = 25000 / 600
|
||||||
|
ParentProgress = 41.67%
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -515,6 +515,142 @@ BEGIN
|
|||||||
END
|
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
|
-- Add use_manual_progress, use_weighted_progress, and use_time_progress to projects table if they don't exist
|
||||||
ALTER TABLE projects
|
ALTER TABLE projects
|
||||||
ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE,
|
ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,12 @@ 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 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
|
||||||
@@ -334,7 +340,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,11 +359,27 @@ 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 = [];
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
task.index = index++;
|
task.index = index++;
|
||||||
|
|
||||||
|
// 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 [data] = result.rows;
|
||||||
|
if (data && data.info) {
|
||||||
|
task.complete_ratio = +data.info.ratio.toFixed();
|
||||||
|
task.completed_count = data.info.total_completed;
|
||||||
|
task.total_tasks_count = data.info.total_tasks;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Proceed with default calculation if database call fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -395,7 +417,7 @@ 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> {
|
||||||
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 +432,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.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Socket } from "socket.io";
|
|||||||
import db from "../../config/db";
|
import db from "../../config/db";
|
||||||
import { SocketEvents } from "../events";
|
import { SocketEvents } from "../events";
|
||||||
import { log, log_error, notifyProjectUpdates } from "../util";
|
import { log, log_error, notifyProjectUpdates } from "../util";
|
||||||
|
import { logProgressChange } from "../../services/activity-logs/activity-logs.service";
|
||||||
|
|
||||||
interface UpdateTaskProgressData {
|
interface UpdateTaskProgressData {
|
||||||
task_id: string;
|
task_id: string;
|
||||||
@@ -16,10 +17,38 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str
|
|||||||
const parsedData = JSON.parse(data) as UpdateTaskProgressData;
|
const parsedData = JSON.parse(data) as UpdateTaskProgressData;
|
||||||
const { task_id, progress_value, parent_task_id } = parsedData;
|
const { task_id, progress_value, parent_task_id } = parsedData;
|
||||||
|
|
||||||
|
console.log(`Updating progress for task ${task_id}: new value = ${progress_value}`);
|
||||||
|
|
||||||
if (!task_id || progress_value === undefined) {
|
if (!task_id || progress_value === undefined) {
|
||||||
return;
|
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) {
|
||||||
|
console.log(`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, team_id FROM tasks WHERE id = $1",
|
||||||
|
[task_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentProgress = currentProgressResult.rows[0]?.progress_value;
|
||||||
|
const projectId = currentProgressResult.rows[0]?.project_id;
|
||||||
|
const teamId = currentProgressResult.rows[0]?.team_id;
|
||||||
|
|
||||||
|
console.log(`Previous progress for task ${task_id}: ${currentProgress}; New: ${progress_value}`);
|
||||||
|
|
||||||
// Update the task progress in the database
|
// Update the task progress in the database
|
||||||
await db.query(
|
await db.query(
|
||||||
`UPDATE tasks
|
`UPDATE tasks
|
||||||
@@ -28,9 +57,13 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str
|
|||||||
[progress_value, task_id]
|
[progress_value, task_id]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the project ID for the task
|
// Log the progress change using the activity logs service
|
||||||
const projectResult = await db.query("SELECT project_id FROM tasks WHERE id = $1", [task_id]);
|
await logProgressChange({
|
||||||
const projectId = projectResult.rows[0]?.project_id;
|
task_id,
|
||||||
|
old_value: currentProgress !== null ? currentProgress.toString() : '0',
|
||||||
|
new_value: progress_value.toString(),
|
||||||
|
socket
|
||||||
|
});
|
||||||
|
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
// Emit the update to all clients in the project room
|
// Emit the update to all clients in the project room
|
||||||
@@ -42,6 +75,8 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(`Emitted progress update for task ${task_id} to project room ${projectId}`);
|
||||||
|
|
||||||
// If this is a subtask, update the parent task's progress
|
// If this is a subtask, update the parent task's progress
|
||||||
if (parent_task_id) {
|
if (parent_task_id) {
|
||||||
const progressRatio = await db.query(
|
const progressRatio = await db.query(
|
||||||
@@ -49,6 +84,8 @@ export async function on_update_task_progress(io: any, socket: Socket, data: str
|
|||||||
[parent_task_id]
|
[parent_task_id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(`Updated parent task ${parent_task_id} progress: ${progressRatio?.rows[0]?.ratio}`);
|
||||||
|
|
||||||
// Emit the parent task's updated progress
|
// Emit the parent task's updated progress
|
||||||
io.to(projectId).emit(
|
io.to(projectId).emit(
|
||||||
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
|
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Socket } from "socket.io";
|
|||||||
import db from "../../config/db";
|
import db from "../../config/db";
|
||||||
import { SocketEvents } from "../events";
|
import { SocketEvents } from "../events";
|
||||||
import { log, log_error, notifyProjectUpdates } from "../util";
|
import { log, log_error, notifyProjectUpdates } from "../util";
|
||||||
|
import { logWeightChange } from "../../services/activity-logs/activity-logs.service";
|
||||||
|
|
||||||
interface UpdateTaskWeightData {
|
interface UpdateTaskWeightData {
|
||||||
task_id: string;
|
task_id: string;
|
||||||
@@ -11,7 +12,6 @@ interface UpdateTaskWeightData {
|
|||||||
|
|
||||||
export async function on_update_task_weight(io: any, socket: Socket, data: string) {
|
export async function on_update_task_weight(io: any, socket: Socket, data: string) {
|
||||||
try {
|
try {
|
||||||
log(socket.id, `${SocketEvents.UPDATE_TASK_WEIGHT}: ${data}`);
|
|
||||||
|
|
||||||
const parsedData = JSON.parse(data) as UpdateTaskWeightData;
|
const parsedData = JSON.parse(data) as UpdateTaskWeightData;
|
||||||
const { task_id, weight, parent_task_id } = parsedData;
|
const { task_id, weight, parent_task_id } = parsedData;
|
||||||
@@ -20,6 +20,15 @@ export async function on_update_task_weight(io: any, socket: Socket, data: strin
|
|||||||
return;
|
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 in the database
|
// Update the task weight in the database
|
||||||
await db.query(
|
await db.query(
|
||||||
`UPDATE tasks
|
`UPDATE tasks
|
||||||
@@ -28,9 +37,13 @@ export async function on_update_task_weight(io: any, socket: Socket, data: strin
|
|||||||
[weight, task_id]
|
[weight, task_id]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the project ID for the task
|
// Log the weight change using the activity logs service
|
||||||
const projectResult = await db.query("SELECT project_id FROM tasks WHERE id = $1", [task_id]);
|
await logWeightChange({
|
||||||
const projectId = projectResult.rows[0]?.project_id;
|
task_id,
|
||||||
|
old_value: currentWeight !== null ? currentWeight.toString() : '100',
|
||||||
|
new_value: weight.toString(),
|
||||||
|
socket
|
||||||
|
});
|
||||||
|
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
// Emit the update to all clients in the project room
|
// Emit the update to all clients in the project room
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
"createClient": "Create client",
|
"createClient": "Create client",
|
||||||
"searchInputPlaceholder": "Search by name or email",
|
"searchInputPlaceholder": "Search by name or email",
|
||||||
"hoursPerDayValidationMessage": "Hours per day must be a number between 1 and 24",
|
"hoursPerDayValidationMessage": "Hours per day must be a number between 1 and 24",
|
||||||
|
"workingDaysValidationMessage": "Working days must be a positive number",
|
||||||
|
"manDaysValidationMessage": "Man days must be a positive number",
|
||||||
"noPermission": "No permission",
|
"noPermission": "No permission",
|
||||||
"progressSettings": "Progress Settings",
|
"progressSettings": "Progress Settings",
|
||||||
"manualProgress": "Manual Progress",
|
"manualProgress": "Manual Progress",
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
"createClient": "Crear cliente",
|
"createClient": "Crear cliente",
|
||||||
"searchInputPlaceholder": "Busca por nombre o email",
|
"searchInputPlaceholder": "Busca por nombre o email",
|
||||||
"hoursPerDayValidationMessage": "Las horas por día deben ser un número entre 1 y 24",
|
"hoursPerDayValidationMessage": "Las horas por día deben ser un número entre 1 y 24",
|
||||||
|
"workingDaysValidationMessage": "Los días de trabajo deben ser un número positivo",
|
||||||
|
"manDaysValidationMessage": "Los días hombre deben ser un número positivo",
|
||||||
"noPermission": "Sin permiso",
|
"noPermission": "Sin permiso",
|
||||||
"progressSettings": "Configuración de Progreso",
|
"progressSettings": "Configuración de Progreso",
|
||||||
"manualProgress": "Progreso Manual",
|
"manualProgress": "Progreso Manual",
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
"createClient": "Criar cliente",
|
"createClient": "Criar cliente",
|
||||||
"searchInputPlaceholder": "Pesquise por nome ou email",
|
"searchInputPlaceholder": "Pesquise por nome ou email",
|
||||||
"hoursPerDayValidationMessage": "As horas por dia devem ser um número entre 1 e 24",
|
"hoursPerDayValidationMessage": "As horas por dia devem ser um número entre 1 e 24",
|
||||||
|
"workingDaysValidationMessage": "Os dias de trabalho devem ser um número positivo",
|
||||||
|
"manDaysValidationMessage": "Os dias de homem devem ser um número positivo",
|
||||||
"noPermission": "Sem permissão",
|
"noPermission": "Sem permissão",
|
||||||
"progressSettings": "Configurações de Progresso",
|
"progressSettings": "Configurações de Progresso",
|
||||||
"manualProgress": "Progresso Manual",
|
"manualProgress": "Progresso Manual",
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse
|
|||||||
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
|
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
|
||||||
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { setProjectData, toggleProjectDrawer, setProjectId as setDrawerProjectId } from '@/features/project/project-drawer.slice';
|
import {
|
||||||
|
setProjectData,
|
||||||
|
toggleProjectDrawer,
|
||||||
|
setProjectId as setDrawerProjectId,
|
||||||
|
} from '@/features/project/project-drawer.slice';
|
||||||
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
import { evt_projects_create } from '@/shared/worklenz-analytics-events';
|
import { evt_projects_create } from '@/shared/worklenz-analytics-events';
|
||||||
@@ -61,7 +65,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const currentSession = useAuthService().getCurrentSession();
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [editMode, setEditMode] = useState<boolean>(false);
|
const [editMode, setEditMode] = useState<boolean>(false);
|
||||||
const [selectedProjectManager, setSelectedProjectManager] = useState<ITeamMemberViewModel | null>(
|
const [selectedProjectManager, setSelectedProjectManager] = useState<ITeamMemberViewModel | null>(
|
||||||
@@ -176,7 +180,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
dispatch(toggleProjectDrawer());
|
dispatch(toggleProjectDrawer());
|
||||||
if (!editMode) {
|
if (!editMode) {
|
||||||
trackMixpanelEvent(evt_projects_create);
|
trackMixpanelEvent(evt_projects_create);
|
||||||
navigate(`/worklenz/projects/${response.data.body.id}?tab=tasks-list&pinned_tab=tasks-list`);
|
navigate(
|
||||||
|
`/worklenz/projects/${response.data.body.id}?tab=tasks-list&pinned_tab=tasks-list`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
refetchProjects();
|
refetchProjects();
|
||||||
window.location.reload(); // Refresh the page
|
window.location.reload(); // Refresh the page
|
||||||
@@ -191,8 +197,17 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
logger.error('Error saving project', error);
|
logger.error('Error saving project', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const calculateWorkingDays = (startDate: dayjs.Dayjs | null, endDate: dayjs.Dayjs | null): number => {
|
const calculateWorkingDays = (
|
||||||
if (!startDate || !endDate || !startDate.isValid() || !endDate.isValid() || startDate.isAfter(endDate)) {
|
startDate: dayjs.Dayjs | null,
|
||||||
|
endDate: dayjs.Dayjs | null
|
||||||
|
): number => {
|
||||||
|
if (
|
||||||
|
!startDate ||
|
||||||
|
!endDate ||
|
||||||
|
!startDate.isValid() ||
|
||||||
|
!endDate.isValid() ||
|
||||||
|
startDate.isAfter(endDate)
|
||||||
|
) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +235,13 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
...project,
|
...project,
|
||||||
start_date: project.start_date ? dayjs(project.start_date) : null,
|
start_date: project.start_date ? dayjs(project.start_date) : null,
|
||||||
end_date: project.end_date ? dayjs(project.end_date) : null,
|
end_date: project.end_date ? dayjs(project.end_date) : null,
|
||||||
working_days: form.getFieldValue('start_date') && form.getFieldValue('end_date') ? calculateWorkingDays(form.getFieldValue('start_date'), form.getFieldValue('end_date')) : project.working_days || 0,
|
working_days:
|
||||||
|
form.getFieldValue('start_date') && form.getFieldValue('end_date')
|
||||||
|
? calculateWorkingDays(
|
||||||
|
form.getFieldValue('start_date'),
|
||||||
|
form.getFieldValue('end_date')
|
||||||
|
)
|
||||||
|
: project.working_days || 0,
|
||||||
use_manual_progress: project.use_manual_progress || false,
|
use_manual_progress: project.use_manual_progress || false,
|
||||||
use_weighted_progress: project.use_weighted_progress || false,
|
use_weighted_progress: project.use_weighted_progress || false,
|
||||||
use_time_progress: project.use_time_progress || false,
|
use_time_progress: project.use_time_progress || false,
|
||||||
@@ -382,12 +403,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!isEditable && (
|
{!isEditable && (
|
||||||
<Alert
|
<Alert message={t('noPermission')} type="warning" showIcon style={{ marginBottom: 16 }} />
|
||||||
message={t('noPermission')}
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<Skeleton active paragraph={{ rows: 12 }} loading={projectLoading}>
|
<Skeleton active paragraph={{ rows: 12 }} loading={projectLoading}>
|
||||||
<Form
|
<Form
|
||||||
@@ -448,14 +464,11 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
|
|
||||||
<Form.Item name="date" layout="horizontal">
|
<Form.Item name="date" layout="horizontal">
|
||||||
<Flex gap={8}>
|
<Flex gap={8}>
|
||||||
<Form.Item
|
<Form.Item name="start_date" label={t('startDate')}>
|
||||||
name="start_date"
|
|
||||||
label={t('startDate')}
|
|
||||||
>
|
|
||||||
<DatePicker
|
<DatePicker
|
||||||
disabledDate={disabledStartDate}
|
disabledDate={disabledStartDate}
|
||||||
disabled={!isProjectManager && !isOwnerorAdmin}
|
disabled={!isProjectManager && !isOwnerorAdmin}
|
||||||
onChange={(date) => {
|
onChange={date => {
|
||||||
const endDate = form.getFieldValue('end_date');
|
const endDate = form.getFieldValue('end_date');
|
||||||
if (date && endDate) {
|
if (date && endDate) {
|
||||||
const days = calculateWorkingDays(date, endDate);
|
const days = calculateWorkingDays(date, endDate);
|
||||||
@@ -464,14 +477,11 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item name="end_date" label={t('endDate')}>
|
||||||
name="end_date"
|
|
||||||
label={t('endDate')}
|
|
||||||
>
|
|
||||||
<DatePicker
|
<DatePicker
|
||||||
disabledDate={disabledEndDate}
|
disabledDate={disabledEndDate}
|
||||||
disabled={!isProjectManager && !isOwnerorAdmin}
|
disabled={!isProjectManager && !isOwnerorAdmin}
|
||||||
onChange={(date) => {
|
onChange={date => {
|
||||||
const startDate = form.getFieldValue('start_date');
|
const startDate = form.getFieldValue('start_date');
|
||||||
if (startDate && date) {
|
if (startDate && date) {
|
||||||
const days = calculateWorkingDays(startDate, date);
|
const days = calculateWorkingDays(startDate, date);
|
||||||
@@ -483,12 +493,48 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item name="working_days" label={t('estimateWorkingDays')}>
|
<Form.Item
|
||||||
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
|
name="working_days"
|
||||||
|
label={t('estimateWorkingDays')}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: (_, value) => {
|
||||||
|
if (value === undefined || value >= 0) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(t('workingDaysValidationMessage', { min: 0 })));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input type="number" min={0} disabled={!isProjectManager && !isOwnerorAdmin} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item name="man_days" label={t('estimateManDays')}>
|
<Form.Item
|
||||||
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
|
name="man_days"
|
||||||
|
label={t('estimateManDays')}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: (_, value) => {
|
||||||
|
if (value === undefined || value >= 0) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(t('manDaysValidationMessage', { min: 0 })));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
disabled={!isProjectManager && !isOwnerorAdmin}
|
||||||
|
onBlur={e => {
|
||||||
|
const value = parseInt(e.target.value, 10);
|
||||||
|
if (value < 0) {
|
||||||
|
form.setFieldsValue({ man_days: 0 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -500,16 +546,28 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
if (value === undefined || (value >= 0 && value <= 24)) {
|
if (value === undefined || (value >= 0 && value <= 24)) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
return Promise.reject(new Error(t('hoursPerDayValidationMessage', { min: 0, max: 24 })));
|
return Promise.reject(
|
||||||
|
new Error(t('hoursPerDayValidationMessage', { min: 0, max: 24 }))
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
disabled={!isProjectManager && !isOwnerorAdmin}
|
||||||
|
onBlur={e => {
|
||||||
|
const value = parseInt(e.target.value, 10);
|
||||||
|
if (value < 0) {
|
||||||
|
form.setFieldsValue({ hours_per_day: 8 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Divider orientation="left">{t('progressSettings')}</Divider>
|
<Divider orientation="left">{t('progressSettings')}</Divider>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="use_manual_progress"
|
name="use_manual_progress"
|
||||||
label={
|
label={
|
||||||
@@ -522,7 +580,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
}
|
}
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
onChange={handleManualProgressChange}
|
onChange={handleManualProgressChange}
|
||||||
disabled={!isProjectManager && !isOwnerorAdmin}
|
disabled={!isProjectManager && !isOwnerorAdmin}
|
||||||
/>
|
/>
|
||||||
@@ -540,7 +598,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
}
|
}
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
onChange={handleWeightedProgressChange}
|
onChange={handleWeightedProgressChange}
|
||||||
disabled={!isProjectManager && !isOwnerorAdmin}
|
disabled={!isProjectManager && !isOwnerorAdmin}
|
||||||
/>
|
/>
|
||||||
@@ -558,7 +616,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
}
|
}
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
onChange={handleTimeProgressChange}
|
onChange={handleTimeProgressChange}
|
||||||
disabled={!isProjectManager && !isOwnerorAdmin}
|
disabled={!isProjectManager && !isOwnerorAdmin}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -111,6 +111,32 @@ const TaskDrawerActivityLog = () => {
|
|||||||
</Tag>
|
</Tag>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case IActivityLogAttributeTypes.PROGRESS:
|
||||||
|
return (
|
||||||
|
<Flex gap={4} align="center">
|
||||||
|
<Tag color="blue">
|
||||||
|
{activity.previous || '0'}%
|
||||||
|
</Tag>
|
||||||
|
<ArrowRightOutlined />
|
||||||
|
<Tag color="blue">
|
||||||
|
{activity.current || '0'}%
|
||||||
|
</Tag>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
|
||||||
|
case IActivityLogAttributeTypes.WEIGHT:
|
||||||
|
return (
|
||||||
|
<Flex gap={4} align="center">
|
||||||
|
<Tag color="purple">
|
||||||
|
Weight: {activity.previous || '100'}
|
||||||
|
</Tag>
|
||||||
|
<ArrowRightOutlined />
|
||||||
|
<Tag color="purple">
|
||||||
|
Weight: {activity.current || '100'}
|
||||||
|
</Tag>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -21,13 +21,16 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
|
|||||||
const isSubTask = !!task?.parent_task_id;
|
const isSubTask = !!task?.parent_task_id;
|
||||||
const hasSubTasks = task?.sub_tasks_count > 0;
|
const hasSubTasks = task?.sub_tasks_count > 0;
|
||||||
|
|
||||||
// Determine which progress input to show based on project settings
|
// Show manual progress input only for tasks without subtasks (not parent tasks)
|
||||||
const showManualProgressInput = project?.use_manual_progress && !hasSubTasks && !isSubTask;
|
// Parent tasks get their progress calculated from subtasks
|
||||||
|
const showManualProgressInput = !hasSubTasks;
|
||||||
|
|
||||||
|
// Only show weight input for subtasks in weighted progress mode
|
||||||
const showTaskWeightInput = project?.use_weighted_progress && isSubTask;
|
const showTaskWeightInput = project?.use_weighted_progress && isSubTask;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Listen for progress updates from the server
|
// Listen for progress updates from the server
|
||||||
socket?.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), (data) => {
|
const handleProgressUpdate = (data: any) => {
|
||||||
if (data.task_id === task.id) {
|
if (data.task_id === task.id) {
|
||||||
if (data.progress_value !== undefined) {
|
if (data.progress_value !== undefined) {
|
||||||
form.setFieldsValue({ progress_value: data.progress_value });
|
form.setFieldsValue({ progress_value: data.progress_value });
|
||||||
@@ -36,34 +39,74 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
|
|||||||
form.setFieldsValue({ weight: data.weight });
|
form.setFieldsValue({ weight: data.weight });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
socket?.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleProgressUpdate);
|
||||||
|
|
||||||
|
// When the component mounts, explicitly request the latest progress for this task
|
||||||
|
if (connected && task.id) {
|
||||||
|
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket?.off(SocketEvents.TASK_PROGRESS_UPDATED.toString());
|
socket?.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleProgressUpdate);
|
||||||
};
|
};
|
||||||
}, [socket, task.id, form]);
|
}, [socket, connected, task.id, form]);
|
||||||
|
|
||||||
const handleProgressChange = (value: number | null) => {
|
const handleProgressChange = (value: number | null) => {
|
||||||
if (connected && task.id && value !== null) {
|
if (connected && task.id && value !== null) {
|
||||||
socket?.emit(SocketEvents.UPDATE_TASK_PROGRESS.toString(), JSON.stringify({
|
// Ensure parent_task_id is not undefined
|
||||||
task_id: task.id,
|
const parent_task_id = task.parent_task_id || null;
|
||||||
progress_value: value,
|
|
||||||
parent_task_id: task.parent_task_id
|
socket?.emit(
|
||||||
}));
|
SocketEvents.UPDATE_TASK_PROGRESS.toString(),
|
||||||
|
JSON.stringify({
|
||||||
|
task_id: task.id,
|
||||||
|
progress_value: value,
|
||||||
|
parent_task_id: parent_task_id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// If this task has subtasks, request recalculation of its progress
|
||||||
|
if (hasSubTasks) {
|
||||||
|
setTimeout(() => {
|
||||||
|
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is a subtask, request the parent's progress to be updated in UI
|
||||||
|
if (parent_task_id) {
|
||||||
|
setTimeout(() => {
|
||||||
|
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), parent_task_id);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWeightChange = (value: number | null) => {
|
const handleWeightChange = (value: number | null) => {
|
||||||
if (connected && task.id && value !== null) {
|
if (connected && task.id && value !== null) {
|
||||||
socket?.emit(SocketEvents.UPDATE_TASK_WEIGHT.toString(), JSON.stringify({
|
// Ensure parent_task_id is not undefined
|
||||||
task_id: task.id,
|
const parent_task_id = task.parent_task_id || null;
|
||||||
weight: value,
|
|
||||||
parent_task_id: task.parent_task_id
|
socket?.emit(
|
||||||
}));
|
SocketEvents.UPDATE_TASK_WEIGHT.toString(),
|
||||||
|
JSON.stringify({
|
||||||
|
task_id: task.id,
|
||||||
|
weight: value,
|
||||||
|
parent_task_id: parent_task_id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// If this is a subtask, request the parent's progress to be updated in UI
|
||||||
|
if (parent_task_id) {
|
||||||
|
setTimeout(() => {
|
||||||
|
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), parent_task_id);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const percentFormatter = (value: number | undefined) => value ? `${value}%` : '0%';
|
const percentFormatter = (value: number | undefined) => (value ? `${value}%` : '0%');
|
||||||
const percentParser = (value: string | undefined) => {
|
const percentParser = (value: string | undefined) => {
|
||||||
const parsed = parseInt(value?.replace('%', '') || '0', 10);
|
const parsed = parseInt(value?.replace('%', '') || '0', 10);
|
||||||
return isNaN(parsed) ? 0 : parsed;
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
@@ -75,43 +118,6 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showManualProgressInput && (
|
|
||||||
<Form.Item
|
|
||||||
name="progress_value"
|
|
||||||
label={
|
|
||||||
<Flex align="center" gap={4}>
|
|
||||||
{t('taskInfoTab.details.progressValue')}
|
|
||||||
<Tooltip title={t('taskInfoTab.details.progressValueTooltip')}>
|
|
||||||
<QuestionCircleOutlined />
|
|
||||||
</Tooltip>
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('taskInfoTab.details.progressValueRequired'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'number',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
message: t('taskInfoTab.details.progressValueRange'),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
formatter={percentFormatter}
|
|
||||||
parser={percentParser}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const value = percentParser(e.target.value);
|
|
||||||
handleProgressChange(value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showTaskWeightInput && (
|
{showTaskWeightInput && (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="weight"
|
name="weight"
|
||||||
@@ -124,10 +130,6 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
}
|
}
|
||||||
rules={[
|
rules={[
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: t('taskInfoTab.details.taskWeightRequired'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'number',
|
||||||
min: 0,
|
min: 0,
|
||||||
@@ -141,15 +143,47 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
|
|||||||
max={100}
|
max={100}
|
||||||
formatter={percentFormatter}
|
formatter={percentFormatter}
|
||||||
parser={percentParser}
|
parser={percentParser}
|
||||||
onBlur={(e) => {
|
onBlur={e => {
|
||||||
const value = percentParser(e.target.value);
|
const value = percentParser(e.target.value);
|
||||||
handleWeightChange(value);
|
handleWeightChange(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
{showManualProgressInput && (
|
||||||
|
<Form.Item
|
||||||
|
name="progress_value"
|
||||||
|
label={
|
||||||
|
<Flex align="center" gap={4}>
|
||||||
|
{t('taskInfoTab.details.progressValue')}
|
||||||
|
<Tooltip title={t('taskInfoTab.details.progressValueTooltip')}>
|
||||||
|
<QuestionCircleOutlined />
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
message: t('taskInfoTab.details.progressValueRange'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
formatter={percentFormatter}
|
||||||
|
parser={percentParser}
|
||||||
|
onBlur={e => {
|
||||||
|
const value = percentParser(e.target.value);
|
||||||
|
handleProgressChange(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TaskDrawerProgress;
|
export default TaskDrawerProgress;
|
||||||
|
|||||||
@@ -210,10 +210,11 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
|
|||||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||||
okText="Yes"
|
okText="Yes"
|
||||||
cancelText="No"
|
cancelText="No"
|
||||||
onConfirm={() => handleDeleteSubTask(record.id)}
|
onPopupClick={(e) => e.stopPropagation()}
|
||||||
|
onConfirm={(e) => {handleDeleteSubTask(record.id)}}
|
||||||
>
|
>
|
||||||
<Tooltip title="Delete">
|
<Tooltip title="Delete">
|
||||||
<Button shape="default" icon={<DeleteOutlined />} size="small" />
|
<Button shape="default" icon={<DeleteOutlined />} size="small" onClick={(e)=> e.stopPropagation()} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
|||||||
<Button
|
<Button
|
||||||
shape="circle"
|
shape="circle"
|
||||||
icon={<ReloadOutlined spin={loadingSubTasks} />}
|
icon={<ReloadOutlined spin={loadingSubTasks} />}
|
||||||
onClick={(e) => {
|
onClick={e => {
|
||||||
e.stopPropagation(); // Prevent click from bubbling up
|
e.stopPropagation(); // Prevent click from bubbling up
|
||||||
fetchSubTasks();
|
fetchSubTasks();
|
||||||
}}
|
}}
|
||||||
@@ -182,19 +182,15 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
|||||||
label: <Typography.Text strong>{t('taskInfoTab.comments.title')}</Typography.Text>,
|
label: <Typography.Text strong>{t('taskInfoTab.comments.title')}</Typography.Text>,
|
||||||
style: panelStyle,
|
style: panelStyle,
|
||||||
className: 'custom-task-drawer-info-collapse',
|
className: 'custom-task-drawer-info-collapse',
|
||||||
children: (
|
children: <TaskComments taskId={selectedTaskId || ''} t={t} />,
|
||||||
<TaskComments
|
|
||||||
taskId={selectedTaskId || ''}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Filter out the 'subTasks' item if this task is a subtask
|
// Filter out the 'subTasks' item if this task is more than level 2
|
||||||
const infoItems = taskFormViewModel?.task?.parent_task_id
|
const infoItems =
|
||||||
? allInfoItems.filter(item => item.key !== 'subTasks')
|
(taskFormViewModel?.task?.task_level ?? 0) >= 2
|
||||||
: allInfoItems;
|
? allInfoItems.filter(item => item.key !== 'subTasks')
|
||||||
|
: allInfoItems;
|
||||||
|
|
||||||
const fetchSubTasks = async () => {
|
const fetchSubTasks = async () => {
|
||||||
if (!selectedTaskId || loadingSubTasks) return;
|
if (!selectedTaskId || loadingSubTasks) return;
|
||||||
@@ -281,7 +277,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
|||||||
defaultActiveKey={[
|
defaultActiveKey={[
|
||||||
'details',
|
'details',
|
||||||
'description',
|
'description',
|
||||||
...(taskFormViewModel?.task?.parent_task_id ? [] : ['subTasks']),
|
'subTasks',
|
||||||
'dependencies',
|
'dependencies',
|
||||||
'attachments',
|
'attachments',
|
||||||
'comments',
|
'comments',
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const TaskDrawer = () => {
|
|||||||
const [refreshTimeLogTrigger, setRefreshTimeLogTrigger] = useState(0);
|
const [refreshTimeLogTrigger, setRefreshTimeLogTrigger] = useState(0);
|
||||||
|
|
||||||
const { showTaskDrawer, timeLogEditing } = useAppSelector(state => state.taskDrawerReducer);
|
const { showTaskDrawer, timeLogEditing } = useAppSelector(state => state.taskDrawerReducer);
|
||||||
|
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
||||||
const taskNameInputRef = useRef<InputRef>(null);
|
const taskNameInputRef = useRef<InputRef>(null);
|
||||||
const isClosingManually = useRef(false);
|
const isClosingManually = useRef(false);
|
||||||
|
|
||||||
@@ -47,20 +47,32 @@ const TaskDrawer = () => {
|
|||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const handleOnClose = () => {
|
const resetTaskState = () => {
|
||||||
// Set flag to indicate we're manually closing the drawer
|
|
||||||
isClosingManually.current = true;
|
|
||||||
setActiveTab('info');
|
|
||||||
|
|
||||||
// Explicitly clear the task parameter from URL
|
|
||||||
clearTaskFromUrl();
|
|
||||||
|
|
||||||
// Update the Redux state
|
|
||||||
dispatch(setShowTaskDrawer(false));
|
dispatch(setShowTaskDrawer(false));
|
||||||
dispatch(setSelectedTaskId(null));
|
dispatch(setSelectedTaskId(null));
|
||||||
dispatch(setTaskFormViewModel({}));
|
dispatch(setTaskFormViewModel({}));
|
||||||
dispatch(setTaskSubscribers([]));
|
dispatch(setTaskSubscribers([]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnClose = (
|
||||||
|
e?: React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent<Element>
|
||||||
|
) => {
|
||||||
|
// Set flag to indicate we're manually closing the drawer
|
||||||
|
isClosingManually.current = true;
|
||||||
|
setActiveTab('info');
|
||||||
|
clearTaskFromUrl();
|
||||||
|
|
||||||
|
const isClickOutsideDrawer =
|
||||||
|
e?.target && (e.target as HTMLElement).classList.contains('ant-drawer-mask');
|
||||||
|
|
||||||
|
if (isClickOutsideDrawer || !taskFormViewModel?.task?.is_sub_task) {
|
||||||
|
resetTaskState();
|
||||||
|
} else {
|
||||||
|
dispatch(setSelectedTaskId(null));
|
||||||
|
dispatch(setTaskFormViewModel({}));
|
||||||
|
dispatch(setTaskSubscribers([]));
|
||||||
|
dispatch(setSelectedTaskId(taskFormViewModel?.task?.parent_task_id || null));
|
||||||
|
}
|
||||||
// Reset the flag after a short delay
|
// Reset the flag after a short delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isClosingManually.current = false;
|
isClosingManually.current = false;
|
||||||
@@ -176,8 +188,8 @@ const TaskDrawer = () => {
|
|||||||
// Get conditional body style
|
// Get conditional body style
|
||||||
const getBodyStyle = () => {
|
const getBodyStyle = () => {
|
||||||
const baseStyle = {
|
const baseStyle = {
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
overflow: 'auto'
|
overflow: 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (activeTab === 'timeLog' && timeLogEditing.isEditing) {
|
if (activeTab === 'timeLog' && timeLogEditing.isEditing) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
|
|||||||
import { SocketEvents } from '@/shared/socket-events';
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp } from '@features/project/project.slice';
|
import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp, getProject } from '@features/project/project.slice';
|
||||||
import { addTask, fetchTaskGroups, fetchTaskListColumns, IGroupBy } from '@features/tasks/tasks.slice';
|
import { addTask, fetchTaskGroups, fetchTaskListColumns, IGroupBy } from '@features/tasks/tasks.slice';
|
||||||
import ProjectStatusIcon from '@/components/common/project-status-icon/project-status-icon';
|
import ProjectStatusIcon from '@/components/common/project-status-icon/project-status-icon';
|
||||||
import { formatDate } from '@/utils/timeUtils';
|
import { formatDate } from '@/utils/timeUtils';
|
||||||
@@ -70,6 +70,7 @@ const ProjectViewHeader = () => {
|
|||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
dispatch(getProject(projectId));
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case 'tasks-list':
|
case 'tasks-list':
|
||||||
dispatch(fetchTaskListColumns(projectId));
|
dispatch(fetchTaskListColumns(projectId));
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ const TaskListTaskCell = ({
|
|||||||
isSubTask: boolean,
|
isSubTask: boolean,
|
||||||
subTasksCount: number
|
subTasksCount: number
|
||||||
) => {
|
) => {
|
||||||
if (subTasksCount > 0) {
|
if (subTasksCount > 0 && !isSubTask) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleExpansion(taskId)}
|
onClick={() => handleToggleExpansion(taskId)}
|
||||||
@@ -112,23 +112,21 @@ const TaskListTaskCell = ({
|
|||||||
const renderSubtasksCountLabel = (taskId: string, isSubTask: boolean, subTasksCount: number) => {
|
const renderSubtasksCountLabel = (taskId: string, isSubTask: boolean, subTasksCount: number) => {
|
||||||
if (!taskId) return null;
|
if (!taskId) return null;
|
||||||
return (
|
return (
|
||||||
!isSubTask && (
|
<Button
|
||||||
<Button
|
onClick={() => handleToggleExpansion(taskId)}
|
||||||
onClick={() => handleToggleExpansion(taskId)}
|
size="small"
|
||||||
size="small"
|
style={{
|
||||||
style={{
|
display: 'flex',
|
||||||
display: 'flex',
|
gap: 2,
|
||||||
gap: 2,
|
paddingInline: 4,
|
||||||
paddingInline: 4,
|
alignItems: 'center',
|
||||||
alignItems: 'center',
|
justifyItems: 'center',
|
||||||
justifyItems: 'center',
|
border: 'none',
|
||||||
border: 'none',
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Typography.Text style={{ fontSize: 12, lineHeight: 1 }}>{subTasksCount}</Typography.Text>
|
||||||
<Typography.Text style={{ fontSize: 12, lineHeight: 1 }}>{subTasksCount}</Typography.Text>
|
<DoubleRightOutlined style={{ fontSize: 10 }} />
|
||||||
<DoubleRightOutlined style={{ fontSize: 10 }} />
|
</Button>
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export enum IActivityLogAttributeTypes {
|
|||||||
ATTACHMENT = 'attachment',
|
ATTACHMENT = 'attachment',
|
||||||
COMMENT = 'comment',
|
COMMENT = 'comment',
|
||||||
ARCHIVE = 'archive',
|
ARCHIVE = 'archive',
|
||||||
|
PROGRESS = 'progress',
|
||||||
|
WEIGHT = 'weight',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IActivityLog {
|
export interface IActivityLog {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export interface ITaskViewModel extends ITask {
|
|||||||
task_labels?: ITaskLabel[];
|
task_labels?: ITaskLabel[];
|
||||||
timer_start_time?: number;
|
timer_start_time?: number;
|
||||||
recurring?: boolean;
|
recurring?: boolean;
|
||||||
|
task_level?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITaskTeamMember extends ITeamMember {
|
export interface ITaskTeamMember extends ITeamMember {
|
||||||
|
|||||||
Reference in New Issue
Block a user