Merge pull request #99 from chamikaJ/fix/custom-progress-methods

Fix/custom progress methods
This commit is contained in:
Chamika J
2025-04-30 15:28:50 +05:30
committed by GitHub
39 changed files with 2622 additions and 143 deletions

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

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

View File

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

View File

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

View File

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

View File

@@ -408,6 +408,9 @@ export default class ProjectsController extends WorklenzControllerBase {
sps.color_code AS status_color,
sps.icon AS status_icon,
(SELECT name FROM clients WHERE id = projects.client_id) AS client_name,
projects.use_manual_progress,
projects.use_weighted_progress,
projects.use_time_progress,
(SELECT COALESCE(ROW_TO_JSON(pm), '{}'::JSON)
FROM (SELECT team_member_id AS id,

View File

@@ -32,7 +32,51 @@ export default class TasksControllerBase extends WorklenzControllerBase {
}
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.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)
task.timer_start_time = moment(task.timer_start_time).valueOf();
// Set completed_count and total_tasks_count regardless of progress calculation method
const totalCompleted = (+task.completed_sub_tasks + +task.parent_task_completed) || 0;
const totalTasks = +task.sub_tasks_count || 0; // if needed add +1 for parent
task.complete_ratio = TasksControllerBase.calculateTaskCompleteRatio(totalCompleted, totalTasks);
const totalTasks = +task.sub_tasks_count || 0;
task.completed_count = totalCompleted;
task.total_tasks_count = totalTasks;

View File

@@ -192,6 +192,12 @@ export default class TasksControllerV2 extends TasksControllerBase {
t.archived,
t.description,
t.sort_order,
t.progress_value,
t.manual_progress,
t.weight,
(SELECT use_manual_progress FROM projects WHERE id = t.project_id) AS project_use_manual_progress,
(SELECT use_weighted_progress FROM projects WHERE id = t.project_id) AS project_use_weighted_progress,
(SELECT use_time_progress FROM projects WHERE id = t.project_id) AS project_use_time_progress,
(SELECT phase_id FROM task_phase WHERE task_id = t.id) AS phase_id,
(SELECT name
@@ -334,7 +340,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
return g;
}, {});
this.updateMapByGroup(tasks, groupBy, map);
await this.updateMapByGroup(tasks, groupBy, map);
const updatedGroups = Object.keys(map).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));
}
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;
const unmapped = [];
for (const task of tasks) {
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);
if (groupBy === GroupBy.STATUS) {
map[task.status]?.tasks.push(task);
@@ -395,7 +417,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
@HandleExceptions()
public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const isSubTasks = !!req.query.parent_task;
// Add customColumns flag to query params
req.query.customColumns = "true";
@@ -410,7 +432,24 @@ export default class TasksControllerV2 extends TasksControllerBase {
[data] = result.rows;
} else { // else we return a flat list of tasks
data = [...result.rows];
for (const task of data) {
// For tasks with subtasks, get the complete ratio from the database function
if (task.sub_tasks_count > 0) {
try {
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [task.id]);
const [ratioData] = result.rows;
if (ratioData && ratioData.info) {
task.complete_ratio = +ratioData.info.ratio.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);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,105 @@
import { Socket } from "socket.io";
import db from "../../config/db";
import { SocketEvents } from "../events";
import { log, log_error, notifyProjectUpdates } from "../util";
import { logProgressChange } from "../../services/activity-logs/activity-logs.service";
interface UpdateTaskProgressData {
task_id: string;
progress_value: number;
parent_task_id: string | null;
}
export async function on_update_task_progress(io: any, socket: Socket, data: string) {
try {
log(socket.id, `${SocketEvents.UPDATE_TASK_PROGRESS}: ${data}`);
const parsedData = JSON.parse(data) as UpdateTaskProgressData;
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) {
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
await db.query(
`UPDATE tasks
SET progress_value = $1, manual_progress = true, updated_at = NOW()
WHERE id = $2`,
[progress_value, task_id]
);
// Log the progress change using the activity logs service
await logProgressChange({
task_id,
old_value: currentProgress !== null ? currentProgress.toString() : '0',
new_value: progress_value.toString(),
socket
});
if (projectId) {
// Emit the update to all clients in the project room
io.to(projectId).emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id,
progress_value
}
);
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 (parent_task_id) {
const progressRatio = await db.query(
"SELECT get_task_complete_ratio($1) as ratio",
[parent_task_id]
);
console.log(`Updated parent task ${parent_task_id} progress: ${progressRatio?.rows[0]?.ratio}`);
// Emit the parent task's updated progress
io.to(projectId).emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id: parent_task_id,
progress_value: progressRatio?.rows[0]?.ratio
}
);
}
// Notify that project updates are available
notifyProjectUpdates(socket, task_id);
}
} catch (error) {
log_error(error);
}
}

View File

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

View File

@@ -57,4 +57,10 @@ export enum SocketEvents {
TASK_ASSIGNEES_CHANGE,
TASK_CUSTOM_COLUMN_UPDATE,
CUSTOM_COLUMN_PINNED_CHANGE,
TEAM_MEMBER_ROLE_CHANGE,
// Task progress events
UPDATE_TASK_PROGRESS,
UPDATE_TASK_WEIGHT,
TASK_PROGRESS_UPDATED,
}

View File

@@ -52,6 +52,8 @@ import { on_task_recurring_change } from "./commands/on-task-recurring-change";
import { on_task_assignees_change } from "./commands/on-task-assignees-change";
import { on_task_custom_column_update } from "./commands/on_custom_column_update";
import { on_custom_column_pinned_change } from "./commands/on_custom_column_pinned_change";
import { on_update_task_progress } from "./commands/on-update-task-progress";
import { on_update_task_weight } from "./commands/on-update-task-weight";
export function register(io: any, socket: Socket) {
log(socket.id, "client registered");
@@ -106,6 +108,8 @@ export function register(io: any, socket: Socket) {
socket.on(SocketEvents.TASK_ASSIGNEES_CHANGE.toString(), data => on_task_assignees_change(io, socket, data));
socket.on(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), data => on_task_custom_column_update(io, socket, data));
socket.on(SocketEvents.CUSTOM_COLUMN_PINNED_CHANGE.toString(), data => on_custom_column_pinned_change(io, socket, data));
socket.on(SocketEvents.UPDATE_TASK_PROGRESS.toString(), data => on_update_task_progress(io, socket, data));
socket.on(SocketEvents.UPDATE_TASK_WEIGHT.toString(), data => on_update_task_weight(io, socket, data));
// socket.io built-in event
socket.on("disconnect", (reason) => on_disconnect(io, socket, reason));

View File

@@ -38,5 +38,14 @@
"createClient": "Create client",
"searchInputPlaceholder": "Search by name or email",
"hoursPerDayValidationMessage": "Hours per day must be a number between 1 and 24",
"noPermission": "No permission"
"workingDaysValidationMessage": "Working days must be a positive number",
"manDaysValidationMessage": "Man days must be a positive number",
"noPermission": "No permission",
"progressSettings": "Progress Settings",
"manualProgress": "Manual Progress",
"manualProgressTooltip": "Allow manual progress updates for tasks without subtasks",
"weightedProgress": "Weighted Progress",
"weightedProgressTooltip": "Calculate progress based on subtask weights",
"timeProgress": "Time-based Progress",
"timeProgressTooltip": "Calculate progress based on estimated time"
}

View File

@@ -22,7 +22,15 @@
"hide-start-date": "Hide Start Date",
"show-start-date": "Show Start Date",
"hours": "Hours",
"minutes": "Minutes"
"minutes": "Minutes",
"progressValue": "Progress Value",
"progressValueTooltip": "Set the progress percentage (0-100%)",
"progressValueRequired": "Please enter a progress value",
"progressValueRange": "Progress must be between 0 and 100",
"taskWeight": "Task Weight",
"taskWeightTooltip": "Set the weight of this subtask (percentage)",
"taskWeightRequired": "Please enter a task weight",
"taskWeightRange": "Weight must be between 0 and 100"
},
"labels": {
"labelInputPlaceholder": "Search or create",

View File

@@ -38,5 +38,14 @@
"createClient": "Crear cliente",
"searchInputPlaceholder": "Busca por nombre o email",
"hoursPerDayValidationMessage": "Las horas por día deben ser un número entre 1 y 24",
"noPermission": "Sin permiso"
"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",
"progressSettings": "Configuración de Progreso",
"manualProgress": "Progreso Manual",
"manualProgressTooltip": "Permitir actualizaciones manuales de progreso para tareas sin subtareas",
"weightedProgress": "Progreso Ponderado",
"weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas",
"timeProgress": "Progreso Basado en Tiempo",
"timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado"
}

View File

@@ -22,7 +22,15 @@
"hide-start-date": "Ocultar fecha de inicio",
"show-start-date": "Mostrar fecha de inicio",
"hours": "Horas",
"minutes": "Minutos"
"minutes": "Minutos",
"progressValue": "Valor de Progreso",
"progressValueTooltip": "Establecer el porcentaje de progreso (0-100%)",
"progressValueRequired": "Por favor, introduce un valor de progreso",
"progressValueRange": "El progreso debe estar entre 0 y 100",
"taskWeight": "Peso de la Tarea",
"taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)",
"taskWeightRequired": "Por favor, introduce un peso para la tarea",
"taskWeightRange": "El peso debe estar entre 0 y 100"
},
"labels": {
"labelInputPlaceholder": "Buscar o crear",

View File

@@ -38,5 +38,14 @@
"createClient": "Criar cliente",
"searchInputPlaceholder": "Pesquise por nome ou email",
"hoursPerDayValidationMessage": "As horas por dia devem ser um número entre 1 e 24",
"noPermission": "Sem permissão"
"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",
"progressSettings": "Configurações de Progresso",
"manualProgress": "Progresso Manual",
"manualProgressTooltip": "Permitir atualizações manuais de progresso para tarefas sem subtarefas",
"weightedProgress": "Progresso Ponderado",
"weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas",
"timeProgress": "Progresso Baseado em Tempo",
"timeProgressTooltip": "Calcular o progresso com base no tempo estimado"
}

View File

@@ -22,7 +22,15 @@
"hide-start-date": "Ocultar data de início",
"show-start-date": "Mostrar data de início",
"hours": "Horas",
"minutes": "Minutos"
"minutes": "Minutos",
"progressValue": "Valor de Progresso",
"progressValueTooltip": "Definir a porcentagem de progresso (0-100%)",
"progressValueRequired": "Por favor, insira um valor de progresso",
"progressValueRange": "O progresso deve estar entre 0 e 100",
"taskWeight": "Peso da Tarefa",
"taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)",
"taskWeightRequired": "Por favor, insira um peso para a tarefa",
"taskWeightRange": "O peso deve estar entre 0 e 100"
},
"labels": {
"labelInputPlaceholder": "Pesquisar ou criar",

View File

@@ -10,6 +10,11 @@ import { IProjectManager } from '@/types/project/projectManager.types';
const rootUrl = `${API_BASE_URL}/projects`;
interface UpdateProjectPayload {
id: string;
[key: string]: any;
}
export const projectsApiService = {
getProjects: async (
index: number,
@@ -78,13 +83,11 @@ export const projectsApiService = {
return response.data;
},
updateProject: async (
id: string,
project: IProjectViewModel
): Promise<IServerResponse<IProjectViewModel>> => {
updateProject: async (payload: UpdateProjectPayload): Promise<IServerResponse<IProjectViewModel>> => {
const { id, ...data } = payload;
const q = toQueryString({ current_project_id: id });
const url = `${rootUrl}/${id}${q}`;
const response = await apiClient.put<IServerResponse<IProjectViewModel>>(`${url}`, project);
const url = `${API_BASE_URL}/projects/${id}${q}`;
const response = await apiClient.patch<IServerResponse<IProjectViewModel>>(url, data);
return response.data;
},

View File

@@ -14,6 +14,7 @@ import {
Popconfirm,
Skeleton,
Space,
Switch,
Tooltip,
Typography,
} from 'antd';
@@ -46,7 +47,11 @@ import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
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 { useAuthService } from '@/hooks/useAuth';
import { evt_projects_create } from '@/shared/worklenz-analytics-events';
@@ -60,7 +65,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState<boolean>(true);
const currentSession = useAuthService().getCurrentSession();
// State
const [editMode, setEditMode] = useState<boolean>(false);
const [selectedProjectManager, setSelectedProjectManager] = useState<ITeamMemberViewModel | null>(
@@ -96,6 +101,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
working_days: project?.working_days || 0,
man_days: project?.man_days || 0,
hours_per_day: project?.hours_per_day || 8,
use_manual_progress: project?.use_manual_progress || false,
use_weighted_progress: project?.use_weighted_progress || false,
use_time_progress: project?.use_time_progress || false,
}),
[project, projectStatuses, projectHealths]
);
@@ -155,6 +163,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
man_days: parseInt(values.man_days),
hours_per_day: parseInt(values.hours_per_day),
project_manager: selectedProjectManager,
use_manual_progress: values.use_manual_progress || false,
use_weighted_progress: values.use_weighted_progress || false,
use_time_progress: values.use_time_progress || false,
};
const action =
@@ -169,7 +180,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
dispatch(toggleProjectDrawer());
if (!editMode) {
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();
window.location.reload(); // Refresh the page
@@ -184,8 +197,17 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
logger.error('Error saving project', error);
}
};
const calculateWorkingDays = (startDate: dayjs.Dayjs | null, endDate: dayjs.Dayjs | null): number => {
if (!startDate || !endDate || !startDate.isValid() || !endDate.isValid() || startDate.isAfter(endDate)) {
const calculateWorkingDays = (
startDate: dayjs.Dayjs | null,
endDate: dayjs.Dayjs | null
): number => {
if (
!startDate ||
!endDate ||
!startDate.isValid() ||
!endDate.isValid() ||
startDate.isAfter(endDate)
) {
return 0;
}
@@ -213,7 +235,16 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
...project,
start_date: project.start_date ? dayjs(project.start_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_weighted_progress: project.use_weighted_progress || false,
use_time_progress: project.use_time_progress || false,
});
setSelectedProjectManager(project.project_manager || null);
setLoading(false);
@@ -284,6 +315,49 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
setIsFormValid(isValid);
};
// Progress calculation method handlers
const handleManualProgressChange = (checked: boolean) => {
if (checked) {
form.setFieldsValue({
use_manual_progress: true,
use_weighted_progress: false,
use_time_progress: false,
});
} else {
form.setFieldsValue({
use_manual_progress: false,
});
}
};
const handleWeightedProgressChange = (checked: boolean) => {
if (checked) {
form.setFieldsValue({
use_manual_progress: false,
use_weighted_progress: true,
use_time_progress: false,
});
} else {
form.setFieldsValue({
use_weighted_progress: false,
});
}
};
const handleTimeProgressChange = (checked: boolean) => {
if (checked) {
form.setFieldsValue({
use_manual_progress: false,
use_weighted_progress: false,
use_time_progress: true,
});
} else {
form.setFieldsValue({
use_time_progress: false,
});
}
};
return (
<Drawer
// loading={loading}
@@ -329,12 +403,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
}
>
{!isEditable && (
<Alert
message={t('noPermission')}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
<Alert message={t('noPermission')} type="warning" showIcon style={{ marginBottom: 16 }} />
)}
<Skeleton active paragraph={{ rows: 12 }} loading={projectLoading}>
<Form
@@ -395,14 +464,11 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
<Form.Item name="date" layout="horizontal">
<Flex gap={8}>
<Form.Item
name="start_date"
label={t('startDate')}
>
<Form.Item name="start_date" label={t('startDate')}>
<DatePicker
disabledDate={disabledStartDate}
disabled={!isProjectManager && !isOwnerorAdmin}
onChange={(date) => {
onChange={date => {
const endDate = form.getFieldValue('end_date');
if (date && endDate) {
const days = calculateWorkingDays(date, endDate);
@@ -411,14 +477,11 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
}}
/>
</Form.Item>
<Form.Item
name="end_date"
label={t('endDate')}
>
<Form.Item name="end_date" label={t('endDate')}>
<DatePicker
disabledDate={disabledEndDate}
disabled={!isProjectManager && !isOwnerorAdmin}
onChange={(date) => {
onChange={date => {
const startDate = form.getFieldValue('start_date');
if (startDate && date) {
const days = calculateWorkingDays(startDate, date);
@@ -429,22 +492,51 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
</Form.Item>
</Flex>
</Form.Item>
{/* <Form.Item
<Form.Item
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
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"
disabled // Make it read-only since it's calculated
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 name="working_days" label={t('estimateWorkingDays')}>
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
</Form.Item>
<Form.Item name="man_days" label={t('estimateManDays')}>
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
</Form.Item>
<Form.Item
name="hours_per_day"
label={t('hoursPerDay')}
@@ -454,12 +546,80 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
if (value === undefined || (value >= 0 && value <= 24)) {
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>
<Divider orientation="left">{t('progressSettings')}</Divider>
<Form.Item
name="use_manual_progress"
label={
<Space>
<Typography.Text>{t('manualProgress')}</Typography.Text>
<Tooltip title={t('manualProgressTooltip')}>
<Button type="text" size="small" icon={<Typography.Text></Typography.Text>} />
</Tooltip>
</Space>
}
valuePropName="checked"
>
<Switch
onChange={handleManualProgressChange}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
</Form.Item>
<Form.Item
name="use_weighted_progress"
label={
<Space>
<Typography.Text>{t('weightedProgress')}</Typography.Text>
<Tooltip title={t('weightedProgressTooltip')}>
<Button type="text" size="small" icon={<Typography.Text></Typography.Text>} />
</Tooltip>
</Space>
}
valuePropName="checked"
>
<Switch
onChange={handleWeightedProgressChange}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
</Form.Item>
<Form.Item
name="use_time_progress"
label={
<Space>
<Typography.Text>{t('timeProgress')}</Typography.Text>
<Tooltip title={t('timeProgressTooltip')}>
<Button type="text" size="small" icon={<Typography.Text></Typography.Text>} />
</Tooltip>
</Space>
}
valuePropName="checked"
>
<Switch
onChange={handleTimeProgressChange}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
</Form.Item>
</Form>

View File

@@ -111,6 +111,32 @@ const TaskDrawerActivityLog = () => {
</Tag>
</Flex>
);
case IActivityLogAttributeTypes.PROGRESS:
return (
<Flex gap={4} align="center">
<Tag color="blue">
{activity.previous || '0'}%
</Tag>
<ArrowRightOutlined />&nbsp;
<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 />&nbsp;
<Tag color="purple">
Weight: {activity.current || '100'}
</Tag>
</Flex>
);
default:
return (

View File

@@ -0,0 +1,189 @@
import { Form, InputNumber, Tooltip } from 'antd';
import { useTranslation } from 'react-i18next';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { useAppSelector } from '@/hooks/useAppSelector';
import { ITaskViewModel } from '@/types/tasks/task.types';
import Flex from 'antd/lib/flex';
import { SocketEvents } from '@/shared/socket-events';
import { useEffect } from 'react';
import { useSocket } from '@/socket/socketContext';
interface TaskDrawerProgressProps {
task: ITaskViewModel;
form: any;
}
const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
const { t } = useTranslation('task-drawer/task-drawer');
const { project } = useAppSelector(state => state.projectReducer);
const { socket, connected } = useSocket();
const isSubTask = !!task?.parent_task_id;
const hasSubTasks = task?.sub_tasks_count > 0;
// Show manual progress input only for tasks without subtasks (not parent tasks)
// 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;
useEffect(() => {
// Listen for progress updates from the server
const handleProgressUpdate = (data: any) => {
if (data.task_id === task.id) {
if (data.progress_value !== undefined) {
form.setFieldsValue({ progress_value: data.progress_value });
}
if (data.weight !== undefined) {
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 () => {
socket?.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleProgressUpdate);
};
}, [socket, connected, task.id, form]);
const handleProgressChange = (value: number | null) => {
if (connected && task.id && value !== null) {
// Ensure parent_task_id is not undefined
const parent_task_id = task.parent_task_id || null;
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) => {
if (connected && task.id && value !== null) {
// Ensure parent_task_id is not undefined
const parent_task_id = task.parent_task_id || null;
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 percentParser = (value: string | undefined) => {
const parsed = parseInt(value?.replace('%', '') || '0', 10);
return isNaN(parsed) ? 0 : parsed;
};
if (!showManualProgressInput && !showTaskWeightInput) {
return null; // Don't show any progress inputs if not applicable
}
return (
<>
{showTaskWeightInput && (
<Form.Item
name="weight"
label={
<Flex align="center" gap={4}>
{t('taskInfoTab.details.taskWeight')}
<Tooltip title={t('taskInfoTab.details.taskWeightTooltip')}>
<QuestionCircleOutlined />
</Tooltip>
</Flex>
}
rules={[
{
type: 'number',
min: 0,
max: 100,
message: t('taskInfoTab.details.taskWeightRange'),
},
]}
>
<InputNumber
min={0}
max={100}
formatter={percentFormatter}
parser={percentParser}
onBlur={e => {
const value = percentParser(e.target.value);
handleWeightChange(value);
}}
/>
</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;

View File

@@ -210,10 +210,11 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText="Yes"
cancelText="No"
onConfirm={() => handleDeleteSubTask(record.id)}
onPopupClick={(e) => e.stopPropagation()}
onConfirm={(e) => {handleDeleteSubTask(record.id)}}
>
<Tooltip title="Delete">
<Button shape="default" icon={<DeleteOutlined />} size="small" />
<Button shape="default" icon={<DeleteOutlined />} size="small" onClick={(e)=> e.stopPropagation()} />
</Tooltip>
</Popconfirm>
</Flex>

View File

@@ -26,6 +26,8 @@ import TaskDrawerDueDate from './details/task-drawer-due-date/task-drawer-due-da
import TaskDrawerEstimation from './details/task-drawer-estimation/task-drawer-estimation';
import TaskDrawerPrioritySelector from './details/task-drawer-priority-selector/task-drawer-priority-selector';
import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billable';
import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progress';
import { useAppSelector } from '@/hooks/useAppSelector';
interface TaskDetailsFormProps {
taskFormViewModel?: ITaskFormViewModel | null;
@@ -34,6 +36,7 @@ interface TaskDetailsFormProps {
const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => {
const { t } = useTranslation('task-drawer/task-drawer');
const [form] = Form.useForm();
const { project } = useAppSelector(state => state.projectReducer);
useEffect(() => {
if (!taskFormViewModel) {
@@ -53,6 +56,8 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
labels: task?.labels || [],
billable: task?.billable || false,
notify: [],
progress_value: task?.progress_value || null,
weight: task?.weight || null,
});
}, [taskFormViewModel, form]);
@@ -89,6 +94,8 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
hours: 0,
minutes: 0,
billable: false,
progress_value: null,
weight: null,
}}
onFinish={handleSubmit}
>
@@ -103,7 +110,7 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
<Form.Item name="assignees" label={t('taskInfoTab.details.assignees')}>
<Flex gap={4} align="center">
<Avatars members={taskFormViewModel?.task?.names || []} />
<Avatars members={taskFormViewModel?.task?.assignee_names || []} />
<TaskDrawerAssigneeSelector
task={(taskFormViewModel?.task as ITaskViewModel) || null}
/>
@@ -114,6 +121,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
<TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} />
{(project?.use_manual_progress || project?.use_weighted_progress) && (taskFormViewModel?.task) && (
<TaskDrawerProgress task={taskFormViewModel?.task as ITaskViewModel} form={form} />
)}
<Form.Item name="priority" label={t('taskInfoTab.details.priority')}>
<TaskDrawerPrioritySelector task={taskFormViewModel?.task as ITaskViewModel} />
</Form.Item>

View File

@@ -125,7 +125,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
<Button
shape="circle"
icon={<ReloadOutlined spin={loadingSubTasks} />}
onClick={(e) => {
onClick={e => {
e.stopPropagation(); // Prevent click from bubbling up
fetchSubTasks();
}}
@@ -182,19 +182,15 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
label: <Typography.Text strong>{t('taskInfoTab.comments.title')}</Typography.Text>,
style: panelStyle,
className: 'custom-task-drawer-info-collapse',
children: (
<TaskComments
taskId={selectedTaskId || ''}
t={t}
/>
),
children: <TaskComments taskId={selectedTaskId || ''} t={t} />,
},
];
// Filter out the 'subTasks' item if this task is a subtask
const infoItems = taskFormViewModel?.task?.parent_task_id
? allInfoItems.filter(item => item.key !== 'subTasks')
: allInfoItems;
// Filter out the 'subTasks' item if this task is more than level 2
const infoItems =
(taskFormViewModel?.task?.task_level ?? 0) >= 2
? allInfoItems.filter(item => item.key !== 'subTasks')
: allInfoItems;
const fetchSubTasks = async () => {
if (!selectedTaskId || loadingSubTasks) return;
@@ -281,7 +277,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
defaultActiveKey={[
'details',
'description',
...(taskFormViewModel?.task?.parent_task_id ? [] : ['subTasks']),
'subTasks',
'dependencies',
'attachments',
'comments',

View File

@@ -32,7 +32,7 @@ const TaskDrawer = () => {
const [refreshTimeLogTrigger, setRefreshTimeLogTrigger] = useState(0);
const { showTaskDrawer, timeLogEditing } = useAppSelector(state => state.taskDrawerReducer);
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
const taskNameInputRef = useRef<InputRef>(null);
const isClosingManually = useRef(false);
@@ -47,20 +47,32 @@ const TaskDrawer = () => {
const dispatch = useAppDispatch();
const handleOnClose = () => {
// 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
const resetTaskState = () => {
dispatch(setShowTaskDrawer(false));
dispatch(setSelectedTaskId(null));
dispatch(setTaskFormViewModel({}));
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
setTimeout(() => {
isClosingManually.current = false;
@@ -176,8 +188,8 @@ const TaskDrawer = () => {
// Get conditional body style
const getBodyStyle = () => {
const baseStyle = {
padding: '24px',
overflow: 'auto'
padding: '24px',
overflow: 'auto',
};
if (activeTab === 'timeLog' && timeLogEditing.isEditing) {

View File

@@ -55,10 +55,9 @@ const initialState: TaskListState = {
export const getProject = createAsyncThunk(
'project/getProject',
async (projectId: string, { rejectWithValue, dispatch }) => {
async (projectId: string, { rejectWithValue }) => {
try {
const response = await projectsApiService.getProject(projectId);
dispatch(setProject(response.body));
return response.body;
} catch (error) {
return rejectWithValue(error instanceof Error ? error.message : 'Failed to fetch project');

View File

@@ -0,0 +1,42 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { projectsApiService } from '@/api/projects/projects.api.service';
interface UpdateProjectPayload {
id: string;
[key: string]: any;
}
export const projectsSlice = createSlice({
name: 'projects',
initialState: {
loading: false,
error: null,
},
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
},
},
});
// Export actions
export const { setLoading, setError } = projectsSlice.actions;
// Async thunks
export const updateProject = (payload: UpdateProjectPayload) => async (dispatch: any) => {
try {
dispatch(setLoading(true));
const response = await projectsApiService.updateProject(payload);
dispatch(setLoading(false));
return response;
} catch (error) {
dispatch(setError((error as Error).message));
dispatch(setLoading(false));
throw error;
}
};
export default projectsSlice.reducer;

View File

@@ -22,7 +22,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth';
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 ProjectStatusIcon from '@/components/common/project-status-icon/project-status-icon';
import { formatDate } from '@/utils/timeUtils';
@@ -70,6 +70,7 @@ const ProjectViewHeader = () => {
const handleRefresh = () => {
if (!projectId) return;
dispatch(getProject(projectId));
switch (tab) {
case 'tasks-list':
dispatch(fetchTaskListColumns(projectId));

View File

@@ -377,6 +377,41 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
};
}, [socket, dispatch]);
// Socket handler for task progress updates
useEffect(() => {
if (!socket) return;
const handleTaskProgressUpdated = (data: {
task_id: string;
progress_value?: number;
weight?: number;
}) => {
if (data.progress_value !== undefined) {
// Find the task in the task groups and update its progress
for (const group of taskGroups) {
const task = group.tasks.find(task => task.id === data.task_id);
if (task) {
dispatch(
updateTaskProgress({
taskId: data.task_id,
progress: data.progress_value,
totalTasksCount: task.total_tasks_count || 0,
completedCount: task.completed_count || 0,
})
);
break;
}
}
}
};
socket.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated);
return () => {
socket.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated);
};
}, [socket, dispatch, taskGroups]);
const handleDragStart = useCallback(({ active }: DragStartEvent) => {
setActiveId(active.id as string);

View File

@@ -86,7 +86,7 @@ const TaskListTaskCell = ({
isSubTask: boolean,
subTasksCount: number
) => {
if (subTasksCount > 0) {
if (subTasksCount > 0 && !isSubTask) {
return (
<button
onClick={() => handleToggleExpansion(taskId)}
@@ -112,23 +112,21 @@ const TaskListTaskCell = ({
const renderSubtasksCountLabel = (taskId: string, isSubTask: boolean, subTasksCount: number) => {
if (!taskId) return null;
return (
!isSubTask && (
<Button
onClick={() => handleToggleExpansion(taskId)}
size="small"
style={{
display: 'flex',
gap: 2,
paddingInline: 4,
alignItems: 'center',
justifyItems: 'center',
border: 'none',
}}
>
<Typography.Text style={{ fontSize: 12, lineHeight: 1 }}>{subTasksCount}</Typography.Text>
<DoubleRightOutlined style={{ fontSize: 10 }} />
</Button>
)
<Button
onClick={() => handleToggleExpansion(taskId)}
size="small"
style={{
display: 'flex',
gap: 2,
paddingInline: 4,
alignItems: 'center',
justifyItems: 'center',
border: 'none',
}}
>
<Typography.Text style={{ fontSize: 12, lineHeight: 1 }}>{subTasksCount}</Typography.Text>
<DoubleRightOutlined style={{ fontSize: 10 }} />
</Button>
);
};

View File

@@ -58,4 +58,9 @@ export enum SocketEvents {
TASK_CUSTOM_COLUMN_UPDATE,
CUSTOM_COLUMN_PINNED_CHANGE,
TEAM_MEMBER_ROLE_CHANGE,
// Task progress events
UPDATE_TASK_PROGRESS,
UPDATE_TASK_WEIGHT,
TASK_PROGRESS_UPDATED,
}

View File

@@ -0,0 +1,11 @@
export interface IProjectViewModel {
id: string;
name: string;
description: string;
team_id: string;
created_at: string;
updated_at: string;
use_manual_progress: boolean;
use_weighted_progress: boolean;
use_time_progress: boolean;
}

View File

@@ -41,4 +41,28 @@ export interface IProjectViewModel extends IProject {
team_member_default_view?: string;
working_days?: number;
id?: string;
name?: string;
description?: string;
notes?: string;
color_code?: string;
status_id?: string;
status_name?: string;
status_color_dark?: string;
health_name?: string;
health_color?: string;
health_color_dark?: string;
category_color_dark?: string;
client_id?: string;
total_tasks?: number;
completed_tasks?: number;
tasks_progress?: number;
man_days?: number;
hours_per_day?: number;
default_view?: string;
task_key_prefix?: string;
use_manual_progress?: boolean;
use_weighted_progress?: boolean;
use_time_progress?: boolean;
}

View File

@@ -31,6 +31,8 @@ export enum IActivityLogAttributeTypes {
ATTACHMENT = 'attachment',
COMMENT = 'comment',
ARCHIVE = 'archive',
PROGRESS = 'progress',
WEIGHT = 'weight',
}
export interface IActivityLog {

View File

@@ -17,42 +17,28 @@ export interface ITaskAssignee {
}
export interface ITask {
assignees?: ITaskAssignee[] | string[];
assignees_ids?: any[];
description?: string;
done?: boolean;
end?: string | Date;
end_date?: string | Date;
id?: string;
name?: string;
resize_valid?: boolean;
start?: string | Date;
start_date?: string | Date;
_start?: Date;
_end?: Date;
color_code?: string;
priority?: string;
priority_id?: string;
status?: string;
status_id?: string;
project_id?: string;
reporter_id?: string;
created_at?: string;
updated_at?: string;
show_handles?: boolean;
min?: number;
max?: number;
total_hours?: number;
total_minutes?: number;
name_color?: string;
sub_tasks_count?: number;
is_sub_task?: boolean;
parent_task_name?: string;
parent_task_id?: string;
show_sub_tasks?: boolean;
sub_tasks?: ISubTask[];
archived?: boolean;
subscribers?: IUser[];
id: string;
name: string;
description: string;
status_id: string;
priority: string;
start_date: string;
end_date: string;
total_hours: number;
total_minutes: number;
billable: boolean;
phase_id: string;
parent_task_id: string | null;
project_id: string;
team_id: string;
task_key: string;
labels: string[];
assignees: string[];
names: string[];
sub_tasks_count: number;
manual_progress: boolean;
progress_value: number | null;
weight: number | null;
}
export interface IProjectMemberViewModel extends IProjectMember {
@@ -65,24 +51,19 @@ export interface IProjectMemberViewModel extends IProjectMember {
}
export interface ITaskViewModel extends ITask {
task_key?: string;
created_from_now?: string;
updated_from_now?: string;
reporter?: string;
start_date?: string;
end_date?: string;
sub_tasks_count?: number;
is_sub_task?: boolean;
status_color?: string;
status_color_dark?: string;
attachments_count?: number;
complete_ratio?: number;
names?: InlineMember[];
labels?: ITaskLabel[];
assignee_names?: InlineMember[];
task_labels?: ITaskLabel[];
timer_start_time?: number;
phase_id?: string;
billable?: boolean;
recurring?: boolean;
task_level?: number;
}
export interface ITaskTeamMember extends ITeamMember {