Implement manual and weighted progress features for tasks
- Added SQL migrations to support manual progress and weighted progress calculations in tasks. - Updated the `get_task_complete_ratio` function to consider manual progress and subtask weights. - Enhanced the project model to include flags for manual, weighted, and time-based progress. - Integrated new progress settings in the project drawer and task drawer components. - Implemented socket events for real-time updates on task progress and weight changes. - Updated frontend localization files to include new progress-related terms and tooltips.
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
-- 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;
|
||||||
|
|
||||||
|
-- Update function to consider manual progress
|
||||||
|
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS
|
||||||
|
$$
|
||||||
|
DECLARE
|
||||||
|
_parent_task_done FLOAT = 0;
|
||||||
|
_sub_tasks_done FLOAT = 0;
|
||||||
|
_sub_tasks_count FLOAT = 0;
|
||||||
|
_total_completed FLOAT = 0;
|
||||||
|
_total_tasks FLOAT = 0;
|
||||||
|
_ratio FLOAT = 0;
|
||||||
|
_is_manual BOOLEAN = FALSE;
|
||||||
|
_manual_value INTEGER = NULL;
|
||||||
|
BEGIN
|
||||||
|
-- Check if manual progress is set
|
||||||
|
SELECT manual_progress, progress_value
|
||||||
|
FROM tasks
|
||||||
|
WHERE id = _task_id
|
||||||
|
INTO _is_manual, _manual_value;
|
||||||
|
|
||||||
|
-- If manual progress is enabled and has a value, use it directly
|
||||||
|
IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN
|
||||||
|
RETURN JSON_BUILD_OBJECT(
|
||||||
|
'ratio', _manual_value,
|
||||||
|
'total_completed', 0,
|
||||||
|
'total_tasks', 0,
|
||||||
|
'is_manual', TRUE
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Otherwise calculate automatically as before
|
||||||
|
SELECT (CASE
|
||||||
|
WHEN EXISTS(SELECT 1
|
||||||
|
FROM tasks_with_status_view
|
||||||
|
WHERE tasks_with_status_view.task_id = _task_id
|
||||||
|
AND is_done IS TRUE) THEN 1
|
||||||
|
ELSE 0 END)
|
||||||
|
INTO _parent_task_done;
|
||||||
|
SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id AND archived IS FALSE INTO _sub_tasks_count;
|
||||||
|
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM tasks_with_status_view
|
||||||
|
WHERE parent_task_id = _task_id
|
||||||
|
AND is_done IS TRUE
|
||||||
|
INTO _sub_tasks_done;
|
||||||
|
|
||||||
|
_total_completed = _parent_task_done + _sub_tasks_done;
|
||||||
|
_total_tasks = _sub_tasks_count; -- +1 for the parent task
|
||||||
|
|
||||||
|
IF _total_tasks > 0 THEN
|
||||||
|
_ratio = (_total_completed / _total_tasks) * 100;
|
||||||
|
ELSE
|
||||||
|
_ratio = _parent_task_done * 100;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN JSON_BUILD_OBJECT(
|
||||||
|
'ratio', _ratio,
|
||||||
|
'total_completed', _total_completed,
|
||||||
|
'total_tasks', _total_tasks,
|
||||||
|
'is_manual', FALSE
|
||||||
|
);
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
-- 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
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- 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;
|
||||||
@@ -408,6 +408,9 @@ export default class ProjectsController extends WorklenzControllerBase {
|
|||||||
sps.color_code AS status_color,
|
sps.color_code AS status_color,
|
||||||
sps.icon AS status_icon,
|
sps.icon AS status_icon,
|
||||||
(SELECT name FROM clients WHERE id = projects.client_id) AS client_name,
|
(SELECT name FROM clients WHERE id = projects.client_id) AS client_name,
|
||||||
|
projects.use_manual_progress,
|
||||||
|
projects.use_weighted_progress,
|
||||||
|
projects.use_time_progress,
|
||||||
|
|
||||||
(SELECT COALESCE(ROW_TO_JSON(pm), '{}'::JSON)
|
(SELECT COALESCE(ROW_TO_JSON(pm), '{}'::JSON)
|
||||||
FROM (SELECT team_member_id AS id,
|
FROM (SELECT team_member_id AS id,
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Socket } from "socket.io";
|
||||||
|
import db from "../../config/db";
|
||||||
|
import { SocketEvents } from "../events";
|
||||||
|
import { log, log_error, notifyProjectUpdates } from "../util";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (!task_id || progress_value === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the project ID for the task
|
||||||
|
const projectResult = await db.query("SELECT project_id FROM tasks WHERE id = $1", [task_id]);
|
||||||
|
const projectId = projectResult.rows[0]?.project_id;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Socket } from "socket.io";
|
||||||
|
import db from "../../config/db";
|
||||||
|
import { SocketEvents } from "../events";
|
||||||
|
import { log, log_error, notifyProjectUpdates } from "../util";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
log(socket.id, `${SocketEvents.UPDATE_TASK_WEIGHT}: ${data}`);
|
||||||
|
|
||||||
|
const parsedData = JSON.parse(data) as UpdateTaskWeightData;
|
||||||
|
const { task_id, weight, parent_task_id } = parsedData;
|
||||||
|
|
||||||
|
if (!task_id || weight === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the task weight in the database
|
||||||
|
await db.query(
|
||||||
|
`UPDATE tasks
|
||||||
|
SET weight = $1, updated_at = NOW()
|
||||||
|
WHERE id = $2`,
|
||||||
|
[weight, task_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the project ID for the task
|
||||||
|
const projectResult = await db.query("SELECT project_id FROM tasks WHERE id = $1", [task_id]);
|
||||||
|
const projectId = projectResult.rows[0]?.project_id;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,4 +57,10 @@ export enum SocketEvents {
|
|||||||
TASK_ASSIGNEES_CHANGE,
|
TASK_ASSIGNEES_CHANGE,
|
||||||
TASK_CUSTOM_COLUMN_UPDATE,
|
TASK_CUSTOM_COLUMN_UPDATE,
|
||||||
CUSTOM_COLUMN_PINNED_CHANGE,
|
CUSTOM_COLUMN_PINNED_CHANGE,
|
||||||
|
TEAM_MEMBER_ROLE_CHANGE,
|
||||||
|
|
||||||
|
// Task progress events
|
||||||
|
UPDATE_TASK_PROGRESS,
|
||||||
|
UPDATE_TASK_WEIGHT,
|
||||||
|
TASK_PROGRESS_UPDATED,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_assignees_change } from "./commands/on-task-assignees-change";
|
||||||
import { on_task_custom_column_update } from "./commands/on_custom_column_update";
|
import { on_task_custom_column_update } from "./commands/on_custom_column_update";
|
||||||
import { on_custom_column_pinned_change } from "./commands/on_custom_column_pinned_change";
|
import { on_custom_column_pinned_change } from "./commands/on_custom_column_pinned_change";
|
||||||
|
import { on_update_task_progress } from "./commands/on-update-task-progress";
|
||||||
|
import { on_update_task_weight } from "./commands/on-update-task-weight";
|
||||||
|
|
||||||
export function register(io: any, socket: Socket) {
|
export function register(io: any, socket: Socket) {
|
||||||
log(socket.id, "client registered");
|
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_ASSIGNEES_CHANGE.toString(), data => on_task_assignees_change(io, socket, data));
|
||||||
socket.on(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), data => on_task_custom_column_update(io, socket, data));
|
socket.on(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), data => on_task_custom_column_update(io, socket, data));
|
||||||
socket.on(SocketEvents.CUSTOM_COLUMN_PINNED_CHANGE.toString(), data => on_custom_column_pinned_change(io, socket, data));
|
socket.on(SocketEvents.CUSTOM_COLUMN_PINNED_CHANGE.toString(), data => on_custom_column_pinned_change(io, socket, data));
|
||||||
|
socket.on(SocketEvents.UPDATE_TASK_PROGRESS.toString(), data => on_update_task_progress(io, socket, data));
|
||||||
|
socket.on(SocketEvents.UPDATE_TASK_WEIGHT.toString(), data => on_update_task_weight(io, socket, data));
|
||||||
|
|
||||||
// socket.io built-in event
|
// socket.io built-in event
|
||||||
socket.on("disconnect", (reason) => on_disconnect(io, socket, reason));
|
socket.on("disconnect", (reason) => on_disconnect(io, socket, reason));
|
||||||
|
|||||||
@@ -38,5 +38,12 @@
|
|||||||
"createClient": "Create client",
|
"createClient": "Create client",
|
||||||
"searchInputPlaceholder": "Search by name or email",
|
"searchInputPlaceholder": "Search by name or email",
|
||||||
"hoursPerDayValidationMessage": "Hours per day must be a number between 1 and 24",
|
"hoursPerDayValidationMessage": "Hours per day must be a number between 1 and 24",
|
||||||
"noPermission": "No permission"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,15 @@
|
|||||||
"hide-start-date": "Hide Start Date",
|
"hide-start-date": "Hide Start Date",
|
||||||
"show-start-date": "Show Start Date",
|
"show-start-date": "Show Start Date",
|
||||||
"hours": "Hours",
|
"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": {
|
"labels": {
|
||||||
"labelInputPlaceholder": "Search or create",
|
"labelInputPlaceholder": "Search or create",
|
||||||
|
|||||||
@@ -38,5 +38,12 @@
|
|||||||
"createClient": "Crear cliente",
|
"createClient": "Crear cliente",
|
||||||
"searchInputPlaceholder": "Busca por nombre o email",
|
"searchInputPlaceholder": "Busca por nombre o email",
|
||||||
"hoursPerDayValidationMessage": "Las horas por día deben ser un número entre 1 y 24",
|
"hoursPerDayValidationMessage": "Las horas por día deben ser un número entre 1 y 24",
|
||||||
"noPermission": "Sin permiso"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,15 @@
|
|||||||
"hide-start-date": "Ocultar fecha de inicio",
|
"hide-start-date": "Ocultar fecha de inicio",
|
||||||
"show-start-date": "Mostrar fecha de inicio",
|
"show-start-date": "Mostrar fecha de inicio",
|
||||||
"hours": "Horas",
|
"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": {
|
"labels": {
|
||||||
"labelInputPlaceholder": "Buscar o crear",
|
"labelInputPlaceholder": "Buscar o crear",
|
||||||
|
|||||||
@@ -38,5 +38,12 @@
|
|||||||
"createClient": "Criar cliente",
|
"createClient": "Criar cliente",
|
||||||
"searchInputPlaceholder": "Pesquise por nome ou email",
|
"searchInputPlaceholder": "Pesquise por nome ou email",
|
||||||
"hoursPerDayValidationMessage": "As horas por dia devem ser um número entre 1 e 24",
|
"hoursPerDayValidationMessage": "As horas por dia devem ser um número entre 1 e 24",
|
||||||
"noPermission": "Sem permissão"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,15 @@
|
|||||||
"hide-start-date": "Ocultar data de início",
|
"hide-start-date": "Ocultar data de início",
|
||||||
"show-start-date": "Mostrar data de início",
|
"show-start-date": "Mostrar data de início",
|
||||||
"hours": "Horas",
|
"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": {
|
"labels": {
|
||||||
"labelInputPlaceholder": "Pesquisar ou criar",
|
"labelInputPlaceholder": "Pesquisar ou criar",
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import { IProjectManager } from '@/types/project/projectManager.types';
|
|||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/projects`;
|
const rootUrl = `${API_BASE_URL}/projects`;
|
||||||
|
|
||||||
|
interface UpdateProjectPayload {
|
||||||
|
id: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
export const projectsApiService = {
|
export const projectsApiService = {
|
||||||
getProjects: async (
|
getProjects: async (
|
||||||
index: number,
|
index: number,
|
||||||
@@ -78,13 +83,11 @@ export const projectsApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateProject: async (
|
updateProject: async (payload: UpdateProjectPayload): Promise<IServerResponse<IProjectViewModel>> => {
|
||||||
id: string,
|
const { id, ...data } = payload;
|
||||||
project: IProjectViewModel
|
|
||||||
): Promise<IServerResponse<IProjectViewModel>> => {
|
|
||||||
const q = toQueryString({ current_project_id: id });
|
const q = toQueryString({ current_project_id: id });
|
||||||
const url = `${rootUrl}/${id}${q}`;
|
const url = `${API_BASE_URL}/projects/${id}${q}`;
|
||||||
const response = await apiClient.put<IServerResponse<IProjectViewModel>>(`${url}`, project);
|
const response = await apiClient.patch<IServerResponse<IProjectViewModel>>(url, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Popconfirm,
|
Popconfirm,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Space,
|
Space,
|
||||||
|
Switch,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -96,6 +97,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
working_days: project?.working_days || 0,
|
working_days: project?.working_days || 0,
|
||||||
man_days: project?.man_days || 0,
|
man_days: project?.man_days || 0,
|
||||||
hours_per_day: project?.hours_per_day || 8,
|
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]
|
[project, projectStatuses, projectHealths]
|
||||||
);
|
);
|
||||||
@@ -155,6 +159,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
man_days: parseInt(values.man_days),
|
man_days: parseInt(values.man_days),
|
||||||
hours_per_day: parseInt(values.hours_per_day),
|
hours_per_day: parseInt(values.hours_per_day),
|
||||||
project_manager: selectedProjectManager,
|
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 =
|
const action =
|
||||||
@@ -214,6 +221,9 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
start_date: project.start_date ? dayjs(project.start_date) : null,
|
start_date: project.start_date ? dayjs(project.start_date) : null,
|
||||||
end_date: project.end_date ? dayjs(project.end_date) : null,
|
end_date: project.end_date ? dayjs(project.end_date) : null,
|
||||||
working_days: form.getFieldValue('start_date') && form.getFieldValue('end_date') ? calculateWorkingDays(form.getFieldValue('start_date'), form.getFieldValue('end_date')) : project.working_days || 0,
|
working_days: form.getFieldValue('start_date') && form.getFieldValue('end_date') ? calculateWorkingDays(form.getFieldValue('start_date'), form.getFieldValue('end_date')) : project.working_days || 0,
|
||||||
|
use_manual_progress: project.use_manual_progress || false,
|
||||||
|
use_weighted_progress: project.use_weighted_progress || false,
|
||||||
|
use_time_progress: project.use_time_progress || false,
|
||||||
});
|
});
|
||||||
setSelectedProjectManager(project.project_manager || null);
|
setSelectedProjectManager(project.project_manager || null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -284,6 +294,49 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
setIsFormValid(isValid);
|
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 (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
// loading={loading}
|
// loading={loading}
|
||||||
@@ -429,22 +482,15 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{/* <Form.Item
|
|
||||||
name="working_days"
|
|
||||||
label={t('estimateWorkingDays')}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
disabled // Make it read-only since it's calculated
|
|
||||||
/>
|
|
||||||
</Form.Item> */}
|
|
||||||
|
|
||||||
<Form.Item name="working_days" label={t('estimateWorkingDays')}>
|
<Form.Item name="working_days" label={t('estimateWorkingDays')}>
|
||||||
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
|
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item name="man_days" label={t('estimateManDays')}>
|
<Form.Item name="man_days" label={t('estimateManDays')}>
|
||||||
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
|
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="hours_per_day"
|
name="hours_per_day"
|
||||||
label={t('hoursPerDay')}
|
label={t('hoursPerDay')}
|
||||||
@@ -461,6 +507,62 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
|||||||
>
|
>
|
||||||
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
|
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
|
||||||
</Form.Item>
|
</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>
|
</Form>
|
||||||
|
|
||||||
{editMode && (
|
{editMode && (
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// Determine which progress input to show based on project settings
|
||||||
|
const showManualProgressInput = project?.use_manual_progress && !hasSubTasks && !isSubTask;
|
||||||
|
const showTaskWeightInput = project?.use_weighted_progress && isSubTask;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for progress updates from the server
|
||||||
|
socket?.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), (data) => {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket?.off(SocketEvents.TASK_PROGRESS_UPDATED.toString());
|
||||||
|
};
|
||||||
|
}, [socket, task.id, form]);
|
||||||
|
|
||||||
|
const handleProgressChange = (value: number | null) => {
|
||||||
|
if (connected && task.id && value !== null) {
|
||||||
|
socket?.emit(SocketEvents.UPDATE_TASK_PROGRESS.toString(), JSON.stringify({
|
||||||
|
task_id: task.id,
|
||||||
|
progress_value: value,
|
||||||
|
parent_task_id: task.parent_task_id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWeightChange = (value: number | null) => {
|
||||||
|
if (connected && task.id && value !== null) {
|
||||||
|
socket?.emit(SocketEvents.UPDATE_TASK_WEIGHT.toString(), JSON.stringify({
|
||||||
|
task_id: task.id,
|
||||||
|
weight: value,
|
||||||
|
parent_task_id: task.parent_task_id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
{showManualProgressInput && (
|
||||||
|
<Form.Item
|
||||||
|
name="progress_value"
|
||||||
|
label={
|
||||||
|
<Flex align="center" gap={4}>
|
||||||
|
{t('taskInfoTab.details.progressValue')}
|
||||||
|
<Tooltip title={t('taskInfoTab.details.progressValueTooltip')}>
|
||||||
|
<QuestionCircleOutlined />
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: t('taskInfoTab.details.progressValueRequired'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
message: t('taskInfoTab.details.progressValueRange'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
formatter={percentFormatter}
|
||||||
|
parser={percentParser}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const value = percentParser(e.target.value);
|
||||||
|
handleProgressChange(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTaskWeightInput && (
|
||||||
|
<Form.Item
|
||||||
|
name="weight"
|
||||||
|
label={
|
||||||
|
<Flex align="center" gap={4}>
|
||||||
|
{t('taskInfoTab.details.taskWeight')}
|
||||||
|
<Tooltip title={t('taskInfoTab.details.taskWeightTooltip')}>
|
||||||
|
<QuestionCircleOutlined />
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: t('taskInfoTab.details.taskWeightRequired'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskDrawerProgress;
|
||||||
@@ -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 TaskDrawerEstimation from './details/task-drawer-estimation/task-drawer-estimation';
|
||||||
import TaskDrawerPrioritySelector from './details/task-drawer-priority-selector/task-drawer-priority-selector';
|
import TaskDrawerPrioritySelector from './details/task-drawer-priority-selector/task-drawer-priority-selector';
|
||||||
import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billable';
|
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 {
|
interface TaskDetailsFormProps {
|
||||||
taskFormViewModel?: ITaskFormViewModel | null;
|
taskFormViewModel?: ITaskFormViewModel | null;
|
||||||
@@ -34,6 +36,7 @@ interface TaskDetailsFormProps {
|
|||||||
const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => {
|
const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => {
|
||||||
const { t } = useTranslation('task-drawer/task-drawer');
|
const { t } = useTranslation('task-drawer/task-drawer');
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const { project } = useAppSelector(state => state.projectReducer);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!taskFormViewModel) {
|
if (!taskFormViewModel) {
|
||||||
@@ -53,6 +56,8 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
|||||||
labels: task?.labels || [],
|
labels: task?.labels || [],
|
||||||
billable: task?.billable || false,
|
billable: task?.billable || false,
|
||||||
notify: [],
|
notify: [],
|
||||||
|
progress_value: task?.progress_value || null,
|
||||||
|
weight: task?.weight || null,
|
||||||
});
|
});
|
||||||
}, [taskFormViewModel, form]);
|
}, [taskFormViewModel, form]);
|
||||||
|
|
||||||
@@ -89,6 +94,8 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
|||||||
hours: 0,
|
hours: 0,
|
||||||
minutes: 0,
|
minutes: 0,
|
||||||
billable: false,
|
billable: false,
|
||||||
|
progress_value: null,
|
||||||
|
weight: null,
|
||||||
}}
|
}}
|
||||||
onFinish={handleSubmit}
|
onFinish={handleSubmit}
|
||||||
>
|
>
|
||||||
@@ -103,7 +110,7 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) =>
|
|||||||
|
|
||||||
<Form.Item name="assignees" label={t('taskInfoTab.details.assignees')}>
|
<Form.Item name="assignees" label={t('taskInfoTab.details.assignees')}>
|
||||||
<Flex gap={4} align="center">
|
<Flex gap={4} align="center">
|
||||||
<Avatars members={taskFormViewModel?.task?.names || []} />
|
<Avatars members={taskFormViewModel?.task?.assignee_names || []} />
|
||||||
<TaskDrawerAssigneeSelector
|
<TaskDrawerAssigneeSelector
|
||||||
task={(taskFormViewModel?.task as ITaskViewModel) || null}
|
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} />
|
<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')}>
|
<Form.Item name="priority" label={t('taskInfoTab.details.priority')}>
|
||||||
<TaskDrawerPrioritySelector task={taskFormViewModel?.task as ITaskViewModel} />
|
<TaskDrawerPrioritySelector task={taskFormViewModel?.task as ITaskViewModel} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -55,10 +55,9 @@ const initialState: TaskListState = {
|
|||||||
|
|
||||||
export const getProject = createAsyncThunk(
|
export const getProject = createAsyncThunk(
|
||||||
'project/getProject',
|
'project/getProject',
|
||||||
async (projectId: string, { rejectWithValue, dispatch }) => {
|
async (projectId: string, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await projectsApiService.getProject(projectId);
|
const response = await projectsApiService.getProject(projectId);
|
||||||
dispatch(setProject(response.body));
|
|
||||||
return response.body;
|
return response.body;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return rejectWithValue(error instanceof Error ? error.message : 'Failed to fetch project');
|
return rejectWithValue(error instanceof Error ? error.message : 'Failed to fetch project');
|
||||||
|
|||||||
42
worklenz-frontend/src/features/projects/projects.slice.ts
Normal file
42
worklenz-frontend/src/features/projects/projects.slice.ts
Normal 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;
|
||||||
@@ -377,6 +377,41 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
};
|
};
|
||||||
}, [socket, dispatch]);
|
}, [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) => {
|
const handleDragStart = useCallback(({ active }: DragStartEvent) => {
|
||||||
setActiveId(active.id as string);
|
setActiveId(active.id as string);
|
||||||
|
|
||||||
|
|||||||
@@ -58,4 +58,9 @@ export enum SocketEvents {
|
|||||||
TASK_CUSTOM_COLUMN_UPDATE,
|
TASK_CUSTOM_COLUMN_UPDATE,
|
||||||
CUSTOM_COLUMN_PINNED_CHANGE,
|
CUSTOM_COLUMN_PINNED_CHANGE,
|
||||||
TEAM_MEMBER_ROLE_CHANGE,
|
TEAM_MEMBER_ROLE_CHANGE,
|
||||||
|
|
||||||
|
// Task progress events
|
||||||
|
UPDATE_TASK_PROGRESS,
|
||||||
|
UPDATE_TASK_WEIGHT,
|
||||||
|
TASK_PROGRESS_UPDATED,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -41,4 +41,28 @@ export interface IProjectViewModel extends IProject {
|
|||||||
|
|
||||||
team_member_default_view?: string;
|
team_member_default_view?: string;
|
||||||
working_days?: number;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,42 +17,28 @@ export interface ITaskAssignee {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ITask {
|
export interface ITask {
|
||||||
assignees?: ITaskAssignee[] | string[];
|
id: string;
|
||||||
assignees_ids?: any[];
|
name: string;
|
||||||
description?: string;
|
description: string;
|
||||||
done?: boolean;
|
status_id: string;
|
||||||
end?: string | Date;
|
priority: string;
|
||||||
end_date?: string | Date;
|
start_date: string;
|
||||||
id?: string;
|
end_date: string;
|
||||||
name?: string;
|
total_hours: number;
|
||||||
resize_valid?: boolean;
|
total_minutes: number;
|
||||||
start?: string | Date;
|
billable: boolean;
|
||||||
start_date?: string | Date;
|
phase_id: string;
|
||||||
_start?: Date;
|
parent_task_id: string | null;
|
||||||
_end?: Date;
|
project_id: string;
|
||||||
color_code?: string;
|
team_id: string;
|
||||||
priority?: string;
|
task_key: string;
|
||||||
priority_id?: string;
|
labels: string[];
|
||||||
status?: string;
|
assignees: string[];
|
||||||
status_id?: string;
|
names: string[];
|
||||||
project_id?: string;
|
sub_tasks_count: number;
|
||||||
reporter_id?: string;
|
manual_progress: boolean;
|
||||||
created_at?: string;
|
progress_value: number | null;
|
||||||
updated_at?: string;
|
weight: number | null;
|
||||||
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[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProjectMemberViewModel extends IProjectMember {
|
export interface IProjectMemberViewModel extends IProjectMember {
|
||||||
@@ -65,23 +51,17 @@ export interface IProjectMemberViewModel extends IProjectMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ITaskViewModel extends ITask {
|
export interface ITaskViewModel extends ITask {
|
||||||
task_key?: string;
|
|
||||||
created_from_now?: string;
|
created_from_now?: string;
|
||||||
updated_from_now?: string;
|
updated_from_now?: string;
|
||||||
reporter?: string;
|
reporter?: string;
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
sub_tasks_count?: number;
|
|
||||||
is_sub_task?: boolean;
|
is_sub_task?: boolean;
|
||||||
status_color?: string;
|
status_color?: string;
|
||||||
status_color_dark?: string;
|
status_color_dark?: string;
|
||||||
attachments_count?: number;
|
attachments_count?: number;
|
||||||
complete_ratio?: number;
|
complete_ratio?: number;
|
||||||
names?: InlineMember[];
|
assignee_names?: InlineMember[];
|
||||||
labels?: ITaskLabel[];
|
task_labels?: ITaskLabel[];
|
||||||
timer_start_time?: number;
|
timer_start_time?: number;
|
||||||
phase_id?: string;
|
|
||||||
billable?: boolean;
|
|
||||||
recurring?: boolean;
|
recurring?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user